diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 2ac6aee..2138fed 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,9 +1,4 @@
-# Monthly update PRs into develop: one batch a month, merged as one PR so develop deploys once.
-# The cooldown holds a release back for 7 days so a broken or hijacked version is caught upstream
-# first; security updates ignore both the schedule and the cooldown.
-#
-# Every ignore rule below is a deliberate hold; its reason is the
-# comment next to it.
+# The 7-day cooldown lets a broken or hijacked release be caught upstream first.
version: 2
updates:
- package-ecosystem: "nuget"
@@ -21,7 +16,7 @@ updates:
patterns: ["*"]
exclude-patterns: ["QuestPDF"]
update-types: [minor, patch]
- # Always alone: a QuestPDF bump must pass .github/scripts/questpdf-cpu-check.sh before it is merged
+ # Alone: a QuestPDF bump must pass .github/scripts/questpdf-cpu-check.sh before merging
questpdf:
patterns: ["QuestPDF"]
ignore:
@@ -79,8 +74,7 @@ updates:
- dependency-name: "@types/node"
versions: [">=23"]
- # One entry for all three Dockerfiles: the SDK and runtime references are shared between
- # Backend and the QuestPDF harness and must move in one PR (see "SDK image" in the register)
+ # One entry so the SDK/runtime references shared across Dockerfiles move in one PR
- package-ecosystem: "docker"
directories:
- "/src/backend"
@@ -95,8 +89,7 @@ updates:
commit-message:
prefix: "chore(deps)"
groups:
- # Dependabot names a Docker dependency by its image path without the registry:
- # dotnet/sdk and dotnet/aspnet, not mcr.microsoft.com/dotnet/...
+ # Dependabot names Docker images without the registry (dotnet/sdk, not mcr.microsoft.com/...)
dotnet-images:
patterns: ["dotnet/*"]
other-images:
diff --git a/.github/scripts/check-deps.sh b/.github/scripts/check-deps.sh
index 6c2bb5c..36b20f7 100644
--- a/.github/scripts/check-deps.sh
+++ b/.github/scripts/check-deps.sh
@@ -1,32 +1,6 @@
#!/usr/bin/env bash
-# Supply-chain gate for pkgdiff: NuGet, npm and (optionally) the built images.
-#
-# bash .github/scripts/check-deps.sh run from any directory
-#
-# TRIVY=1 also scan ${BACKEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-backend:local} and
-# ${FRONTEND_IMAGE:-ghcr.io/pkgdiff-dev/pkgdiff-frontend:local} with the
-# pinned Trivy image (the defaults are what docker-compose.build.yml builds)
-# CHECK_DEPS_DOCKER=auto auto (default): use dotnet / npm from PATH, and the pinned SDK / Node
-# image for a tool that is missing; 1: always containers; 0: never
-# PKGDIFF_ROOT=
check another checkout (default: the checkout this script is in)
-#
-# Checks (every check runs, also after a failure):
-# sdk-image-pin the SDK image is pinned by digest and is the same reference in
-# src/backend/Dockerfile, src/backend/Directory.Build.props and the QuestPDF harness
-# nuget-locked-restore dotnet restore --locked-mode with the audit gate (lock drift, NU1901-NU1904)
-# nuget-vulnerable dotnet list package --vulnerable --include-transitive, read as JSON
-# npm-audit-prod npm audit --omit=dev (production dependencies must be clean)
-# npm-audit-high .github/scripts/npm-audit-gate.mjs with src/frontend/audit-allowlist.json
-# trivy-backend TRIVY=1 only: fixable HIGH/CRITICAL in the backend image
-# trivy-frontend TRIVY=1 only: fixable HIGH/CRITICAL in the frontend image
-#
-# Output: one "PASS " or "FAIL : " line per check (evidence is indented
-# above it), then "check-deps: N passed, M failed". A check that could not be carried out is a
-# FAIL whose detail starts with "could not check"; it is never reported as a pass.
-# Exit status: 0 when every check passed, otherwise 1.
-#
-# The checkout is never modified: the manifests are copied to a temporary directory and the
-# tools run on that copy, either on this machine or inside a container.
+# Supply-chain gate: NuGet and npm audits, plus image scans with TRIVY=1. Exit 0 when all pass.
+# Env: TRIVY=1, CHECK_DEPS_DOCKER=auto|1|0, PKGDIFF_ROOT=, BACKEND_IMAGE, FRONTEND_IMAGE.
set -uo pipefail
export DOTNET_CLI_UI_LANGUAGE=en DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
@@ -51,8 +25,7 @@ case "$MODE" in
;;
esac
-# Git Bash rewrites arguments that look like POSIX paths (/work, /var/run/docker.sock) before
-# docker.exe sees them; turn that off and use the socket form Docker Desktop accepts.
+# Git Bash rewrites POSIX-looking arguments before docker.exe sees them; turn that off.
DOCKER_SOCKET='/var/run/docker.sock'
case "$(uname -s 2>/dev/null)" in
MINGW* | MSYS* | CYGWIN*)
@@ -121,7 +94,6 @@ one_line() {
tr '\r\n' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}
-# Prints the image of the "AS build" stage of a Dockerfile.
build_stage_image() {
[ -f "$1" ] || return 1
tr -d '\r' <"$1" |
@@ -129,13 +101,11 @@ build_stage_image() {
head -n 1
}
-# A status that decides something never comes from `writer | grep -q`: under pipefail a writer
-# killed by SIGPIPE makes a match read as "no match". These tests read a here-string or a file.
+# No `writer | grep -q` for decisions: under pipefail a SIGPIPE'd writer turns a match into a miss.
is_pinned() {
grep -Eq '^[A-Za-z0-9][A-Za-z0-9./_-]*:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$' <<<"$1"
}
-# Decides whether a tool set runs in a container: 0 = container, 1 = this machine.
use_container() {
local tool
case "$MODE" in
@@ -148,9 +118,7 @@ use_container() {
return 1
}
-# run_in_container [docker create options]
-# Copies to /work in a fresh container, runs /work/run.sh there and copies /work/out back.
-# No bind mount is used, so nothing depends on how this machine shares its file system with Docker.
+# Copies files in and out instead of bind-mounting, so host file sharing with Docker doesn't matter.
run_in_container() {
local image="$1" dir="$2" cid rc
shift 2
@@ -169,8 +137,6 @@ run_in_container() {
return "$rc"
}
-# ---------------------------------------------------------------- sdk-image-pin
-
check_sdk_image_pin() {
local name='sdk-image-pin'
local dockerfile="$ROOT/src/backend/Dockerfile"
@@ -205,8 +171,6 @@ check_sdk_image_pin() {
pass "$name"
}
-# ---------------------------------------------------------------- NuGet
-
prepare_nuget() {
local dir="$1" rel
mkdir -p "$dir" || return 1
@@ -240,7 +204,6 @@ echo "$?" >out/vulnerable.rc
EOF
}
-# Unique "error XX0000: message" lines of a restore log, without the trailing "[project path]".
restore_errors() {
tr -d '\r' <"$1" | sed -n 's/.*\(error [A-Z][A-Z]*[0-9][0-9]*: .*\)$/\1/p' | sed 's/ \[[^][]*\]$//' | sort -u
}
@@ -286,11 +249,9 @@ check_nuget() {
return
fi
- # nuget-locked-restore
rc="$(read_rc "$out/locked-restore.rc")"
if [ "$rc" = "0" ] && grep -Eq 'NU190[05]' "$out/locked-restore.log"; then
- # With packages in the local cache, a restore still succeeds when the advisory data cannot be
- # downloaded; it only warns (NU1900 / NU1905). The audit did not run, so this is not a pass.
+ # A cached restore succeeds with only a warning when advisory data can't load: no audit ran.
tr -d '\r' <"$out/locked-restore.log" | grep -oE 'warning NU190[05]: [^[]*' | sort -u | evidence
unchecked nuget-locked-restore "the restore succeeded, but the advisory data could not be loaded (NU1900/NU1905), so the audit did not run"
elif [ "$rc" = "0" ]; then
@@ -315,7 +276,6 @@ check_nuget() {
fi
fi
- # nuget-vulnerable
rc="$(read_rc "$out/plain-restore.rc")"
if [ "$rc" != "0" ]; then
restore_errors "$out/plain-restore.log" 2>/dev/null | head -n 10 | evidence
@@ -343,7 +303,6 @@ check_nuget() {
return
fi
if grep -q '"vulnerabilities"' "$out/vulnerable.json"; then
- # The report lists only affected packages, so every id / resolvedVersion pair in it is a finding.
findings="$(grep -oE '"(id|resolvedVersion|severity|advisoryurl)": *"[^"]*"' "$out/vulnerable.json" |
awk -F'"' '
$2 == "id" { id = $4 }
@@ -358,8 +317,6 @@ check_nuget() {
pass nuget-vulnerable
}
-# ---------------------------------------------------------------- npm
-
prepare_npm() {
local dir="$1" file
mkdir -p "$dir/Frontend" "$dir/scripts" || return 1
@@ -437,7 +394,7 @@ check_npm() {
return
fi
- # npm-audit-prod: npm audit --omit=dev exits non-zero on findings and on errors; the JSON tells them apart.
+ # npm audit exits non-zero on findings and on errors; only the JSON tells them apart.
rc="$(read_rc "$out/audit-prod.rc")"
if ! grep -q '"auditReportVersion"' "$out/audit-prod.json" 2>/dev/null; then
cat "$out/audit-prod.json" "$out/audit-prod.err" 2>/dev/null | tail -n 10 | evidence
@@ -451,13 +408,9 @@ check_npm() {
fail npm-audit-prod "production dependencies have advisories: $(printf '%s' "$packages" | one_line)"
fi
- # npm-audit-high: replaces a bare `npm audit --audit-level=high`, which cannot tell a reviewed,
- # time-boxed exception from a new advisory. The gate keeps its own 0 / 1 / 2 exit status visible.
rc="$(read_rc "$out/gate.rc")"
case "$rc" in
0)
- # Only the entries of the "Accepted exceptions" block are reviewed exceptions. The "Not gated"
- # blocks list advisories below high that nobody reviewed; they keep their own label.
tr -d '\r' <"$out/gate.log" | sed -n \
-e '1p' \
-e '/^Accepted exceptions/,/^$/s/^ - \(GHSA-.*\)$/accepted exception: \1/p' \
@@ -481,8 +434,6 @@ check_npm() {
esac
}
-# ---------------------------------------------------------------- Trivy
-
check_trivy() {
local name="$1" image="$2" log="$WORK/$1.log" rc detail
@@ -513,24 +464,16 @@ check_trivy() {
unchecked "$name" "trivy exit 0, but it detected no OS packages in $image, so nothing was scanned"
fi
elif [ "$rc" -eq 1 ] && grep -Eq '^Total: [1-9][0-9]* \(' "$log"; then
- # Exit 1 together with a report is the only result that means "findings".
- # grep reads the file itself: behind a pipe, `grep -q` stops at the first match, the writer
- # dies of SIGPIPE on a large report, and pipefail turns the match into "false". The pattern
- # has no end anchor, so a trailing CR does not matter.
- # The whole report: whoever fixes the image needs the package and the fixed version.
+ # Only exit 1 together with a report means findings.
grep -v "$(printf '\tINFO\t')" "$log" | evidence
fail "$name" "fixable HIGH/CRITICAL findings in $image: $(tr -d '\r' <"$log" | grep -E '^Total: ' | one_line)"
else
- # No report: the scanner never started (docker run exits 125, 126 or 127 when the image cannot
- # be pulled or run) or it stopped before the scan finished. Nothing is known about the image.
tail -n 10 "$log" | evidence
detail="$(grep -E 'FATAL|[Ee]rror' "$log" | head -n 1 | one_line)"
unchecked "$name" "trivy did not complete (exit $rc)${detail:+: $detail}"
fi
}
-# ---------------------------------------------------------------- run
-
echo "check-deps: root $ROOT"
check_sdk_image_pin
diff --git a/.github/scripts/questpdf-cpu-check.sh b/.github/scripts/questpdf-cpu-check.sh
index f1f87b5..620318b 100644
--- a/.github/scripts/questpdf-cpu-check.sh
+++ b/.github/scripts/questpdf-cpu-check.sh
@@ -1,15 +1,5 @@
#!/usr/bin/env bash
-# QuestPDF CPU gate: renders the PDF export document inside a Linux container and reports
-# whether the QuestPDF native library works on a CPU without AVX2 (the production CPU is
-# an Ivy Bridge Xeon: AVX, but no AVX2, FMA or BMI2).
-#
-# --native render on the real CPU of this machine. Run it on the production
-# server before the backend is recreated with a new QuestPDF version.
-# --emulate render under QEMU with the IvyBridge CPU model, after a self-test
-# proves that the emulator really traps AVX2, FMA and BMI2.
-# --version QuestPDF version to test (default: src/backend/Directory.Packages.props)
-#
-# Everything runs in containers; nothing is executed on the host itself.
+# Checks in containers that QuestPDF renders on a CPU without AVX2, FMA or BMI2 (production).
# Exit status: 0 = PASS, 1 = FAIL, 2 = usage error.
set -euo pipefail
@@ -67,8 +57,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CONTEXT="$ROOT/.github/scripts/questpdf-cpu-check"
if [ -z "$VERSION" ]; then
- # sed stops at the first match itself. With `| head -n 1`, a second matching line could kill
- # sed with SIGPIPE, and under `set -e -o pipefail` that ends the script without a message.
+ # sed quits itself: `| head -n 1` could SIGPIPE sed and silently abort under pipefail.
VERSION="$(sed -n '/ typeof(QuestPDF.Settings).GetProperty(name, BindingFlags.Public | BindingFlags.Static))
.FirstOrDefault(property => property is { CanWrite: true });
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b798865..a593e3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,4 @@
-# Runs on every pull request into develop or master, and as the gate of the publish workflow.
-# No toolchain is installed on the runner: the tests run inside the SDK and Node images pinned in
-# src/backend/Dockerfile and src/frontend/Dockerfile, so CI uses exactly what the published images use.
+# PR gate and publish gate; runs inside the SDK and Node images the Dockerfiles pin.
name: CI
on:
@@ -25,8 +23,7 @@ jobs:
with:
persist-credentials: false
- # The database tests run against the PostgreSQL image docker-compose.yml pins; without a server
- # they would be skipped. The password belongs to this throwaway container only.
+ # Without a database server the database tests would be skipped
- name: Run the tests in the pinned SDK image, against the pinned PostgreSQL
run: |
sdk="$(sed -n 's/^FROM \(mcr\.microsoft\.com\/dotnet\/sdk:[^ ]*\) AS build.*/\1/p' src/backend/Dockerfile)"
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e95a2ed..e7917b7 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,9 +1,4 @@
-# Runs deploy/remote.sh on the server over SSH.
-# - Automatically: publish.yml calls it after the images of a push to develop are published,
-# while the repository variable PKGDIFF_AUTO_DEPLOY is "true".
-# - By hand: Actions > Deploy > Run workflow (deploy a tag, roll back, or show the status).
-# It does nothing until these repository secrets exist (docs/ops/deploy-ghcr.md):
-# DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS; optional DEPLOY_PORT, DEPLOY_PATH
+# Runs deploy/remote.sh on the server over SSH; called by publish.yml or run by hand.
name: Deploy
on:
@@ -39,7 +34,6 @@ jobs:
environment: production
runs-on: ubuntu-24.04
timeout-minutes: 20
- # One deploy at a time, in the order they were started; a running one is never cancelled
concurrency:
group: deploy-production
cancel-in-progress: false
@@ -87,10 +81,7 @@ jobs:
request="$ACTION"
if [ "$ACTION" = deploy ]; then request="deploy $TAG $REVISION"; fi
- # Every word of the request was checked above. A key installed with remote.sh as its forced
- # command ignores the script path given here and reads only the request.
- # The log of this job may be public: it shows the "deploy:" lines of the server only, the full
- # output (paths, container state) stays in deploy.log on the runner and on the server
+ # The job log may be public: show only the server's "deploy:" lines
status=0
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=10 \
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6daf049..a24f128 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,12 +1,4 @@
-# Builds the backend and frontend images once and pushes them to GHCR, but only after the CI gate
-# and the image scan pass. The server never builds: it pulls a tag with deploy/deploy.sh.
-#
-# A push to develop is also deployed, by deploy.yml, while the repository variable
-# PKGDIFF_AUTO_DEPLOY is "true" (Settings > Secrets and variables > Actions > Variables).
-#
-# Tags: sha-<7 hex> on every run (never moves), the branch name (develop, master) on a branch push,
-# and .. plus . on a v* tag. ":buildcache" holds build layers
-# only; it is not a runnable image.
+# Builds, scans and pushes the images to GHCR after the CI gate; the server only pulls them.
name: Publish images
on:
@@ -74,11 +66,9 @@ jobs:
run: |
docker buildx create --name pkgdiff --driver docker-container --use
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- # Every build reruns the OS upgrade layer: a fix published during the day (libx11 on 2026-10-18)
- # is in the next image, not only in tomorrow's. The layers after it are small.
+ # Rerun the OS upgrade layer on every build so same-day security fixes are included
upgrade_key="$GITHUB_RUN_ID.$GITHUB_RUN_ATTEMPT"
- # $1 image name, $2 build context, $3 build argument that reruns the OS upgrade layer
build() {
docker buildx build "$2" \
--tag "$IMAGE_PREFIX-$1:$PRIMARY" \
@@ -96,8 +86,6 @@ jobs:
build backend src/backend APT_UPGRADE_DATE
build frontend src/frontend APK_UPGRADE_DATE
- # The frontend image has two roles, nginx and the render server; both have to start. The
- # render server runs as it does in compose: unprivileged, on a read-only file system.
- name: Start the frontend image in both roles
run: |
image="$IMAGE_PREFIX-frontend:$PRIMARY"
@@ -115,7 +103,6 @@ jobs:
docker exec pkgdiff-ssr-check wget -q -O - http://127.0.0.1:4000/terms | grep -q 'ng-server-context="ssr"'
docker rm -f pkgdiff-ssr-check
- # The package checks ran in the gate; this run adds the scan of the two images just built
- name: Scan the images
run: |
CHECK_DEPS_DOCKER=1 TRIVY=1 \
@@ -146,8 +133,7 @@ jobs:
echo "Deploy on the server: \`bash deploy/deploy.sh $PRIMARY\`"
} >> "$GITHUB_STEP_SUMMARY"
- # develop is the branch the server runs. The switch is a repository variable, so a release that
- # needs a manual step on the server first can be held back without touching this file.
+ # A repository variable, so a release can be held back without editing this file
deploy:
name: Deploy
needs: publish
diff --git a/deploy/backup.sh b/deploy/backup.sh
index 9076742..21eba4d 100644
--- a/deploy/backup.sh
+++ b/deploy/backup.sh
@@ -5,21 +5,8 @@
# bash deploy/backup.sh --check restore the newest nightly backup into a scratch database and report
# bash deploy/backup.sh --check the same for one dump (a dump deploy/deploy.sh took works too)
# bash deploy/backup.sh --list show the backups that exist
-#
-# A backup is a plain pg_dump, gzipped: the format deploy/deploy.sh writes before a deploy, so one
-# restore procedure (docs/ops/operations.md) fits both. Nightly backups live in their own folder,
-# because the deploy script keeps a fixed number of its own dumps and must not count these.
-#
-# A dump that cannot be taken or is cut off is deleted and the script exits 1; older backups are
-# only deleted after a new one was verified. --check never touches the live database: it creates
-# the scratch database, loads the dump, counts what arrived and drops the scratch database again.
-#
-# Environment:
-# PKGDIFF_BACKUP_DIR where backups go (default ~/pkgdiff-backups; nightly ones in nightly/)
-# PKGDIFF_NIGHTLY_KEEP_DAYS days a nightly backup is kept (default 14)
-# PKGDIFF_DB_CONTAINER the database container (default pkgdiff-db)
set -euo pipefail
-# A dump holds password hashes and encrypted settings: nobody but the owner reads it, from its first byte on
+# Dumps hold password hashes and encrypted settings: owner-only from the first byte
umask 077
# cron runs with a short PATH
@@ -71,8 +58,7 @@ nightly_files() {
newest_nightly() { nightly_files | sort | tail -n 1; }
-# The names carry the time of the dump, so a name is compared with the name a dump taken exactly
-# KEEP_DAYS ago would have. The newest backup is never deleted, whatever the clock says.
+# Names sort by dump time, so they compare against a cutoff name; the newest is always kept.
prune() {
local cutoff newest old
cutoff="pkgdiffdb-$(date -u -d "-$KEEP_DAYS days" +%Y%m%dT%H%M%SZ).sql.gz"
@@ -103,7 +89,6 @@ backup() {
drop_scratch() { sql -d postgres -c "DROP DATABASE IF EXISTS $SCRATCH" >/dev/null 2>&1 || true; }
-# Tables in the public schema, applied migrations, users: enough to tell a real restore from an empty one
figures() {
sql -d "$1" -c "SELECT (SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE') || ' tables, ' || (SELECT count(*) FROM \"__EFMigrationsHistory\") || ' migrations, ' || (SELECT count(*) FROM \"AspNetUsers\") || ' users, ' || (SELECT count(*) FROM \"CachedPackages\") || ' cached packages'"
}
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index 0d0325b..1d9e55f 100644
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -6,24 +6,6 @@
# bash deploy/deploy.sh --status show the configured tag and what is running
#
# is one the publish workflow pushed: sha-<7 hex> (never moves), develop, master or a version.
-#
-# A deploy pulls both images first and changes nothing when a pull fails. It then tags the images
-# that run now as ":rollback", writes the tag to .env, recreates the containers without building
-# and waits until the backend and the frontend answer on their loopback ports and, for an image
-# that renders on the server, until a page arrives rendered. When they do not, it switches back
-# to the ":rollback" images by itself and exits 1.
-#
-# The database and the volumes are never changed by this script. Before a deploy it takes a dump of
-# the database, because a rollback switches images only: it does not undo a migration that the new
-# backend applied on start, and that dump is the way back.
-#
-# Environment:
-# PKGDIFF_GHCR_TOKEN_FILE file with a read-only GHCR token (default ~/.pkgdiff-ghcr-token);
-# when it exists, the script logs in to ghcr.io before it pulls
-# PKGDIFF_GHCR_USER GitHub user the token belongs to (default fleizean)
-# PKGDIFF_HEALTH_TIMEOUT seconds to wait for a healthy stack (default 180)
-# PKGDIFF_BACKUP_DIR where the dumps go (default ~/pkgdiff-backups)
-# PKGDIFF_BACKUP_KEEP how many dumps to keep (default 10)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -43,7 +25,6 @@ LEGACY_BACKEND_URL="${PKGDIFF_LEGACY_BACKEND_URL:-http://127.0.0.1:8080/api/pack
FRONTEND_URL="${PKGDIFF_FRONTEND_URL:-http://127.0.0.1:8090/}"
HEALTH_TIMEOUT="${PKGDIFF_HEALTH_TIMEOUT:-180}"
HEALTH_INTERVAL="${PKGDIFF_HEALTH_INTERVAL:-5}"
-# What Angular writes on the root element of a page it rendered on the server
RENDERED_MARKER='ng-server-context="ssr"'
BACKUP_DIR="${PKGDIFF_BACKUP_DIR:-$HOME/pkgdiff-backups}"
@@ -69,7 +50,6 @@ revision() {
docker image inspect "$1" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true
}
-# Image ID that the container of a service runs; empty when there is no such container.
running_image() { docker inspect "pkgdiff-$1" --format '{{.Image}}' 2>/dev/null || true; }
# The shell value wins over the one in .env, so compose sees the tag before .env is written.
@@ -86,7 +66,6 @@ configured_tag() {
sed -n 's/^PKGDIFF_IMAGE_TAG=//p' "$ENV_FILE" | tail -n 1 | tr -d '\r"'"'"
}
-# Replaces (or appends) the one PKGDIFF_IMAGE_TAG line; every other line of .env stays as it is.
write_tag() {
local tag="$1" tmp
tmp="$(mktemp "$ENV_FILE.XXXXXX")" || return 1
@@ -122,9 +101,7 @@ preflight() {
fi
}
-# The backend image runs as an unprivileged user and compose mounts the settings file into it.
-# A file only root can read would stop the new backend from starting, so this is settled before
-# anything changes: the file is given to the group of that user (it stays unreadable for others).
+# The non-root backend user must read the mounted settings file; settle that before any change.
settings_readable() {
local backend user
backend="$(image backend "$1")"
@@ -147,9 +124,7 @@ login() {
die "login to ghcr.io failed with the token in $TOKEN_FILE; nothing was changed"
}
-# Dumps the database next to the earlier dumps and keeps the newest ones. A dump that cannot be
-# taken or is not complete stops the deploy before anything was changed. Without a running
-# database container (a first install) there is nothing to dump.
+# A rollback does not undo migrations, so this dump is the way back; a bad dump aborts the deploy.
backup_database() {
local tag="$1" file old
if [ -z "$(running_image db)" ]; then
@@ -167,7 +142,6 @@ backup_database() {
rm -f "$file.partial"
die "the database backup failed; nothing was changed"
fi
- # The names start with the time of the dump, so their order is the order in time
ls -1 "$BACKUP_DIR"/pkgdiffdb-*.sql.gz 2>/dev/null | sort -r | tail -n "+$((BACKUP_KEEP + 1))" | while IFS= read -r old; do
rm -f "$old"
done
@@ -175,8 +149,7 @@ backup_database() {
http_ok() { curl -fsS -o /dev/null --max-time 10 "$1" 2>/dev/null; }
-# Ready means the database and Redis answer too. An image from before /health/ready existed answers
-# 404 there and is asked through its old health endpoint, so rolling back to it still works.
+# Older images answer 404 on /health/ready; fall back to their old endpoint so rollback works.
backend_ok() {
local code
code="$(curl -s -o /dev/null --max-time 10 -w '%{http_code}' "$BACKEND_URL" 2>/dev/null || true)"
@@ -187,14 +160,11 @@ backend_ok() {
esac
}
-# An image that renders its pages on the server says so in a label; an older one serves the
-# client-side application only.
renders_on_server() {
[ "$(docker image inspect "$1" --format '{{index .Config.Labels "dev.pkgdiff.rendering"}}' 2>/dev/null || true)" = 'server' ]
}
-# nginx answers with the client-side page while the render server is down, so a 200 alone says
-# nothing about it: the page itself has to say that it was rendered.
+# nginx serves the client-side page while the render server is down, so a 200 proves nothing.
page_is_rendered() { curl -fsS --max-time 20 "$FRONTEND_URL" 2>/dev/null | grep -q "$RENDERED_MARKER"; }
# Both ports must stay on the loopback interface: the host nginx is the only public entry.
@@ -203,7 +173,6 @@ ports_ok() {
[ "$(docker port pkgdiff-frontend 80/tcp 2>/dev/null)" = '127.0.0.1:8090' ]
}
-# Waits until the containers run the images of this tag and answer; prints the reason when they do not.
wait_healthy() {
local tag="$1" deadline=$((SECONDS + HEALTH_TIMEOUT)) s wanted problem
while :; do
@@ -240,8 +209,7 @@ have_rollback_point() {
done
}
-# The tag to write when going back: the earlier tag name while it still names the rollback
-# images, otherwise the local ":rollback" tag (a moving tag such as develop has moved on).
+# A moving tag may no longer name the rollback images; then the local ":rollback" tag is used.
rollback_tag_name() {
local previous="$1" s
if [ -n "$previous" ] && [ "$previous" != "$ROLLBACK_TAG" ] && valid_tag "$previous"; then
@@ -257,7 +225,6 @@ rollback_tag_name() {
printf '%s' "$ROLLBACK_TAG"
}
-# Remembers what runs now, image and tag name, unless this deploy is a repeat of the last one.
mark_rollback_point() {
local tag="$1" previous="$2" s running changed=0
for s in $SERVICES; do
diff --git a/deploy/grafana/provisioning/dashboards/pkgdiff.yml b/deploy/grafana/provisioning/dashboards/pkgdiff.yml
index 35334d0..9a2f66f 100644
--- a/deploy/grafana/provisioning/dashboards/pkgdiff.yml
+++ b/deploy/grafana/provisioning/dashboards/pkgdiff.yml
@@ -1,4 +1,4 @@
-# Dashboards from deploy/grafana/dashboards, read-only in Grafana: a change belongs in the repository
+# Read-only in Grafana: dashboards are changed in the repository
apiVersion: 1
providers:
diff --git a/deploy/grafana/provisioning/datasources/prometheus.yml b/deploy/grafana/provisioning/datasources/prometheus.yml
index 28927d1..d37720b 100644
--- a/deploy/grafana/provisioning/datasources/prometheus.yml
+++ b/deploy/grafana/provisioning/datasources/prometheus.yml
@@ -1,5 +1,4 @@
-# The Prometheus of this compose stack. Its own name and uid, so it never collides with a data
-# source that was created by hand before.
+# Own name and uid so it never collides with a data source created by hand.
apiVersion: 1
datasources:
diff --git a/deploy/nginx/cloudflare-realip.conf b/deploy/nginx/cloudflare-realip.conf
index a3c94df..2a21793 100644
--- a/deploy/nginx/cloudflare-realip.conf
+++ b/deploy/nginx/cloudflare-realip.conf
@@ -1,12 +1,5 @@
-# Host nginx — /etc/nginx/conf.d/cloudflare-realip.conf
-#
-# The site is behind Cloudflare, so every connection reaches the host nginx from a Cloudflare
-# address. This file makes $remote_addr the visitor's address again (CF-Connecting-IP), but only
-# for connections that really come from Cloudflare. pkgdiff.conf forwards $remote_addr to the
-# containers, which is what rate limiting, IP blocking and the request log use.
-#
-# The ranges are Cloudflare's published list. Compare with https://www.cloudflare.com/ips-v4 and
-# https://www.cloudflare.com/ips-v6 before applying, then: nginx -t && systemctl reload nginx
+# Host nginx (/etc/nginx/conf.d): restores the visitor IP on connections from Cloudflare.
+# Ranges from https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
diff --git a/deploy/nginx/pkgdiff.conf b/deploy/nginx/pkgdiff.conf
index 92d4c57..ec617a8 100644
--- a/deploy/nginx/pkgdiff.conf
+++ b/deploy/nginx/pkgdiff.conf
@@ -1,30 +1,12 @@
-# Host nginx config — /etc/nginx/sites-available/pkgdiff
-# Certificates already exist on the host: /etc/letsencrypt/live/pkgdiff.dev/
-# Apply changes: copy this file over the one on the host, then `nginx -t && systemctl reload nginx`.
-# Behind Cloudflare, cloudflare-realip.conf (same folder) must be installed as well: without it
-# $remote_addr below is a Cloudflare address and every visitor shares one rate-limit bucket.
-#
-# Every HTTPS name only answers Cloudflare (Authenticated Origin Pulls): a direct request to the
-# server address would skip Cloudflare Access, which guards /admin, /api/admin, /api/auth and
-# /hangfire. Turn on SSL/TLS > Origin Server > Authenticated Origin Pulls > Global in Cloudflare
-# BEFORE reloading this file, and install Cloudflare's client CA once:
-# curl -fsSo /etc/nginx/cloudflare-origin-pull-ca.pem \
-# https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem
-# Turning that switch off while these lines are live answers every visitor with 400.
-#
-# "http2 on;" needs nginx 1.25.1 or newer. On an older nginx the runbook installs this file with
-# the older spelling ("listen 443 ssl http2;"), which is the only one that version knows.
-#
-# Headers: this layer terminates TLS, so it sends Strict-Transport-Security, on every answer of
-# every name. The other security headers of pkgdiff.dev come from the container nginx behind it,
-# which knows the application; api.pkgdiff.dev goes straight to the backend and gets them here.
+# Host nginx (/etc/nginx/sites-available/pkgdiff); needs cloudflare-realip.conf installed too.
+# HTTPS only answers Cloudflare (Authenticated Origin Pulls) so Cloudflare Access can't be bypassed.
+# "http2 on;" needs nginx 1.25.1 or newer.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
-# HTTP → HTTPS
server {
listen 80;
server_name pkgdiff.dev www.pkgdiff.dev api.pkgdiff.dev;
@@ -32,7 +14,6 @@ server {
return 301 https://$host$request_uri;
}
-# www → non-www
server {
listen 443 ssl;
http2 on;
@@ -51,7 +32,6 @@ server {
return 301 https://pkgdiff.dev$request_uri;
}
-# Frontend (container nginx: 8090 → files, rendered pages, /api, /analysisHub, /hangfire)
server {
listen 443 ssl;
http2 on;
@@ -67,10 +47,9 @@ server {
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
- # The largest request is a blog post saved from the admin panel
client_max_body_size 2m;
- # The backend sends this header too once it sees an HTTPS request; one copy is enough
+ # The backend sends HSTS too; keep only one copy
proxy_hide_header Strict-Transport-Security;
add_header Strict-Transport-Security "max-age=31536000" always;
@@ -89,7 +68,6 @@ server {
}
}
-# API subdomain — straight to the backend container
server {
listen 443 ssl;
http2 on;
@@ -110,8 +88,6 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
- # This name answers API calls with JSON and files; nothing it sends is a page that may load
- # anything. The Hangfire dashboard is used through pkgdiff.dev/hangfire, not through this name.
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always;
location / {
diff --git a/deploy/remote.sh b/deploy/remote.sh
index 163674c..9f5d251 100644
--- a/deploy/remote.sh
+++ b/deploy/remote.sh
@@ -6,12 +6,7 @@
# remote.sh rollback run the images again that ran before the last deploy
# remote.sh status show the configured tag and what is running
#
-# It is meant to be the forced command of that key in ~/.ssh/authorized_keys (docs/ops/deploy-ghcr.md):
-#
-# command="/opt/pkgdiff/src/deploy/remote.sh",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA... pkgdiff-actions-deploy
-#
-# sshd then runs this script whatever the client asked for and hands over what it asked for in
-# SSH_ORIGINAL_COMMAND. That text is only split into words and checked; nothing in it is evaluated.
+# Forced command of the deploy key: SSH_ORIGINAL_COMMAND is split and checked, never evaluated.
set -euo pipefail
usage() {
@@ -62,11 +57,9 @@ main() {
cd "$root"
if [ "$action" = deploy ]; then
- # The compose file and the deploy script belong to the commit the images were built from.
- # A fast-forward only: a checkout with local commits or changes in the way stops here.
if [ -n "$commit" ]; then
git fetch --quiet origin
- # Only what was merged: a commit that is on no deployed branch is refused, whoever asks
+ # Refuse a commit that is on no deployed branch
if ! git merge-base --is-ancestor "$commit" origin/develop 2>/dev/null \
&& ! git merge-base --is-ancestor "$commit" origin/master 2>/dev/null; then
echo "deploy: $commit is not on develop or master; nothing was done" >&2
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
index 13ebdc3..ed763c0 100644
--- a/docker-compose.build.yml
+++ b/docker-compose.build.yml
@@ -1,15 +1,14 @@
-# Builds backend and frontend from this checkout instead of pulling them from GHCR.
-# Always used together with the main file, with PKGDIFF_IMAGE_TAG=local in .env:
+# Builds backend and frontend from source; use with PKGDIFF_IMAGE_TAG=local in .env:
# docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
services:
backend:
build:
context: ./src/backend
dockerfile: Dockerfile
- # A local build runs on the defaults in the image and on .env; no server settings file is mounted
+ # Local builds mount no server settings file
volumes: !reset []
- # The ssr service runs this same image; it is built once, here
+ # The ssr service runs this same image
frontend:
build:
context: ./src/frontend
diff --git a/docker-compose.yml b/docker-compose.yml
index 390dfa1..7ddfb7c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,5 @@
-# Production stack. Backend and frontend run prebuilt images from GHCR; nothing is built here.
-# Deploy with deploy/deploy.sh (docs/ops/deploy-ghcr.md). To build from source instead, add
-# docker-compose.build.yml: docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
-#
-# Every service has a memory and a process limit, so one of them running away cannot take the
-# host (which also runs other projects) down with it; the two that depend on load can be changed
-# in .env. Nothing but the two ports the host nginx proxies to and the two monitoring ports is
-# published, and all four on the loopback interface only.
+# Production stack: backend and frontend run prebuilt GHCR images, deployed by deploy/deploy.sh.
+# To build from source, add -f docker-compose.build.yml.
services:
backend:
image: ghcr.io/pkgdiff-dev/pkgdiff-backend:${PKGDIFF_IMAGE_TAG:?PKGDIFF_IMAGE_TAG is not set; deploy/deploy.sh writes it to .env}
@@ -28,20 +22,16 @@ services:
- AdminSettings__ViewerPassword=${VIEWER_PASSWORD:-}
- ForwardedHeaders__TrustedNetworks=${FORWARDED_TRUSTED_NETWORKS:-172.28.0.0/16}
- GitHubSettings__PersonalAccessToken=${GITHUB_PAT:-}
- # The image runs as an unprivileged user (uid 1654) and needs no capability
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
- # No CPU limit: the work of the backend is bounded in the application (10 analyses at a time,
- # 10 job workers), and a limit above the number of cores of the host would stop the container
- # from being created at all
+ # No CPU limit: one above the host's core count stops the container from being created
mem_limit: ${PKGDIFF_BACKEND_MEMORY:-2g}
pids_limit: 1024
ports:
- "127.0.0.1:8080:8080"
volumes:
- # The settings of this server, on top of the defaults in the image (appsettings.json, no secrets)
- ./config/appsettings.json:/app/appsettings.Production.json:ro
depends_on:
db:
@@ -49,8 +39,7 @@ services:
redis:
condition: service_healthy
healthcheck:
- # Ready means the database and Redis answer. An image from before /health/ready existed answers 404 there
- # and is asked through its old health endpoint, so a rollback to it still shows as healthy.
+ # Older images answer 404 on /health/ready; fall back to their old endpoint so rollback works.
test: ["CMD-SHELL", "code=$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health/ready); [ \"$$code\" = 200 ] || { [ \"$$code\" = 404 ] && curl -fsS -o /dev/null http://localhost:8080/api/package/health; }"]
interval: 30s
timeout: 10s
@@ -65,14 +54,11 @@ services:
max-file: "3"
- # The render server: the frontend image run with Node instead of nginx. It renders the public
- # pages and asks the API through the frontend container, the way a browser does.
ssr:
image: ghcr.io/pkgdiff-dev/pkgdiff-frontend:${PKGDIFF_IMAGE_TAG:?PKGDIFF_IMAGE_TAG is not set; deploy/deploy.sh writes it to .env}
container_name: pkgdiff-ssr
restart: always
- # An image from before server rendering has no render server. The service then idles, so a
- # rollback to such an image still brings the stack up; its nginx never asks this service.
+ # Older images have no render server; idle instead so a rollback still brings the stack up.
entrypoint: ["/bin/sh", "-c", "if [ -f /app/server/server.mjs ]; then exec node /app/server/server.mjs; else echo 'this image has no render server; idling'; exec sleep infinity; fi"]
user: "65534:65534"
read_only: true
@@ -85,7 +71,6 @@ services:
environment:
- NODE_ENV=production
- PORT=4000
- # Where a render finds the API: nginx of the frontend service, inside the compose network
- SSR_INTERNAL_ORIGIN=http://frontend
mem_limit: 512m
cpus: 1.0
@@ -109,9 +94,7 @@ services:
image: ghcr.io/pkgdiff-dev/pkgdiff-frontend:${PKGDIFF_IMAGE_TAG:?PKGDIFF_IMAGE_TAG is not set; deploy/deploy.sh writes it to .env}
container_name: pkgdiff-frontend
restart: always
- # The image runs nginx as the unprivileged "nginx" user. Capabilities and the file system
- # are left as they are on purpose: an image from before that change (a rollback) starts
- # nginx as root, which still needs both.
+ # No cap_drop/read_only on purpose: older images (a rollback) still start nginx as root.
security_opt:
- no-new-privileges:true
mem_limit: 256m
@@ -120,14 +103,13 @@ services:
ports:
- "127.0.0.1:8090:80"
healthcheck:
- # A file nginx serves itself: its health says nothing about the render server or the backend.
# 127.0.0.1, not localhost: localhost resolves to ::1 first and nginx listens on IPv4 only.
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:80/robots.txt || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
- # nginx looks the backend up once, when it starts; the render server is looked up per request
+ # nginx resolves the backend once at start; the render server is resolved per request
depends_on:
backend:
condition: service_healthy
@@ -150,13 +132,11 @@ services:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=pkgdiffdb
- # The entrypoint starts as root to fix the ownership of the data directory and then runs the
- # server as the "postgres" user
+ # No cap_drop: the entrypoint starts as root to chown the data directory
security_opt:
- no-new-privileges:true
mem_limit: ${PKGDIFF_DB_MEMORY:-1024m}
pids_limit: 512
- # No port on the host: backups and maintenance use docker exec
volumes:
- postgres-data:/var/lib/postgresql
networks:
@@ -183,11 +163,9 @@ services:
mem_limit: 320m
cpus: 1.0
pids_limit: 128
- # No port on the host: only the backend reaches it, over the compose network
volumes:
- redis-data:/data
- # Redis holds the cache and the rate-limit counters, nothing that cannot be rebuilt: at its
- # own limit, below the limit of the container, it drops the entries used least recently
+ # Only rebuildable data, so evict LRU below the container memory limit
command: redis-server --stop-writes-on-bgsave-error no --maxmemory 192mb --maxmemory-policy allkeys-lru
networks:
- pkgdiff-network
@@ -214,7 +192,6 @@ services:
mem_limit: 512m
cpus: 0.5
pids_limit: 256
- # Loopback only: reached from outside through an SSH tunnel (docs/ops/operations.md)
ports:
- "127.0.0.1:${PKGDIFF_PROMETHEUS_PORT:-9390}:9090"
volumes:
@@ -258,16 +235,13 @@ services:
mem_limit: 512m
cpus: 0.5
pids_limit: 256
- # Loopback only: reached from outside through an SSH tunnel (docs/ops/operations.md)
ports:
- "127.0.0.1:${PKGDIFF_GRAFANA_PORT:-3300}:3000"
volumes:
- grafana-data:/var/lib/grafana
- # The Prometheus data source and the pkgdiff dashboard, from this repository
- ./deploy/grafana/provisioning:/etc/grafana/provisioning:ro
- ./deploy/grafana/dashboards:/var/lib/grafana/dashboards:ro
- # No health condition on Prometheus: monitoring must never be the reason an application
- # deploy fails
+ # No health condition: monitoring must never be the reason an application deploy fails
depends_on:
- prometheus
healthcheck:
diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile
index 9be1bcd..6ebbc75 100644
--- a/src/backend/Dockerfile
+++ b/src/backend/Dockerfile
@@ -1,12 +1,10 @@
-# Build stage: the SDK image that generated the lock files (same reference as in Directory.Build.props)
+# The SDK that generated the lock files; must match the reference in Directory.Build.props
FROM mcr.microsoft.com/dotnet/sdk:10.0.402-noble@sha256:0d99ebf1ccad15037b5c98c9fe8979d6b8dbac23812feae2277a0b277b5c5ffa AS build
# Emergency only: --build-arg PKGDIFF_AUDIT_AS_ERRORS=false turns NU1901-NU1904 back into warnings
ARG PKGDIFF_AUDIT_AS_ERRORS=true
WORKDIR /src
-# Central versions, audit settings, the package source and the lock files come first,
-# so the restore layer stays cached until a dependency changes
COPY ["PkgDiff.slnx", "Directory.Packages.props", "Directory.Build.props", "nuget.config", "./"]
COPY ["PkgDiff.API/PkgDiff.csproj", "PkgDiff.API/packages.lock.json", "PkgDiff.API/"]
COPY ["PkgDiff.Application/PkgDiff.Application.csproj", "PkgDiff.Application/packages.lock.json", "PkgDiff.Application/"]
@@ -14,29 +12,21 @@ COPY ["PkgDiff.Domain/PkgDiff.Domain.csproj", "PkgDiff.Domain/packages.lock.json
COPY ["PkgDiff.Infrastructure/PkgDiff.Infrastructure.csproj", "PkgDiff.Infrastructure/packages.lock.json", "PkgDiff.Infrastructure/"]
COPY ["PkgDiff.Shared/PkgDiff.Shared.csproj", "PkgDiff.Shared/packages.lock.json", "PkgDiff.Shared/"]
-# Restores exactly what the lock files record. NuGet audit runs inside this restore, so a
-# known-vulnerable package fails the build here, but only when this layer is rebuilt (cold cache
-# or a changed manifest). .github/scripts/check-deps.sh is the check that audits on every run.
+# The audit here only runs when this layer rebuilds; check-deps.sh audits on every run.
RUN dotnet restore "PkgDiff.API/PkgDiff.csproj" --locked-mode -p:ContinuousIntegrationBuild=true -p:PkgDiffAuditAsErrors=${PKGDIFF_AUDIT_AS_ERRORS}
COPY . .
-# Publish reuses the restore above, so nothing is resolved a second time
WORKDIR /src/PkgDiff.API
RUN dotnet publish "PkgDiff.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false -p:PkgDiffAuditAsErrors=${PKGDIFF_AUDIT_AS_ERRORS}
-# Runtime stage: ASP.NET Core 10.0.13 on Ubuntu 24.04, pinned by tag and index digest
FROM mcr.microsoft.com/dotnet/aspnet:10.0.13-noble@sha256:d5633778c754e89a386882eddf8dbee6942447a8a4b8267c19cf1e1158d0b0ac AS final
WORKDIR /app
EXPOSE 8080
-# Pick up Ubuntu security fixes published after the runtime image was built, and install curl,
-# which the compose healthcheck calls.
-# The base digest is pinned, so Docker would reuse this layer from its build cache
-# forever; bump the date (or pass --build-arg APT_UPGRADE_DATE=...) to run it again.
+# With a pinned base this layer stays cached forever; bump the date to pick up security fixes.
+# curl is needed by the compose healthcheck.
ARG APT_UPGRADE_DATE=2026-10-03
-# Non-interactive, and a changed configuration file keeps the installed version: an upgrade must
-# never stop the image build at a question. The variable is set for this layer only, not as ENV.
RUN export DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get upgrade -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \
@@ -45,8 +35,5 @@ RUN export DEBIAN_FRONTEND=noninteractive \
COPY --from=build /app/publish .
-# The unprivileged user the ASP.NET image ships (uid 1654). The application writes nothing next
-# to its files; a settings file mounted by compose has to be readable for this user
-# (deploy/deploy.sh checks that before it switches).
USER $APP_UID
ENTRYPOINT ["dotnet", "PkgDiff.dll"]
diff --git a/src/backend/PkgDiff.API/Controllers/Admin/AdminDashboardController.cs b/src/backend/PkgDiff.API/Controllers/Admin/AdminDashboardController.cs
index b628563..5f43b13 100644
--- a/src/backend/PkgDiff.API/Controllers/Admin/AdminDashboardController.cs
+++ b/src/backend/PkgDiff.API/Controllers/Admin/AdminDashboardController.cs
@@ -19,8 +19,6 @@ public AdminDashboardController(IAdminDashboardService service, ILogger> GetDashboard()
@@ -37,8 +35,6 @@ public async Task> GetDashboard()
}
}
- // ───── Search History ─────
-
[HttpGet("search-history")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Security.ViewLogs)]
public async Task>> GetSearchHistory(
diff --git a/src/backend/PkgDiff.API/Controllers/Admin/AdminPackagesController.cs b/src/backend/PkgDiff.API/Controllers/Admin/AdminPackagesController.cs
index abd5312..4a66920 100644
--- a/src/backend/PkgDiff.API/Controllers/Admin/AdminPackagesController.cs
+++ b/src/backend/PkgDiff.API/Controllers/Admin/AdminPackagesController.cs
@@ -19,8 +19,6 @@ public AdminPackagesController(IAdminPackageService service, ILogger>> GetPackages(
@@ -61,8 +59,6 @@ public async Task DeletePackage(int id)
}
}
- // ───── Registries ─────
-
[HttpGet("registries")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Registries.View)]
public async Task>> GetRegistries()
diff --git a/src/backend/PkgDiff.API/Controllers/Admin/AdminSecurityController.cs b/src/backend/PkgDiff.API/Controllers/Admin/AdminSecurityController.cs
index 4d64ee1..046e43c 100644
--- a/src/backend/PkgDiff.API/Controllers/Admin/AdminSecurityController.cs
+++ b/src/backend/PkgDiff.API/Controllers/Admin/AdminSecurityController.cs
@@ -95,7 +95,7 @@ public async Task UnblockIp(int id)
}
}
- // Both are required: the seeded Viewer role holds ViewLogs, and the only consumer (IP Blocking page) is gated on ManageIps.
+ // ViewLogs alone is too broad (the seeded Viewer role has it), so ManageIps is required too
[HttpGet("security/ip-statistics")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Security.ViewLogs)]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Security.ManageIps)]
diff --git a/src/backend/PkgDiff.API/Controllers/AdminBlogController.cs b/src/backend/PkgDiff.API/Controllers/AdminBlogController.cs
index 07c239c..09801ea 100644
--- a/src/backend/PkgDiff.API/Controllers/AdminBlogController.cs
+++ b/src/backend/PkgDiff.API/Controllers/AdminBlogController.cs
@@ -113,7 +113,6 @@ public async Task UpdateBlog(int id, [FromBody] UpdateBlogRequest
return Ok(blog);
}
- // The editor previews through the same renderer the public blog page uses
[HttpPost("preview")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Blog.Manage)]
public IActionResult PreviewBlog([FromBody] BlogPreviewRequest request)
diff --git a/src/backend/PkgDiff.API/Controllers/AuditController.cs b/src/backend/PkgDiff.API/Controllers/AuditController.cs
index f03b3fb..602bc54 100644
--- a/src/backend/PkgDiff.API/Controllers/AuditController.cs
+++ b/src/backend/PkgDiff.API/Controllers/AuditController.cs
@@ -4,13 +4,11 @@
namespace PkgDiff.API.Controllers;
-// Dependency audit: a visitor pastes a dependency file, checks the packages read from it and gets a
-// report of their known vulnerabilities. The report is made by a background job and polled here.
[ApiController]
[Route("api/[controller]")]
public class AuditController : ControllerBase
{
- // Audits waiting or running at the same time, all visitors together; each one takes a job worker
+ // All visitors together; each audit takes a job worker
private const int MaxActiveAudits = 20;
private const long MaxRequestBytes = 2 * 1024 * 1024;
@@ -25,7 +23,6 @@ public AuditController(IManifestParser parser, IDependencyAuditService audits, I
_logger = logger;
}
- // Reads the packages out of the file; nothing is fetched and nothing is stored
[HttpPost("parse")]
[RequestSizeLimit(MaxRequestBytes)]
public ActionResult Parse([FromBody] ManifestParseRequest request)
diff --git a/src/backend/PkgDiff.API/Controllers/BlogController.cs b/src/backend/PkgDiff.API/Controllers/BlogController.cs
index 9959f13..7147ac8 100644
--- a/src/backend/PkgDiff.API/Controllers/BlogController.cs
+++ b/src/backend/PkgDiff.API/Controllers/BlogController.cs
@@ -35,7 +35,6 @@ public async Task GetPublishedBlogs([FromQuery] int page = 1, [Fr
b.PackageId,
b.Version,
b.PublishedAt,
- // Only send a small snippet of the content for the list view
Excerpt = b.Content.Length > 200 ? b.Content.Substring(0, 200) + "..." : b.Content
},
page,
diff --git a/src/backend/PkgDiff.API/Controllers/ContributorsController.cs b/src/backend/PkgDiff.API/Controllers/ContributorsController.cs
index af7d15b..7bef6d1 100644
--- a/src/backend/PkgDiff.API/Controllers/ContributorsController.cs
+++ b/src/backend/PkgDiff.API/Controllers/ContributorsController.cs
@@ -18,9 +18,6 @@ public ContributorsController(IContributorService contributorService, IJobSchedu
_jobs = jobs;
}
- ///
- /// Returns the top contributors leaderboard ranked by GitHub @mention count.
- ///
[HttpGet]
public async Task GetLeaderboard([FromQuery] int limit = 100)
{
@@ -45,9 +42,6 @@ public async Task GetLeaderboardPaged([FromQuery] int page = 1, [
});
}
- ///
- /// Triggers a one-time backfill job to scan ALL existing release notes and populate Contributors table.
- ///
[HttpPost("backfill")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.Contributors.Manage)]
public IActionResult TriggerBackfill()
diff --git a/src/backend/PkgDiff.API/Controllers/HealthController.cs b/src/backend/PkgDiff.API/Controllers/HealthController.cs
index f0734bb..fc1980b 100644
--- a/src/backend/PkgDiff.API/Controllers/HealthController.cs
+++ b/src/backend/PkgDiff.API/Controllers/HealthController.cs
@@ -16,9 +16,6 @@ public HealthController(IHealthCheckService healthCheckService)
_healthCheckService = healthCheckService;
}
- ///
- /// Get overall health status (quick check)
- ///
[HttpGet("status")]
public async Task> GetStatus()
{
@@ -26,9 +23,6 @@ public async Task> GetStatus()
return Ok(status);
}
- ///
- /// Get detailed health information for all services
- ///
[HttpGet("details")]
public async Task> GetDetails()
{
diff --git a/src/backend/PkgDiff.API/Controllers/PackageController.cs b/src/backend/PkgDiff.API/Controllers/PackageController.cs
index f931da5..8b4fec5 100644
--- a/src/backend/PkgDiff.API/Controllers/PackageController.cs
+++ b/src/backend/PkgDiff.API/Controllers/PackageController.cs
@@ -16,13 +16,10 @@ public class PackageController : ControllerBase
{
private const int MaxConcurrentAnalyses = 10;
- // One visitor must not take every analysis slot: a few at once per address
private const int MaxConcurrentAnalysesPerAddress = 2;
private static readonly System.Collections.Concurrent.ConcurrentDictionary AnalysesPerAddress = new();
- // Opening a package nobody looked at before queues a scrape of its notes. Asking for one new name after
- // another must not fill the job queue or use up the GitHub budget: scrapes queued this way are capped
- // per address and in total, per hour. A package over the cap is still shown, and scraped on a later visit.
+ // Caps first-visit scrapes so a stream of new names cannot fill the queue or the GitHub budget
private const int MaxScrapesPerAddressPerHour = 30;
private const int MaxScrapesPerHour = 600;
private const int ClientClosedRequest = 499;
@@ -129,7 +126,7 @@ private async Task> AnalyzeAsync(AnalyzeRequest req
{
var ipAddress = HttpContext.GetClientIpAddress();
- // When this request is aborted and nobody else waits for the same analysis, the work behind it stops
+ // Aborting the request stops the work unless another request waits on the same analysis
using var work = WorkCancellation.Use(cancellationToken);
var result = await _analysisService.AnalyzePackageAsync(request, ipAddress);
await MarkViewedAsync(request.Registry, request.PackageId);
@@ -211,7 +208,6 @@ public async Task> GetPackageMetadata(string regis
var metadata = WithTrust(await _analysisService.GetPackageMetadataAsync(registry, packageId), await TrustOfAsync(registry, packageId));
await MarkViewedAsync(registry, packageId);
- // Trigger background scraping if we have unscraped versions and no job is already running
var hasUnscrapedVersions = metadata.Versions.Any(v => string.IsNullOrEmpty(v.ReleaseNotesSource));
var cacheKey = $"scrape-attempt-{registry}-{packageId}";
@@ -277,7 +273,6 @@ public async Task>> GetCachedPackages()
{
try
{
- // The same list for every visitor: built at most every few minutes
var cachedPackages = await _memory.GetOrCreateAsync("cached-packages", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
@@ -306,7 +301,6 @@ public async Task ExportReleaseNotesToPdf(string registry, string
{
var metadata = WithTrust(await _analysisService.GetPackageMetadataAsync(registry, packageId), await TrustOfAsync(registry, packageId));
- // Generate PDF
var fileContent = await _exportService.ExportToPdfAsync(metadata);
var fileName = $"{packageId}-release-notes.pdf";
@@ -336,13 +330,11 @@ public async Task ExportDiffToPdf([FromBody] AnalyzeRequest reque
try
{
- // Perform analysis
var ipAddress = HttpContext.GetClientIpAddress();
using var work = WorkCancellation.Use(cancellationToken);
var diff = await _analysisService.AnalyzePackageAsync(request, ipAddress);
diff.Trust = await TrustOfAsync(request.Registry, request.PackageId);
- // Generate PDF
var fileContent = await _exportService.ExportDiffToPdfAsync(diff);
var fileName = $"{request.PackageId}-diff-{request.CurrentVersion}-to-{request.TargetVersion}.pdf";
diff --git a/src/backend/PkgDiff.API/Controllers/SitemapController.cs b/src/backend/PkgDiff.API/Controllers/SitemapController.cs
index b96de5f..51078a1 100644
--- a/src/backend/PkgDiff.API/Controllers/SitemapController.cs
+++ b/src/backend/PkgDiff.API/Controllers/SitemapController.cs
@@ -28,7 +28,6 @@ public async Task GetDynamicSitemap()
xml.AppendLine("");
xml.AppendLine("");
- // Static pages
xml.AppendLine(" ");
xml.AppendLine(" https://pkgdiff.dev/");
xml.AppendLine(" daily");
@@ -71,7 +70,6 @@ public async Task GetDynamicSitemap()
xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}");
xml.AppendLine(" ");
- // Package pages
foreach (var package in packages)
{
var encodedPackageId = Uri.EscapeDataString(package.PackageId);
diff --git a/src/backend/PkgDiff.API/Controllers/VersionExcludePatternsController.cs b/src/backend/PkgDiff.API/Controllers/VersionExcludePatternsController.cs
index 517917b..70b0017 100644
--- a/src/backend/PkgDiff.API/Controllers/VersionExcludePatternsController.cs
+++ b/src/backend/PkgDiff.API/Controllers/VersionExcludePatternsController.cs
@@ -19,9 +19,6 @@ public VersionExcludePatternsController(IUnitOfWork unitOfWork, IVersionFilterSe
_versionFilterService = versionFilterService;
}
- ///
- /// Get all version exclude patterns
- ///
[HttpGet]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.View)]
public async Task>> GetAll()
@@ -30,9 +27,6 @@ public async Task>> GetAll()
return Ok(patterns.OrderBy(p => p.Id));
}
- ///
- /// Get a specific pattern by ID
- ///
[HttpGet("{id}")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.View)]
public async Task> GetById(int id)
@@ -44,9 +38,6 @@ public async Task> GetById(int id)
return Ok(pattern);
}
- ///
- /// Create a new exclude pattern
- ///
[HttpPost]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.Create)]
public async Task> Create([FromBody] CreatePatternRequest request)
@@ -64,15 +55,11 @@ public async Task> Create([FromBody] CreateP
await _unitOfWork.VersionExcludePatterns.AddAsync(pattern);
await _unitOfWork.SaveChangesAsync();
- // Refresh cache
await _versionFilterService.RefreshPatternsAsync();
return CreatedAtAction(nameof(GetById), new { id = pattern.Id }, pattern);
}
- ///
- /// Update an existing pattern
- ///
[HttpPut("{id}")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.Edit)]
public async Task> Update(int id, [FromBody] UpdatePatternRequest request)
@@ -93,15 +80,11 @@ public async Task> Update(int id, [FromBody]
await _unitOfWork.VersionExcludePatterns.UpdateAsync(pattern);
await _unitOfWork.SaveChangesAsync();
- // Refresh cache
await _versionFilterService.RefreshPatternsAsync();
return Ok(pattern);
}
- ///
- /// Delete a pattern
- ///
[HttpDelete("{id}")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.Delete)]
public async Task Delete(int id)
@@ -113,15 +96,11 @@ public async Task Delete(int id)
await _unitOfWork.VersionExcludePatterns.DeleteAsync(pattern);
await _unitOfWork.SaveChangesAsync();
- // Refresh cache
await _versionFilterService.RefreshPatternsAsync();
return NoContent();
}
- ///
- /// Toggle pattern active status
- ///
[HttpPatch("{id}/toggle")]
[Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.Edit)]
public async Task> ToggleActive(int id)
@@ -134,24 +113,18 @@ public async Task> ToggleActive(int id)
await _unitOfWork.VersionExcludePatterns.UpdateAsync(pattern);
await _unitOfWork.SaveChangesAsync();
- // Refresh cache
await _versionFilterService.RefreshPatternsAsync();
return Ok(pattern);
}
- ///
- /// Preview: Test pattern against sample versions
- /// Returns 3 example versions that would be excluded
- ///
[HttpPost("preview")]
- [Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.View)] // View might be enough, but it's a tool for editing
+ [Authorize(Policy = PkgDiff.Shared.Constants.Permissions.VersionExcludePatterns.View)]
public ActionResult Preview([FromBody] PreviewRequest request)
{
if (string.IsNullOrWhiteSpace(request.Pattern))
return BadRequest(new { message = "Pattern is required" });
- // Sample versions for testing
var sampleVersions = new[]
{
"1.0.0",
@@ -192,7 +165,6 @@ public ActionResult Preview([FromBody] PreviewRequest request)
}
}
-// DTOs
public record CreatePatternRequest(string Pattern, string? Description, bool IsActive = true);
public record UpdatePatternRequest(string? Pattern, string? Description, bool? IsActive);
public record PreviewRequest(string Pattern);
diff --git a/src/backend/PkgDiff.API/Extensions/AuthServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/AuthServiceCollectionExtensions.cs
index c562a70..aa01fa2 100644
--- a/src/backend/PkgDiff.API/Extensions/AuthServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/AuthServiceCollectionExtensions.cs
@@ -51,8 +51,7 @@ public static IServiceCollection AddPkgDiffAuthentication(
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Value.SecretKey))
};
- // A signed token is not enough: its user must still exist, not be locked out and have the
- // security stamp the token was issued with. The answer is cached for a minute per token stamp.
+ // A signed token is not enough: the user must exist, be unlocked and match the security stamp
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
diff --git a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
index a0c87d5..e735f6c 100644
--- a/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
+++ b/src/backend/PkgDiff.API/Extensions/DatabaseSeeder.cs
@@ -18,7 +18,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
var logger = scope.ServiceProvider.GetRequiredService().CreateLogger("DatabaseSeeder");
var configuration = scope.ServiceProvider.GetRequiredService();
- // Seed Roles
if (!await roleManager.RoleExistsAsync("Admin"))
{
EnsureSucceeded(await roleManager.CreateAsync(new IdentityRole("Admin")), "create role Admin");
@@ -35,7 +34,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
await SeedAdminUserAsync(userManager, adminPassword, logger);
- // Seed Viewer User (optional)
var viewerUser = await userManager.FindByNameAsync("pkgdiff_viewer");
if (viewerUser == null && viewerPassword != null)
{
@@ -64,11 +62,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
logger.LogInformation("Viewer user not seeded: AdminSettings:ViewerPassword (env VIEWER_PASSWORD) is not set.");
}
- // ──────────────────────────────
- // Seed Role Permissions
- // ──────────────────────────────
-
- // 1. Admin Role (Grant ALL permissions)
var adminRole = await roleManager.FindByNameAsync("Admin");
if (adminRole != null)
{
@@ -88,7 +81,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
if (newClaimsCount > 0) logger.LogInformation("Added {Count} new permissions to role {Role}", newClaimsCount, "Admin");
}
- // 2. Viewer Role (Grant VIEW permissions)
var viewerRole = await roleManager.FindByNameAsync("Viewer");
if (viewerRole != null)
{
@@ -121,9 +113,7 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
if (newClaimsCount > 0) logger.LogInformation("Added {Count} new permissions to role {Role}", newClaimsCount, "Viewer");
}
- // Seed System Settings: insert missing known keys with their flags, repair flags of existing
- // known keys and encrypt plaintext secrets. Idempotent: a second run changes nothing and never re-encrypts.
- // The protector is resolved first: in Production a missing SETTINGS_ENCRYPTION_KEY stops startup here.
+ // Idempotent, never re-encrypts; the protector resolves first so a missing key stops startup
var protector = scope.ServiceProvider.GetRequiredService();
var existingSettings = await context.SystemSettings.ToListAsync();
var createdSettings = SystemSettingsMaintenance.CreateMissing(existingSettings.Select(s => s.Key));
@@ -139,7 +129,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
logger.LogInformation("System settings: {Added} added, {Changed} updated", createdSettings.Count, changedSettings);
}
- // Seed Registries
if (!await context.Registries.AnyAsync())
{
var registries = new[]
@@ -165,7 +154,6 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
}
}
- // Seed Version Exclude Patterns
var expectedPatterns = new[]
{
new { Id = 1, Pattern = "dev", Description = "Development builds (e.g., 1.0.0-dev-123)" },
@@ -232,8 +220,7 @@ public static async Task SeedDataAsync(IServiceProvider serviceProvider)
logger.LogInformation("Version exclude patterns seeded: {Added} added, {Updated} updated", addedCount, updatedCount);
}
}
- // Bootstrap only: once any user holds Admin, a demoted or deleted pkgdiff_admin is an administrator's
- // decision and is never re-promoted or recreated here.
+ // Bootstrap only: once any Admin exists, pkgdiff_admin is never re-promoted or recreated
public static async Task SeedAdminUserAsync(UserManager userManager, string? adminPassword, ILogger logger)
{
if ((await userManager.GetUsersInRoleAsync("Admin")).Count > 0)
@@ -267,7 +254,6 @@ public static async Task SeedAdminUserAsync(UserManager userMan
logger.LogInformation("Admin user {UserName} created", "pkgdiff_admin");
}
- // Reached only when no administrator exists at all (fresh install or an interrupted first seed)
EnsureSucceeded(await userManager.AddToRoleAsync(adminUser, "Admin"), "assign role Admin to pkgdiff_admin");
logger.LogInformation("Role {Role} assigned to {UserName}", "Admin", "pkgdiff_admin");
}
diff --git a/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs
index 95364c0..951ebeb 100644
--- a/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs
@@ -18,7 +18,6 @@
namespace PkgDiff.API.Extensions;
-// The registrations that make up the host: each method is one concern of Program.cs
public static class HostingServiceCollectionExtensions
{
public const string DevelopmentCorsPolicy = "AllowAngularDev";
@@ -41,7 +40,6 @@ public static IServiceCollection AddPkgDiffSwagger(this IServiceCollection servi
BearerFormat = "JWT"
});
- // Delegate-based API of Microsoft.OpenApi 2.x
options.AddSecurityRequirement(document => new Microsoft.OpenApi.OpenApiSecurityRequirement
{
[new Microsoft.OpenApi.OpenApiSecuritySchemeReference("Bearer", document)] = []
@@ -73,8 +71,7 @@ public static IServiceCollection AddPkgDiffPersistence(this IServiceCollection s
.AddEntityFrameworkStores()
.AddDefaultTokenProviders();
- // Data Protection key ring in Postgres: dashboard tickets/cookies and progress tokens survive container recreation.
- // Keys are encrypted with a key derived from SETTINGS_ENCRYPTION_KEY so a database dump alone cannot forge them.
+ // Kept in Postgres to survive recreation; encrypted so a database dump alone cannot forge tokens
services.AddDataProtection()
.SetApplicationName("pkgdiff")
.PersistKeysToDbContext()
@@ -99,7 +96,7 @@ public static IServiceCollection AddPkgDiffBackgroundJobs(this IServiceCollectio
options.WorkerCount = 10;
});
- // Dependency audits get two workers of their own and nothing else runs them
+ // Dependency audits get two dedicated workers
services.AddHangfireServer(options =>
{
options.ServerName = $"{Environment.MachineName}:audits";
@@ -112,14 +109,12 @@ public static IServiceCollection AddPkgDiffBackgroundJobs(this IServiceCollectio
services.AddScoped();
services.AddScoped();
- // Hangfire dashboard access (ticket -> cookie, per-request Admin re-check)
services.AddSingleton();
services.AddScoped();
return services;
}
- // Cache backend: CacheSettings:Backend is "Redis" or "Memory"; Redis also holds the rate-limit counters
public static IServiceCollection AddPkgDiffCaching(this IServiceCollection services, IConfiguration configuration)
{
services.AddMemoryCache();
@@ -145,7 +140,6 @@ public static IServiceCollection AddPkgDiffCaching(this IServiceCollection servi
services.AddSingleton();
}
- // Inbound rate-limit tiers (RateLimiting section); the defaults apply when the section is absent
services.AddOptions()
.Bind(configuration.GetSection(InboundRateLimitOptions.SectionName))
.Validate(options => options.IsValid(), "RateLimiting: every PerMinute and PerFiveMinutes value must be greater than zero.")
@@ -156,16 +150,15 @@ public static IServiceCollection AddPkgDiffCaching(this IServiceCollection servi
public static IServiceCollection AddPkgDiffDiagnostics(this IServiceCollection services)
{
- // Any exception no action caught becomes a ProblemDetails response; the detail goes to the log only
services.AddProblemDetails();
services.AddExceptionHandler();
- // /health is liveness (the process answers); /health/ready also needs the database and Redis
+ // /health is liveness; /health/ready also needs the database and Redis
services.AddHealthChecks()
.AddCheck("database", tags: new[] { HealthResponses.ReadyTag })
.AddCheck("redis", tags: new[] { HealthResponses.ReadyTag });
- // Bounded: when the database cannot keep up, the oldest pending log entries are dropped instead of memory growing
+ // Bounded: drops the oldest pending entries instead of growing memory when the DB lags
services.AddSingleton(Channel.CreateBounded(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropOldest,
diff --git a/src/backend/PkgDiff.API/Extensions/HttpContextExtensions.cs b/src/backend/PkgDiff.API/Extensions/HttpContextExtensions.cs
index d494c86..5ca9145 100644
--- a/src/backend/PkgDiff.API/Extensions/HttpContextExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/HttpContextExtensions.cs
@@ -2,8 +2,7 @@ namespace PkgDiff.API.Extensions;
public static class HttpContextExtensions
{
- // RemoteIpAddress is already rewritten by UseForwardedHeaders for trusted proxies only,
- // so request headers are never consulted here.
+ // Already rewritten by UseForwardedHeaders for trusted proxies; headers are never read here
public static string GetClientIpAddress(this HttpContext context)
{
var ip = context.Connection.RemoteIpAddress;
diff --git a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
index 71bad56..ce415c6 100644
--- a/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/OutboundHttpServiceCollectionExtensions.cs
@@ -37,7 +37,7 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0");
});
- // Fixed trusted registry hosts get larger caps: real packuments (npm typescript ~16 MB) exceed 2 MiB.
+ // Trusted registry hosts get larger caps: real packuments exceed 2 MiB
services.AddHttpClient(HttpClientNames.NpmRegistry, client =>
{
client.BaseAddress = new Uri("https://registry.npmjs.org/");
@@ -52,7 +52,6 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
client.MaxResponseContentBufferSize = RegistryMaxResponseBytes;
});
- // pub.dev HTML pages measured at <= 0.3 MB (analyzer/versions), so 8 MiB leaves ample headroom
services.AddHttpClient(HttpClientNames.PubDev, client =>
{
client.BaseAddress = new Uri("https://pub.dev/");
@@ -62,7 +61,7 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
services.AddScoped();
- // Security data providers, called from background jobs only
+ // Security providers: called from background jobs only
AddSecurityApi(services, HttpClientNames.Osv, "https://api.osv.dev/");
AddSecurityApi(services, HttpClientNames.DepsDev, "https://api.deps.dev/");
AddSecurityApi(services, HttpClientNames.Scorecard, "https://api.securityscorecards.dev/");
@@ -74,7 +73,7 @@ public static IServiceCollection AddPkgDiffOutboundHttp(this IServiceCollection
client.Timeout = TimeSpan.FromSeconds(5);
});
- // Single intentional internal target (host.docker.internal), restricted by ufw per docs/ops/network-hardening.md
+ // The one intentional internal target (host.docker.internal), restricted by ufw
services.AddHttpClient((provider, client) =>
{
var baseUrl = provider.GetRequiredService()["Ollama:BaseUrl"] ?? "http://localhost:11434";
diff --git a/src/backend/PkgDiff.API/Extensions/SecretsServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/SecretsServiceCollectionExtensions.cs
index 232bf22..4834022 100644
--- a/src/backend/PkgDiff.API/Extensions/SecretsServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/SecretsServiceCollectionExtensions.cs
@@ -12,8 +12,7 @@ public static class SecretsServiceCollectionExtensions
public static IServiceCollection AddPkgDiffSecrets(this IServiceCollection services)
{
- // The key is validated when the protector is first resolved, never during registration,
- // so the EF design-time host keeps working without SETTINGS_ENCRYPTION_KEY.
+ // Key validated on first resolve so EF design-time tooling works without it
services.AddSingleton(sp => new AesGcmSecretProtector(ResolveSettingsKey(sp, warnOnDevelopmentFallback: true)));
services.AddScoped();
@@ -21,8 +20,7 @@ public static IServiceCollection AddPkgDiffSecrets(this IServiceCollection servi
return services;
}
- // Same key policy as the settings: required outside Development, derived dev-only key in Development.
- // Resolved lazily when the key ring is first loaded, never during registration.
+ // Same key policy as the settings; resolved lazily for EF design-time tooling
public static IDataProtectionBuilder ProtectKeysWithSettingsEncryptionKey(this IDataProtectionBuilder builder)
{
builder.Services.AddSingleton(sp => new KeyRingEncryptionKey(ResolveSettingsKey(sp, warnOnDevelopmentFallback: false)));
diff --git a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
index 76aaed0..aa15f96 100644
--- a/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
+++ b/src/backend/PkgDiff.API/Extensions/ServiceCollectionExtensions.cs
@@ -11,18 +11,14 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddBusinessServices(this IServiceCollection services)
{
- // SSRF-guarded defaults for every HttpClient, the named clients and the Ollama exception
services.AddPkgDiffOutboundHttp();
- // Secret settings protector (key from Security:SettingsEncryptionKey) and decrypting settings reader
services.AddPkgDiffSecrets();
- // Rate Limiting (Singleton - global state)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- // Core Registry Services
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -38,30 +34,25 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
client.DefaultRequestHeaders.Add("User-Agent", "PkgDiff/1.0");
});
- // Scrape Log Service
services.AddScoped();
- // Scraping Strategies
services.AddScoped();
services.AddScoped();
services.AddScoped();
- // One client per registry, found by name through the factory
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
- // Factory & Cache Services
services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
- // Analysis Services
services.AddSingleton();
services.AddScoped(); // Concrete class for Hangfire
- services.AddScoped(); // Interface
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -84,7 +75,7 @@ public static IServiceCollection AddBusinessServices(this IServiceCollection ser
services.AddScoped();
services.AddScoped();
- // AutoPilot & LLM (the Ollama typed client is registered by AddPkgDiffOutboundHttp)
+ // The Ollama typed client is registered by AddPkgDiffOutboundHttp
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/backend/PkgDiff.API/Extensions/StartupTasks.cs b/src/backend/PkgDiff.API/Extensions/StartupTasks.cs
index 5fdcfdb..cc16a0b 100644
--- a/src/backend/PkgDiff.API/Extensions/StartupTasks.cs
+++ b/src/backend/PkgDiff.API/Extensions/StartupTasks.cs
@@ -9,12 +9,10 @@
namespace PkgDiff.API.Extensions;
-// What the host does once before it serves traffic
public static class StartupTasks
{
private const string DefaultAutoPilotCron = "0 8,14,20 * * *";
- // The refresh job is what finds new versions for Live Updates; without the setting it still runs, every six hours
private const string DefaultCacheRefreshCron = "0 */6 * * *";
// A database that cannot be migrated stops the application
@@ -35,7 +33,7 @@ public static async Task MigrateDatabaseAsync(this WebApplication app)
}
}
- // Logs GitHub configuration and token validity; never any token characters
+ // Never logs any token characters
public static async Task LogGitHubStatusAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
@@ -53,7 +51,7 @@ await GitHubStartupProbe.LogStatusAsync(
}
}
- // Any failure (missing ADMIN_PASSWORD, Identity error) stops startup
+ // Any failure stops startup
public static async Task SeedDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
@@ -67,13 +65,11 @@ public static async Task ScheduleRecurringJobsAsync(this WebApplication app)
job => job.ExecuteAsync(),
app.Configuration["BackgroundJobs:CacheRefreshCron"] ?? DefaultCacheRefreshCron);
- // Security data of packages people looked at, refreshed in the background before it is served stale
RecurringJob.AddOrUpdate(
"security-refresh",
job => job.RefreshExpiredAsync(),
"15 */6 * * *");
- // Dependency audit reports are kept for 30 days
RecurringJob.AddOrUpdate(
"dependency-audit-cleanup",
job => job.DeleteExpiredAsync(),
diff --git a/src/backend/PkgDiff.API/Filters/HangfireAuthorizationFilter.cs b/src/backend/PkgDiff.API/Filters/HangfireAuthorizationFilter.cs
index cfe6fd2..186ddba 100644
--- a/src/backend/PkgDiff.API/Filters/HangfireAuthorizationFilter.cs
+++ b/src/backend/PkgDiff.API/Filters/HangfireAuthorizationFilter.cs
@@ -3,7 +3,7 @@
namespace PkgDiff.API.Filters;
-// Re-checks against the database on every dashboard request that the cookie or bearer user is still an active Admin.
+// Re-checks the database on every request that the user is still an active Admin
public class HangfireAuthorizationFilter : IDashboardAsyncAuthorizationFilter
{
public Task AuthorizeAsync(DashboardContext context)
diff --git a/src/backend/PkgDiff.API/Filters/ValidatePackageRouteAttribute.cs b/src/backend/PkgDiff.API/Filters/ValidatePackageRouteAttribute.cs
index 73e5132..25a13f7 100644
--- a/src/backend/PkgDiff.API/Filters/ValidatePackageRouteAttribute.cs
+++ b/src/backend/PkgDiff.API/Filters/ValidatePackageRouteAttribute.cs
@@ -79,7 +79,6 @@ private static async Task IsRegistryDisabledAsync(ActionExecutingContext c
var services = context.HttpContext.RequestServices;
var unitOfWork = services.GetRequiredService();
- // pub and pub.dev are aliases for the same registry row
var keys = RegistryNames.SpellingsOf(registry);
try
diff --git a/src/backend/PkgDiff.API/Jobs/DatabaseCleanupJob.cs b/src/backend/PkgDiff.API/Jobs/DatabaseCleanupJob.cs
index 6b12289..06180c8 100644
--- a/src/backend/PkgDiff.API/Jobs/DatabaseCleanupJob.cs
+++ b/src/backend/PkgDiff.API/Jobs/DatabaseCleanupJob.cs
@@ -3,7 +3,6 @@
namespace PkgDiff.API.Jobs;
-// Daily Hangfire job: the log tables would otherwise grow without end
public class DatabaseCleanupJob
{
private readonly ApplicationDbContext _dbContext;
@@ -15,17 +14,14 @@ public DatabaseCleanupJob(ApplicationDbContext dbContext)
public async Task CleanupOldRequestLogsAsync()
{
- // RequestLogs older than 30 days
await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM \"RequestLogs\" WHERE \"CreatedAt\" < {0}",
DateTime.UtcNow.AddDays(-30));
- // ScrapeLogs older than 30 days
await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM \"ScrapeLogs\" WHERE \"CreatedAt\" < {0}",
DateTime.UtcNow.AddDays(-30));
- // SearchHistories older than 90 days
await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM \"SearchHistories\" WHERE \"SearchedAt\" < {0}",
DateTime.UtcNow.AddDays(-90));
diff --git a/src/backend/PkgDiff.API/Middleware/GlobalExceptionHandler.cs b/src/backend/PkgDiff.API/Middleware/GlobalExceptionHandler.cs
index 9d14d3f..fb6e4ce 100644
--- a/src/backend/PkgDiff.API/Middleware/GlobalExceptionHandler.cs
+++ b/src/backend/PkgDiff.API/Middleware/GlobalExceptionHandler.cs
@@ -4,8 +4,7 @@
namespace PkgDiff.API.Middleware;
-// Last line of defence for an exception no action caught: a status code that fits, the detail in
-// the log only, and a ProblemDetails body that also carries the "error" field the SPA reads
+// Detail goes to the log only; the ProblemDetails body also carries the SPA's "error" field
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private const int ClientClosedRequest = 499;
diff --git a/src/backend/PkgDiff.API/Middleware/HangfireTicketMiddleware.cs b/src/backend/PkgDiff.API/Middleware/HangfireTicketMiddleware.cs
index 2b5af2b..07e2457 100644
--- a/src/backend/PkgDiff.API/Middleware/HangfireTicketMiddleware.cs
+++ b/src/backend/PkgDiff.API/Middleware/HangfireTicketMiddleware.cs
@@ -2,8 +2,7 @@
namespace PkgDiff.API.Middleware;
-// Exchanges a one-time dashboard ticket for the /hangfire cookie. This cannot live in the Hangfire
-// authorization filter because Hangfire overwrites the response status when a filter denies access.
+// Not in the authorization filter: Hangfire overwrites the response status when one denies
public class HangfireTicketMiddleware
{
private readonly RequestDelegate _next;
diff --git a/src/backend/PkgDiff.API/Middleware/MetricsAccessMiddleware.cs b/src/backend/PkgDiff.API/Middleware/MetricsAccessMiddleware.cs
index dfbc85b..888e3b7 100644
--- a/src/backend/PkgDiff.API/Middleware/MetricsAccessMiddleware.cs
+++ b/src/backend/PkgDiff.API/Middleware/MetricsAccessMiddleware.cs
@@ -33,9 +33,7 @@ public async Task InvokeAsync(HttpContext context)
await _next(context);
}
- // ForwardedHeadersMiddleware records the original peer only when it applied X-Forwarded-For, so
- // that header's presence means the request came through the proxy chain from a visitor. The bridge gateway is
- // where host-originated traffic (docker-proxy) appears from, so it is not an internal container.
+ // X-Original-For means a proxied visitor; the bridge gateway carries host-originated traffic
private bool IsInternalScrape(HttpContext context)
{
var peer = context.Connection.RemoteIpAddress;
diff --git a/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs b/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs
index eaf04ac..c733a95 100644
--- a/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs
+++ b/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs
@@ -27,8 +27,7 @@ public async Task InvokeAsync(HttpContext context)
var ip = context.GetClientIpAddress();
var path = context.Request.Path;
- // Only a signed-in caller gets the larger admin budget: the limiter runs before authentication,
- // so an anonymous request to an admin address is counted like any other
+ // Runs before authentication: only callers with credentials get the admin budget
if (IsAdminPath(path) && HasCredentials(context))
{
if (await IsOverLimitAsync("rl:admin", ip, _options.Admin))
@@ -42,7 +41,7 @@ public async Task InvokeAsync(HttpContext context)
await ReturnRateLimitError(context, "Too many requests. Please slow down.");
return;
}
- // Exports have their own counters: the general ones also count the API calls of the page the export is started from
+ // Separate counters: the page starting an export already spends the general budget
else if (IsPdfExport(path) && await IsOverLimitAsync("rl:pdf", ip, _options.PdfExport))
{
await ReturnRateLimitError(context, "Too many requests for PDF exports. Please slow down.");
@@ -66,13 +65,12 @@ private static bool IsAdminPath(PathString path) =>
path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/hangfire", StringComparison.OrdinalIgnoreCase);
- // Both PDF exports: /api/Package/{registry}/{id}/export/pdf and /api/Package/export/diff/pdf
private static bool IsPdfExport(PathString path) =>
path.Value is { } value
&& value.Contains("/export/", StringComparison.OrdinalIgnoreCase)
&& value.EndsWith("/pdf", StringComparison.OrdinalIgnoreCase);
- // Exactly this address: /api/audit/parse reads a file and starts nothing
+ // Exact path match, so /api/audit/parse does not count as an audit start
private static bool IsPost(HttpContext context, string path) =>
HttpMethods.IsPost(context.Request.Method)
&& string.Equals(context.Request.Path.Value?.TrimEnd('/'), path, StringComparison.OrdinalIgnoreCase);
diff --git a/src/backend/PkgDiff.API/Middleware/RequestLoggingMiddleware.cs b/src/backend/PkgDiff.API/Middleware/RequestLoggingMiddleware.cs
index 0cc0670..6a52088 100644
--- a/src/backend/PkgDiff.API/Middleware/RequestLoggingMiddleware.cs
+++ b/src/backend/PkgDiff.API/Middleware/RequestLoggingMiddleware.cs
@@ -9,16 +9,13 @@ public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
- // Endpoint prefixes that belong to internal/system traffic — never logged.
- // Skipping is path-only: behind the proxy every peer is a 172.* address, so IP or
- // user-agent based skips would silently drop real visitor traffic.
+ // Path-only: behind the proxy every peer is 172.*, so IP-based skips would drop visitors
private static readonly string[] _skipEndpoints =
[
"/health", "/metrics", "/swagger", "/hangfire",
"/analysisHub", "/favicon", "/_framework",
];
- // File extensions that are static assets — never logged
private static readonly string[] _skipExtensions =
[
".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
diff --git a/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs b/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs
index 1150886..f8899a6 100644
--- a/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs
+++ b/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs
@@ -6,16 +6,13 @@ public sealed class InboundRateLimitOptions
public RateLimitTier General { get; set; } = new() { PerMinute = 60, PerFiveMinutes = 200 };
- // Counted on its own, on top of the general tier: an export is far more expensive than an API call
+ // On top of the general tier: an export costs far more than an API call
public RateLimitTier PdfExport { get; set; } = new() { PerMinute = 5, PerFiveMinutes = 15 };
- // The admin panel and the Hangfire dashboard poll, so signed-in work needs more room than a visitor
public RateLimitTier Admin { get; set; } = new() { PerMinute = 240, PerFiveMinutes = 1200 };
- // Sign-in attempts per address: tight, so guessing passwords is slow
public RateLimitTier Login { get; set; } = new() { PerMinute = 5, PerFiveMinutes = 15 };
- // Each audit checks up to the package limit against OSV: a few per address are plenty
public RateLimitTier AuditStart { get; set; } = new() { PerMinute = 3, PerFiveMinutes = 6 };
public bool IsValid() =>
diff --git a/src/backend/PkgDiff.API/Options/TrustedNetworks.cs b/src/backend/PkgDiff.API/Options/TrustedNetworks.cs
index 77ce119..6b9fdae 100644
--- a/src/backend/PkgDiff.API/Options/TrustedNetworks.cs
+++ b/src/backend/PkgDiff.API/Options/TrustedNetworks.cs
@@ -21,8 +21,7 @@ public bool Contains(IPAddress address)
n.BaseAddress.AddressFamily == normalized.AddressFamily && n.Contains(normalized));
}
- // The Docker bridge gateway (e.g. 172.28.0.1) is where host-originated traffic appears from,
- // so it must never count as an internal container peer.
+ // The bridge gateway carries host-originated traffic, so it is never an internal peer
public bool IsGateway(IPAddress address)
{
var normalized = Normalize(address);
diff --git a/src/backend/PkgDiff.API/Program.cs b/src/backend/PkgDiff.API/Program.cs
index 0bdff27..e5e0fcc 100644
--- a/src/backend/PkgDiff.API/Program.cs
+++ b/src/backend/PkgDiff.API/Program.cs
@@ -18,7 +18,6 @@
builder.Services.AddPkgDiffPersistence(builder.Configuration);
-// JWT authentication (validated JwtSettings) and permission policies
builder.Services.AddPkgDiffAuthentication(builder.Configuration);
builder.Services.AddPkgDiffAuthorization();
@@ -28,7 +27,6 @@
builder.Services.AddPkgDiffCaching(builder.Configuration);
builder.Services.AddPkgDiffDiagnostics();
-// Forwarded headers (trusted proxy networks from ForwardedHeaders:TrustedNetworks)
builder.Services.AddPkgDiffForwardedHeaders(builder.Configuration);
builder.Services.AddPkgDiffCors();
@@ -40,8 +38,7 @@
await app.MigrateDatabaseAsync();
await app.LogGitHubStatusAsync();
-// The HTTP request pipeline; the order below matters.
-// Must run first so every later middleware sees the real client IP and scheme.
+// Pipeline order matters; forwarded headers first so later middleware sees the real client IP
app.UseForwardedHeaders();
app.UseExceptionHandler();
@@ -55,7 +52,7 @@
}
else
{
- // No HTTPS redirect here: TLS ends at the host nginx, which redirects HTTP itself; the container has no HTTPS port
+ // No HTTPS redirect: TLS ends at the host nginx
app.UseCors(HostingServiceCollectionExtensions.ProductionCorsPolicy);
app.UseHsts();
}
@@ -63,20 +60,18 @@
app.UseDefaultFiles();
app.UseStaticFiles();
-// Security middleware (must be before authentication)
+// Must run before authentication
app.UseMiddleware();
app.UseMiddleware();
app.UseMiddleware();
-app.UseAuthentication(); // Must be before UseAuthorization
+app.UseAuthentication();
app.UseAuthorization();
-// Prometheus metrics: internal scrape (trusted network, not the bridge gateway) or Admin only
app.UseMiddleware();
-app.UseMetricServer(); // Exposes /metrics
-app.UseHttpMetrics(); // Captures HTTP metrics
+app.UseMetricServer();
+app.UseHttpMetrics();
-// Hangfire dashboard, active Admins only: /hangfire?ticket= becomes a scoped cookie, re-checked on every request
app.UseMiddleware();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
@@ -101,7 +96,6 @@
await app.SeedDatabaseAsync();
-// After all middleware and initialization
await app.ScheduleRecurringJobsAsync();
app.Run();
diff --git a/src/backend/PkgDiff.API/Services/GitHubStartupProbe.cs b/src/backend/PkgDiff.API/Services/GitHubStartupProbe.cs
index 0902c4f..72f0e78 100644
--- a/src/backend/PkgDiff.API/Services/GitHubStartupProbe.cs
+++ b/src/backend/PkgDiff.API/Services/GitHubStartupProbe.cs
@@ -11,7 +11,7 @@ public static class GitHubStartupProbe
private const int UnauthenticatedHourlyLimit = 60;
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(5);
- // Never log any part of the token: prefixes/suffixes narrow brute force and identify the token in leaked logs.
+ // Never log any part of the token: even fragments identify it in leaked logs
public static async Task LogStatusAsync(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
diff --git a/src/backend/PkgDiff.API/Services/HangfireDashboardAccess.cs b/src/backend/PkgDiff.API/Services/HangfireDashboardAccess.cs
index cf4b02b..a2d691e 100644
--- a/src/backend/PkgDiff.API/Services/HangfireDashboardAccess.cs
+++ b/src/backend/PkgDiff.API/Services/HangfireDashboardAccess.cs
@@ -37,7 +37,7 @@ public string CreateTicket(string userId) =>
public string CreateTicket(string userId, DateTimeOffset expiresAt)
{
- // The random nonce identifies the ticket for the single-use marker independently of its textual encoding.
+ // The nonce keys the single-use marker independently of the ticket's encoding
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
return _ticketProtector.Protect(nonce + NonceSeparator + userId, expiresAt);
}
@@ -64,8 +64,7 @@ public string CreateTicket(string userId, DateTimeOffset expiresAt)
var nonce = payload[..separator];
var userId = payload[(separator + 1)..];
- // IDistributedCache has no atomic check-and-set: two redemptions racing in the same instant are
- // bounded by the 60 s ticket lifetime and the per-request Admin re-check.
+ // No atomic check-and-set: a same-instant race is bounded by the 60 s ticket lifetime
var markerKey = "hangfire-ticket-used:" + nonce;
if (await _cache.GetAsync(markerKey) is not null)
return null;
@@ -95,8 +94,7 @@ public string CreateCookieValue(string userId) =>
public async Task IsAuthorizedAsync(HttpContext context, IAdminRoleChecker checker)
{
- // A JWT role claim lives for up to 24 h and cannot be revoked, so the bearer path gets the same
- // "still an active Admin" database check as the cookie path
+ // JWT role claims cannot be revoked, so the bearer path also gets the database Admin check
if (context.User.IsInRole("Admin"))
{
var bearerUserId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? context.User.FindFirstValue("sub");
diff --git a/src/backend/PkgDiff.API/Services/IRateLimitCounterStore.cs b/src/backend/PkgDiff.API/Services/IRateLimitCounterStore.cs
index 5861444..d307a9c 100644
--- a/src/backend/PkgDiff.API/Services/IRateLimitCounterStore.cs
+++ b/src/backend/PkgDiff.API/Services/IRateLimitCounterStore.cs
@@ -2,9 +2,6 @@ namespace PkgDiff.API.Services;
public interface IRateLimitCounterStore
{
- ///
- /// Atomically increments the counter for the given key and returns the new value.
- /// Sets the TTL on first increment.
- ///
+ // Atomic; the TTL is set on the first increment only
Task IncrementAsync(string key, TimeSpan ttl);
}
diff --git a/src/backend/PkgDiff.API/Services/MemoryRateLimitCounterStore.cs b/src/backend/PkgDiff.API/Services/MemoryRateLimitCounterStore.cs
index 7495698..c6ba3f1 100644
--- a/src/backend/PkgDiff.API/Services/MemoryRateLimitCounterStore.cs
+++ b/src/backend/PkgDiff.API/Services/MemoryRateLimitCounterStore.cs
@@ -2,8 +2,7 @@
namespace PkgDiff.API.Services;
-// Fixed windows: the first request of a key starts its window, and the window ends when its time is up,
-// however many requests arrive in between
+// Fixed windows, each starting at the first request of its key
public class MemoryRateLimitCounterStore : IRateLimitCounterStore
{
private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1);
@@ -54,7 +53,6 @@ public Task IncrementAsync(string key, TimeSpan ttl)
}
}
- // Keeps the dictionary from growing with every address ever seen
private void Sweep(DateTime now)
{
var due = Interlocked.Read(ref _nextSweepTicks);
diff --git a/src/backend/PkgDiff.API/Services/ReadinessHealthChecks.cs b/src/backend/PkgDiff.API/Services/ReadinessHealthChecks.cs
index 0db04e3..13fd89b 100644
--- a/src/backend/PkgDiff.API/Services/ReadinessHealthChecks.cs
+++ b/src/backend/PkgDiff.API/Services/ReadinessHealthChecks.cs
@@ -54,7 +54,7 @@ public static class HealthResponses
{
public const string ReadyTag = "ready";
- // Names and states only: a health endpoint is unauthenticated, so it never carries exception text
+ // Unauthenticated endpoint: names and states only, never exception text
public static Task WriteAsync(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
diff --git a/src/backend/PkgDiff.API/Services/RedisRateLimitCounterStore.cs b/src/backend/PkgDiff.API/Services/RedisRateLimitCounterStore.cs
index be34e7f..b1f678f 100644
--- a/src/backend/PkgDiff.API/Services/RedisRateLimitCounterStore.cs
+++ b/src/backend/PkgDiff.API/Services/RedisRateLimitCounterStore.cs
@@ -4,8 +4,7 @@ namespace PkgDiff.API.Services;
public class RedisRateLimitCounterStore : IRateLimitCounterStore
{
- // One script, so a counter can never be left without an expiry: INCR followed by a separate
- // EXPIRE would block an address for good if the process stopped between the two commands
+ // One script, so a crash between INCR and EXPIRE cannot leave a counter without expiry
private const string IncrementScript = @"
local count = redis.call('INCR', KEYS[1])
if count == 1 then
diff --git a/src/backend/PkgDiff.API/Services/RequestLogBatchService.cs b/src/backend/PkgDiff.API/Services/RequestLogBatchService.cs
index e7d8cc4..f3cc10f 100644
--- a/src/backend/PkgDiff.API/Services/RequestLogBatchService.cs
+++ b/src/backend/PkgDiff.API/Services/RequestLogBatchService.cs
@@ -32,7 +32,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
- // Wait for the next item or flush interval
using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
cts.CancelAfter(_flushInterval);
@@ -57,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error processing log batch");
- await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // Backoff
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
diff --git a/src/backend/PkgDiff.Application/Diagnostics/PkgDiffTelemetry.cs b/src/backend/PkgDiff.Application/Diagnostics/PkgDiffTelemetry.cs
index c9abc32..6d04270 100644
--- a/src/backend/PkgDiff.Application/Diagnostics/PkgDiffTelemetry.cs
+++ b/src/backend/PkgDiff.Application/Diagnostics/PkgDiffTelemetry.cs
@@ -4,10 +4,8 @@
namespace PkgDiff.Application.Diagnostics;
-// The application's own metrics and traces, through the .NET APIs that OpenTelemetry collects:
-// one Meter and one ActivitySource, both named "PkgDiff". The /metrics endpoint publishes the meter
-// as it is; an OpenTelemetry SDK added to the host only has to subscribe to this name.
-// Tags are kept to small fixed sets. A package id goes on a trace, never on a metric.
+// The app's own metrics and traces: one Meter and one ActivitySource, both named "PkgDiff".
+// Tags stay small fixed sets: a package id goes on a trace, never on a metric.
public static class PkgDiffTelemetry
{
public const string Name = "PkgDiff";
diff --git a/src/backend/PkgDiff.Application/Interfaces/IAutoPilotService.cs b/src/backend/PkgDiff.Application/Interfaces/IAutoPilotService.cs
index 031ce00..ef0ae80 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IAutoPilotService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IAutoPilotService.cs
@@ -4,9 +4,7 @@ namespace PkgDiff.Application.Interfaces;
public interface IAutoPilotService
{
- // Hangfire reads filters from the interface method it enqueues. A failed draft is not
- // retried: the next scheduled run tries again, and retries of a slow CPU-bound LLM call
- // used to pile up behind each other and block the workers.
+ // Hangfire reads filters from the interface. No retries: the next scheduled run tries again.
[AutomaticRetry(Attempts = 0)]
[DisableConcurrentExecution(timeoutInSeconds: 600)]
Task RunAutoPilotJobAsync();
diff --git a/src/backend/PkgDiff.Application/Interfaces/IDependencyAuditService.cs b/src/backend/PkgDiff.Application/Interfaces/IDependencyAuditService.cs
index b90879d..3ee4d7c 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IDependencyAuditService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IDependencyAuditService.cs
@@ -4,21 +4,17 @@ namespace PkgDiff.Application.Interfaces;
public interface IDependencyAuditService
{
- // Stores the request and queues the background job; returns the id of the report
Task StartAsync(StartAuditRequest request);
- // null when there is no such report or it has expired
Task GetAsync(Guid id);
- // Audits waiting or running; the start endpoint refuses new ones above a limit
Task CountActiveAsync();
- // Background job, on its own queue: a burst of audits must not hold the workers scrapes need
+ // Own queue: a burst of audits must not hold the workers scrapes need
[Hangfire.Queue(AuditQueue)]
Task RunAsync(Guid id);
const string AuditQueue = "audits";
- // Recurring job
Task DeleteExpiredAsync();
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IExportService.cs b/src/backend/PkgDiff.Application/Interfaces/IExportService.cs
index fe5506e..e57aea8 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IExportService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IExportService.cs
@@ -4,13 +4,7 @@ namespace PkgDiff.Application.Interfaces;
public interface IExportService
{
- ///
- /// Export all release notes for a package to PDF format
- ///
Task ExportToPdfAsync(PackageMetadata metadata);
- ///
- /// Export diff analysis results to PDF format
- ///
Task ExportDiffToPdfAsync(AnalysisResult diff);
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IGitHubRateLimitHandler.cs b/src/backend/PkgDiff.Application/Interfaces/IGitHubRateLimitHandler.cs
index be8183c..d7c25b8 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IGitHubRateLimitHandler.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IGitHubRateLimitHandler.cs
@@ -1,26 +1,11 @@
namespace PkgDiff.Application.Interfaces;
-///
-/// Service for tracking and managing GitHub API rate limits based on response headers
-///
public interface IGitHubRateLimitHandler
{
- ///
- /// Counts one request against the budget, or throws
- /// when the budget is spent until the rate limit resets
- ///
- /// True if request can proceed
+ // Throws GitHubRateLimitedException while the budget is spent until the reset
Task CanMakeRequestAsync();
- ///
- /// Updates rate limit state from GitHub API response headers
- ///
- /// HTTP response containing rate limit headers
Task UpdateFromHeadersAsync(HttpResponseMessage response);
- ///
- /// Gets the current rate limit status
- ///
- /// Tuple of (remaining, limit, reset time)
Task<(int Remaining, int Limit, DateTime? ResetTime)> GetRateLimitStatusAsync();
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IGitHubScraperService.cs b/src/backend/PkgDiff.Application/Interfaces/IGitHubScraperService.cs
index b08a26f..db87464 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IGitHubScraperService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IGitHubScraperService.cs
@@ -2,23 +2,13 @@ namespace PkgDiff.Application.Interfaces;
public interface IGitHubScraperService
{
- ///
- /// Extracts the GitHub repository URL from a NuGet package page
- ///
Task ExtractGitHubRepositoryUrlAsync(string packageId);
- ///
- /// Gets all releases from a GitHub repository
- ///
- /// Dictionary with version as key and release notes as value
Task> GetAllReleasesAsync(string githubRepoUrl);
- ///
- /// Gets release notes for a specific version from GitHub Releases
- ///
Task GetReleaseNotesForVersionAsync(string githubRepoUrl, string version);
- // With the package name a monorepo's tags ("socket.io@4.8.3", "@scope/core@1.2.0") can be told apart
+ // The package name tells a monorepo's per-package tags apart
Task GetReleaseNotesForVersionAsync(string githubRepoUrl, string version, string? packageId) =>
GetReleaseNotesForVersionAsync(githubRepoUrl, version);
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IJobScheduler.cs b/src/backend/PkgDiff.Application/Interfaces/IJobScheduler.cs
index 943cb58..cf1debc 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IJobScheduler.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IJobScheduler.cs
@@ -2,8 +2,6 @@
namespace PkgDiff.Application.Interfaces;
-// Queues work for the background workers. The caller names the service and the call; which job
-// system stores and runs it is not its concern.
public interface IJobScheduler
{
string Enqueue(Expression> job);
diff --git a/src/backend/PkgDiff.Application/Interfaces/IPackageTrustService.cs b/src/backend/PkgDiff.Application/Interfaces/IPackageTrustService.cs
index 628ca0d..3c7a6f2 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IPackageTrustService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IPackageTrustService.cs
@@ -7,9 +7,7 @@ public interface IPackageTrustService
// Reads the stored data only and queues a refresh when it is missing or old; never calls a provider
Task GetTrustAsync(string registry, string packageId);
- // Background job: asks the providers and stores the result
Task RefreshAsync(string registry, string packageId);
- // Recurring job: refreshes the stored packages whose data has expired
Task RefreshExpiredAsync();
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IRateLimiterService.cs b/src/backend/PkgDiff.Application/Interfaces/IRateLimiterService.cs
index 62f7464..50aea9a 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IRateLimiterService.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IRateLimiterService.cs
@@ -1,24 +1,10 @@
namespace PkgDiff.Application.Interfaces;
-///
-/// Service for managing rate limits for external API calls (e.g., NuGet.org web scraping)
-///
public interface IRateLimiterService
{
- ///
- /// Waits if necessary to respect rate limits, then executes the action
- ///
- /// Rate limit key (e.g., "nuget-scraping")
- /// Action to execute
Task ExecuteAsync(string key, Func> action);
- ///
- /// Checks if the rate limit allows execution without waiting
- ///
bool CanExecute(string key);
- ///
- /// Gets the time until the next available slot
- ///
TimeSpan GetTimeUntilNextSlot(string key);
}
diff --git a/src/backend/PkgDiff.Application/Interfaces/IRegistryClient.cs b/src/backend/PkgDiff.Application/Interfaces/IRegistryClient.cs
index 9ba5c6c..7aa2ce0 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IRegistryClient.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IRegistryClient.cs
@@ -3,14 +3,11 @@
namespace PkgDiff.Application.Interfaces;
-// Everything that differs from one registry to the next. Each registry has exactly one client,
-// found by name through IRegistryServiceFactory.
public interface IRegistryClient
{
- // One of RegistryNames
string Id { get; }
- // The release-notes source recorded for a version whose notes come from the registry itself
+ // Source recorded for notes that come from the registry itself
string SourceLabel { get; }
VersionScheme VersionScheme { get; }
@@ -21,7 +18,7 @@ public interface IRegistryClient
// False when the notes of a package can only come from its repository
bool PublishesReleaseNotes { get; }
- // True when the metadata of a package names its folder in the repository (PackageMetadata.SourceDirectory)
+ // True when package metadata names its folder in the repository
bool KnowsSourceDirectory => false;
Task PackageExistsAsync(string packageId, bool includePrerelease);
@@ -33,7 +30,6 @@ public interface IRegistryClient
Task GetMetadataAsync(string packageId);
- // The notes the registry itself publishes for a version; null when it publishes none
Task GetOwnReleaseNotesAsync(string packageId, string version);
IReleaseNotesScrapingStrategy GetScrapingStrategy(string? githubRepoUrl);
diff --git a/src/backend/PkgDiff.Application/Interfaces/IReleaseNotesUrlResolver.cs b/src/backend/PkgDiff.Application/Interfaces/IReleaseNotesUrlResolver.cs
index bafd9ad..de0f869 100644
--- a/src/backend/PkgDiff.Application/Interfaces/IReleaseNotesUrlResolver.cs
+++ b/src/backend/PkgDiff.Application/Interfaces/IReleaseNotesUrlResolver.cs
@@ -1,16 +1,8 @@
namespace PkgDiff.Application.Interfaces;
-///
-/// Detects URLs in release notes text that point to actual release notes content,
-/// fetches and returns the resolved content. Returns original text if no URL resolution needed.
-///
public interface IReleaseNotesUrlResolver
{
- ///
- /// Inspects release notes text. If the text is URL-dominated (contains URLs but little
- /// substantive content), extracts URLs, fetches their content, and returns the resolved
- /// release notes. Otherwise returns the original text unchanged.
- ///
+ // Fetches the linked content when the notes are mostly URLs; otherwise returns them unchanged
Task ResolveAsync(
string rawReleaseNotes,
string packageId,
@@ -18,10 +10,6 @@ Task ResolveAsync(
string? githubRepoUrl);
}
-/// The resolved (or original) release notes text
-/// True if URLs were detected and content was fetched from them
-/// Source description if resolved
-/// GitHub repo URL discovered during resolution, if any
public record UrlResolverResult(
string ReleaseNotes,
bool WasResolved,
diff --git a/src/backend/PkgDiff.Application/Jobs/BlogSocialPostJob.cs b/src/backend/PkgDiff.Application/Jobs/BlogSocialPostJob.cs
index 24b4d1d..2938d90 100644
--- a/src/backend/PkgDiff.Application/Jobs/BlogSocialPostJob.cs
+++ b/src/backend/PkgDiff.Application/Jobs/BlogSocialPostJob.cs
@@ -61,8 +61,7 @@ public async Task ExecuteAsync(int blogPostId)
if (previousPosts.Any(p => p.Platform == platform && p.Status == SocialMediaPostStatus.Success))
continue;
- // A Pending row means an earlier attempt reached the platform but its outcome was never recorded;
- // posting again could publish a duplicate, so it is left for an admin to check
+ // Pending means an earlier attempt's outcome is unknown; reposting could publish a duplicate
if (previousPosts.Any(p => p.Platform == platform && p.Status == SocialMediaPostStatus.Pending))
{
_logger.LogWarning(
diff --git a/src/backend/PkgDiff.Application/Jobs/PackageAnalysisJob.cs b/src/backend/PkgDiff.Application/Jobs/PackageAnalysisJob.cs
index d2a1e50..a50328e 100644
--- a/src/backend/PkgDiff.Application/Jobs/PackageAnalysisJob.cs
+++ b/src/backend/PkgDiff.Application/Jobs/PackageAnalysisJob.cs
@@ -29,10 +29,7 @@ public async Task ExecuteAsync()
try
{
- // Get all cached packages with their registry info
- // Only what the refresh request needs, not the cached version lists
- // Only packages someone looked at in the last 30 days (before views were tracked: cached then).
- // Anyone can add a package by opening it; one nobody visits is refreshed when it is opened again.
+ // Only packages viewed in the last 30 days; an unvisited one refreshes when it is opened again
var cutoff = DateTime.UtcNow - RefreshWindow;
var packages = await _unitOfWork.ToListAsync(_unitOfWork.CachedPackages.Query()
.Where(p => (p.LastViewedAt ?? p.CachedAt) > cutoff)
@@ -46,13 +43,12 @@ public async Task ExecuteAsync()
{
PackageId = package.PackageId,
Registry = package.Registry,
- CurrentVersion = string.Empty, // Will fetch latest
- TargetVersion = string.Empty, // Will fetch latest
+ CurrentVersion = string.Empty,
+ TargetVersion = string.Empty,
IncludePrerelease = false
};
- // One job per package, run in parallel by the Hangfire workers; a package that cannot
- // be compared is skipped there instead of failing
+ // One job per package; one that cannot be compared is skipped there instead of failing
_jobs.Enqueue(x => x.RefreshAsync(request));
}
diff --git a/src/backend/PkgDiff.Application/Jobs/PackageRefreshJob.cs b/src/backend/PkgDiff.Application/Jobs/PackageRefreshJob.cs
index 8004b6d..8fb998f 100644
--- a/src/backend/PkgDiff.Application/Jobs/PackageRefreshJob.cs
+++ b/src/backend/PkgDiff.Application/Jobs/PackageRefreshJob.cs
@@ -4,10 +4,7 @@
namespace PkgDiff.Application.Jobs;
-// Refreshes one cached package for the recurring dispatcher (PackageAnalysisJob).
-// A package that cannot be compared, because its registry lists a single version or no longer
-// knows it, was cached when someone opened its page. That is not a failure of the refresh: every
-// later run would fail the same way and fill the failed-job list of the dashboard.
+// A package that cannot be compared is skipped, not failed: every later run would fail the same way.
public class PackageRefreshJob
{
private readonly IPackageAnalysisService _analysisService;
diff --git a/src/backend/PkgDiff.Application/Security/TrustDiff.cs b/src/backend/PkgDiff.Application/Security/TrustDiff.cs
index 8aaeb25..f57f055 100644
--- a/src/backend/PkgDiff.Application/Security/TrustDiff.cs
+++ b/src/backend/PkgDiff.Application/Security/TrustDiff.cs
@@ -2,8 +2,7 @@
namespace PkgDiff.Application.Security;
-// The security side of an upgrade: what the FROM version suffers from that the TO version no longer
-// does, and what TO brings that FROM did not have
+// What an upgrade fixes (in FROM, gone in TO) and what it introduces (in TO, not in FROM)
public static class TrustDiff
{
public static IReadOnlyList AdvisoriesOf(PackageTrust trust, string version)
diff --git a/src/backend/PkgDiff.Application/Services/AdminDashboardService.cs b/src/backend/PkgDiff.Application/Services/AdminDashboardService.cs
index c3d7690..57c7f87 100644
--- a/src/backend/PkgDiff.Application/Services/AdminDashboardService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminDashboardService.cs
@@ -19,8 +19,6 @@ public AdminDashboardService(IUnitOfWork unitOfWork)
private const string SystemCaller = "System";
- // ───── Dashboard ─────
-
public async Task GetDashboardStatsAsync()
{
// Background refreshes record "System" as their caller; they are not searches
@@ -71,8 +69,6 @@ public async Task GetDashboardStatsAsync()
};
}
- // ───── Search History ─────
-
public async Task> GetSearchHistoryAsync(int page = 1, int pageSize = 20)
{
(page, pageSize) = Paging.Clamp(page, pageSize);
diff --git a/src/backend/PkgDiff.Application/Services/AdminJobService.cs b/src/backend/PkgDiff.Application/Services/AdminJobService.cs
index 9b60153..a344418 100644
--- a/src/backend/PkgDiff.Application/Services/AdminJobService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminJobService.cs
@@ -27,7 +27,6 @@ public Task GetJobsOverviewAsync()
LastJobState = j.LastJobState ?? "None"
}).ToList();
- // Recent completed jobs — fetch more to capture full batches
var succeeded = monitor.SucceededJobs(0, 50);
var failed = monitor.FailedJobs(0, 50);
@@ -63,7 +62,6 @@ public Task GetJobsOverviewAsync()
});
}
- // Group: parent (ExecuteAsync) jobs get children attached
var parentJobs = allJobs
.Where(j => j.JobName.Contains("ExecuteAsync"))
.OrderByDescending(j => j.CreatedAt)
@@ -77,7 +75,6 @@ public Task GetJobsOverviewAsync()
foreach (var parent in parentJobs)
{
- // Match children created within 5 seconds of parent's CreatedAt
var children = childPool
.Where(c => parent.CreatedAt.HasValue && c.CreatedAt.HasValue &&
Math.Abs((c.CreatedAt.Value - parent.CreatedAt.Value).TotalSeconds) < 5)
@@ -92,7 +89,6 @@ public Task GetJobsOverviewAsync()
recentJobs.Add(parent);
}
- // Any unmatched children as standalone entries
recentJobs.AddRange(childPool);
recentJobs = recentJobs
@@ -100,7 +96,6 @@ public Task GetJobsOverviewAsync()
.Take(20)
.ToList();
- // Active queue jobs (enqueued + processing)
var queuedJobs = new List();
var queues = monitor.Queues();
@@ -157,11 +152,9 @@ private static string GetJobDisplayName(Hangfire.Common.Job? job)
{
if (job?.Args == null || job.Args.Count == 0) return null;
- // Try direct cast (Hangfire deserializes to the actual type)
if (job.Args[0] is AnalyzeRequest request)
return request.PackageId;
- // Fallback: parse from serialized JSON string
try
{
var json = job.Args[0]?.ToString();
diff --git a/src/backend/PkgDiff.Application/Services/AdminPackageService.cs b/src/backend/PkgDiff.Application/Services/AdminPackageService.cs
index f1b562a..4661310 100644
--- a/src/backend/PkgDiff.Application/Services/AdminPackageService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminPackageService.cs
@@ -17,8 +17,6 @@ public AdminPackageService(IUnitOfWork unitOfWork)
_unitOfWork = unitOfWork;
}
- // ───── Packages ─────
-
public async Task> GetPackagesAsync(int page = 1, int pageSize = 20, string? search = null)
{
(page, pageSize) = Paging.Clamp(page, pageSize);
@@ -48,15 +46,12 @@ public async Task DeletePackageAsync(int id)
var entity = await _unitOfWork.CachedPackages.GetByIdAsync(id);
if (entity == null) throw new KeyNotFoundException($"Package with id {id} not found");
- // Delete related release notes first
await _unitOfWork.CachedReleaseNotes.DeleteWhereAsync(r => r.CachedPackageId == id);
await _unitOfWork.CachedPackages.DeleteAsync(entity);
await _unitOfWork.SaveChangesAsync();
}
- // ───── Registries ─────
-
public async Task> GetRegistriesAsync()
{
var registries = await _unitOfWork.Registries.GetAllAsync();
@@ -148,8 +143,6 @@ public async Task ReorderRegistriesAsync(List requests)
await _unitOfWork.SaveChangesAsync();
}
- // ───── Sitemap ─────
-
public async Task> GetPackagesForSitemapAsync()
{
return await _unitOfWork.ToListAsync(_unitOfWork.CachedPackages.Query()
diff --git a/src/backend/PkgDiff.Application/Services/AdminRoleService.cs b/src/backend/PkgDiff.Application/Services/AdminRoleService.cs
index 845c903..b3544ac 100644
--- a/src/backend/PkgDiff.Application/Services/AdminRoleService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminRoleService.cs
@@ -66,8 +66,7 @@ public async Task DeleteRoleAsync(string id, bool callerIsAdmin, IReadOnlyCollec
throw new InvalidOperationException("The 'Admin' role is a system role and cannot be deleted.");
}
- // Deleting a role revokes its permissions from every member: a non-Admin may do that only for
- // permissions they hold, as when editing the role
+ // Deleting a role revokes its permissions, so a non-Admin must hold them, as when editing
if (!callerIsAdmin)
{
var rolePermissions = (await _roleManager.GetClaimsAsync(role)).Where(c => c.Type == "Permission").Select(c => c.Value);
@@ -143,8 +142,7 @@ public async Task UpdateRolePermissionsAsync(
var currentClaims = await _roleManager.GetClaimsAsync(role);
var permissionClaims = currentClaims.Where(c => c.Type == "Permission").ToList();
- // Permission policies pass on the claim alone, so a non-Admin may only grant or revoke what they hold;
- // otherwise Roles.Edit would let them hand their own role every permission
+ // Policies check the claim alone, so a non-Admin may only grant or revoke permissions they hold
if (!callerIsAdmin)
{
var currentPermissions = permissionClaims.Select(c => c.Value).ToHashSet(StringComparer.Ordinal);
@@ -156,13 +154,11 @@ public async Task UpdateRolePermissionsAsync(
}
}
- // Remove existing permission claims
foreach (var claim in permissionClaims)
{
await _roleManager.RemoveClaimAsync(role, claim);
}
- // Add new permission claims
foreach (var permission in requestedPermissions)
{
await _roleManager.AddClaimAsync(role, new System.Security.Claims.Claim("Permission", permission));
diff --git a/src/backend/PkgDiff.Application/Services/AdminSettingsService.cs b/src/backend/PkgDiff.Application/Services/AdminSettingsService.cs
index 44a2d14..e45b836 100644
--- a/src/backend/PkgDiff.Application/Services/AdminSettingsService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminSettingsService.cs
@@ -82,15 +82,15 @@ public static string GetCronExpressionForFrequency(int dailyFrequency)
{
return dailyFrequency switch
{
- 1 => "0 12 * * *", // Once a day at noon
- 2 => "0 0,12 * * *", // Every 12 hours
- 3 => "0 8,14,20 * * *", // 3 times a day
- 4 => "0 0,6,12,18 * * *", // Every 6 hours
- 6 => "0 0,4,8,12,16,20 * * *", // Every 4 hours
- 8 => "0 0,3,6,9,12,15,18,21 * * *", // Every 3 hours
- 12 => "0 0,2,4,6,8,10,12,14,16,18,20,22 * * *", // Every 2 hours
- _ when dailyFrequency >= 24 => "0 * * * *", // Every hour
- _ => "0 8,14,20 * * *" // Default fallback
+ 1 => "0 12 * * *",
+ 2 => "0 0,12 * * *",
+ 3 => "0 8,14,20 * * *",
+ 4 => "0 0,6,12,18 * * *",
+ 6 => "0 0,4,8,12,16,20 * * *",
+ 8 => "0 0,3,6,9,12,15,18,21 * * *",
+ 12 => "0 0,2,4,6,8,10,12,14,16,18,20,22 * * *",
+ _ when dailyFrequency >= 24 => "0 * * * *",
+ _ => "0 8,14,20 * * *"
};
}
}
diff --git a/src/backend/PkgDiff.Application/Services/AdminUserService.cs b/src/backend/PkgDiff.Application/Services/AdminUserService.cs
index 28aa50e..278e944 100644
--- a/src/backend/PkgDiff.Application/Services/AdminUserService.cs
+++ b/src/backend/PkgDiff.Application/Services/AdminUserService.cs
@@ -307,7 +307,7 @@ public async Task ResetUserPasswordAsync(
var user = await _userManager.FindByIdAsync(id);
if (user == null) throw new KeyNotFoundException($"User with id {id} not found");
- // Resetting an Admin's password is an account takeover, so it needs the same Admin-only rule as granting Admin.
+ // Resetting an Admin's password is an account takeover, so it is Admin-only
await ThrowIfNonAdminTargetsAdminAsync(user, callerIsAdmin, "Only administrators can reset an administrator's password.");
await ThrowIfTargetExceedsCallerAsync(user, callerIsAdmin, callerPermissions, "You cannot reset the password of a user with permissions you do not hold.");
diff --git a/src/backend/PkgDiff.Application/Services/AuthService.cs b/src/backend/PkgDiff.Application/Services/AuthService.cs
index 1e6f004..a5f6953 100644
--- a/src/backend/PkgDiff.Application/Services/AuthService.cs
+++ b/src/backend/PkgDiff.Application/Services/AuthService.cs
@@ -38,17 +38,14 @@ public AuthService(
return null;
}
- // No account lockout: with a public user name anyone could keep the admin locked out by failing on
- // purpose. Guessing is slowed per address instead (the sign-in rate tier and AuthController).
+ // No lockout: anyone could lock out a known admin name; guessing is throttled per address
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: false);
if (!result.Succeeded)
return null;
- // Update last login
user.LastLoginAt = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
- // Generate JWT token
var token = await GenerateJwtToken(user);
var roles = await _userManager.GetRolesAsync(user);
var permissions = new List();
@@ -153,7 +150,6 @@ private async Task GenerateJwtToken(ApplicationUser user)
new Claim(SecurityStampClaim, await _userManager.GetSecurityStampAsync(user) ?? string.Empty)
};
- // Add role claims and permissions
foreach (var roleName in roles)
{
claims.Add(new Claim(ClaimTypes.Role, roleName));
diff --git a/src/backend/PkgDiff.Application/Services/AutoPilotOutputCleaner.cs b/src/backend/PkgDiff.Application/Services/AutoPilotOutputCleaner.cs
index 7899d1b..1d5082b 100644
--- a/src/backend/PkgDiff.Application/Services/AutoPilotOutputCleaner.cs
+++ b/src/backend/PkgDiff.Application/Services/AutoPilotOutputCleaner.cs
@@ -23,9 +23,7 @@ public static class AutoPilotOutputCleaner
AutoPilotQualityGate.UpgradeNotesHeading
};
- // Small models rarely reproduce the exact heading text; these are the variants seen in practice.
- // Synonyms only apply to top-level headings (H1/H2), bold lines and bare lines, so an H3 such as
- // "### Changes" inside a section is not promoted to a second required section.
+ // Heading variants small models produce; synonyms apply only to H1/H2, bold and bare lines
private static readonly Dictionary HeadingSynonyms = new(StringComparer.Ordinal)
{
["summary"] = AutoPilotQualityGate.OverviewHeading,
@@ -253,8 +251,7 @@ private static string NormalizeSpacing(string line)
return line[..start] + body;
}
- // Returns the canonical "## ..." heading when the line is a required section in any common form
- // (H1-H3, **bold**, bare "Summary:" line, numbered, curly apostrophe), otherwise null
+ // The canonical "## ..." heading when the line is a required section in any common form, else null
private static string? CanonicalizeHeading(string trimmed)
{
if (trimmed.Length == 0)
diff --git a/src/backend/PkgDiff.Application/Services/AutoPilotService.cs b/src/backend/PkgDiff.Application/Services/AutoPilotService.cs
index 62bb431..8453197 100644
--- a/src/backend/PkgDiff.Application/Services/AutoPilotService.cs
+++ b/src/backend/PkgDiff.Application/Services/AutoPilotService.cs
@@ -13,9 +13,7 @@ public class AutoPilotService : IAutoPilotService
private const int MaxReleaseNotesChars = 6000;
private const string ReleaseNotesTruncatedMarker = "[Release notes truncated]";
- // Low temperature and a repeat penalty keep small models close to the source notes.
- // The prod host runs Ollama on a CPU without AVX2 (~1-2 tokens/s), so the context and
- // output budget stay small enough for a draft to finish well inside the HTTP timeout.
+ // Low temperature keeps small models close to the notes; small budgets suit a slow CPU-only host
private static readonly OllamaGenerationOptions BlogGenerationOptions = new(
Temperature: 0.2,
TopP: 0.9,
@@ -43,7 +41,6 @@ public AutoPilotService(
[DisableConcurrentExecution(timeoutInSeconds: 600)]
public async Task RunAutoPilotJobAsync()
{
- // Check if AutoPilot is enabled in DB settings
var enabledSetting = (await _unitOfWork.SystemSettings.FindAsync(s => s.Key == "AutoPilot:Enabled")).FirstOrDefault();
if (enabledSetting != null && enabledSetting.Value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
@@ -51,7 +48,6 @@ public async Task RunAutoPilotJobAsync()
return;
}
- // Read LLM model from DB, fall back to config/default
var modelSetting = (await _unitOfWork.SystemSettings.FindAsync(s => s.Key == "AutoPilot:LlmModel")).FirstOrDefault();
var llmModel = modelSetting?.Value ?? _defaultLlmModel;
@@ -91,7 +87,6 @@ public async Task RunAutoPilotJobAsync()
if (!AutoPilotQualityGate.Passes(cleaned, targetPackage.PackageId, out var reason))
{
// Returning instead of throwing: a Hangfire retry would only replay the same bad model output
- // The model's own headings are logged so a rejection can be diagnosed without the full output
var modelHeadings = llmResponse.Split('\n')
.Select(l => l.Trim())
.Where(l => l.StartsWith('#') || l.StartsWith("**", StringComparison.Ordinal))
@@ -106,7 +101,7 @@ public async Task RunAutoPilotJobAsync()
var post = BuildDraftPost(cleaned, targetPackage, latestReleaseNote, version, llmModel);
await _unitOfWork.BlogPosts.AddAsync(post);
- await _unitOfWork.SaveChangesAsync(); // Save blog to get the ID
+ await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("AutoPilot: Created draft blog post for {PackageId}: {Slug}", targetPackage.PackageId, post.Slug);
}
@@ -119,11 +114,9 @@ public async Task RunAutoPilotJobAsync()
private async Task<(CachedPackage? Package, CachedReleaseNote? ReleaseNote)> SelectInterestingPackageAsync()
{
- // Fetch 50 most recently searched / cached packages (include Registry for Value)
var recentPackages = await _unitOfWork.CachedPackages.GetTopDescendingWithIncludeAsync(
p => p.CachedAt, p => p.Registry, 50);
- // Avoid duplicates: skip packages whose current version already has a blog post
var allBlogs = await _unitOfWork.ToListAsync(_unitOfWork.BlogPosts.Query()
.Select(b => new { b.PackageId, b.Version, b.CachedReleaseNoteId }));
var bloggedVersionKeys = allBlogs
@@ -135,20 +128,17 @@ public async Task RunAutoPilotJobAsync()
.Select(b => b.CachedReleaseNoteId!.Value)
.ToHashSet();
- // Find the first candidate that has meaningful release notes
foreach (var package in recentPackages)
{
var releaseNote = await GetLatestReleaseNote(package.Id);
if (releaseNote != null && bloggedReleaseNoteIds.Contains(releaseNote.Id))
{
- // We've already written a blog post specifically for this release note
continue;
}
string versionToCheck = releaseNote?.Version ?? package.LatestVersion ?? "Unknown";
- // Legacy / fallback checks by version string
if (bloggedVersionKeys.Contains($"{package.PackageId}::{versionToCheck}") ||
bloggedVersionKeys.Contains($"{package.PackageId}::{package.LatestVersion}"))
{
@@ -227,9 +217,8 @@ private string GenerateSlug(string phrase)
{
string str = Regex.Replace(phrase.ToLowerInvariant(), @"[^a-z0-9\s-]", "");
str = Regex.Replace(str, @"[\s-]+", " ").Trim();
- str = Regex.Replace(str, @"\s", "-"); // hyphens
+ str = Regex.Replace(str, @"\s", "-");
- // add random hash to prevent conflicts
str = $"{str}-{Guid.NewGuid().ToString().Substring(0, 5)}";
return str;
}
diff --git a/src/backend/PkgDiff.Application/Services/DependencyAuditService.cs b/src/backend/PkgDiff.Application/Services/DependencyAuditService.cs
index 8a8f837..0ab35f3 100644
--- a/src/backend/PkgDiff.Application/Services/DependencyAuditService.cs
+++ b/src/backend/PkgDiff.Application/Services/DependencyAuditService.cs
@@ -10,9 +10,7 @@
namespace PkgDiff.Application.Services;
-// A dependency audit: the visitor's packages are stored, a background job looks each one up in its
-// registry (does it exist, what is its latest version), asks OSV about all of them in one batch and
-// stores the report. Nothing is fetched while the visitor waits; the page polls the report.
+// Audits run in a background job and are stored; nothing is fetched while the visitor waits
public class DependencyAuditService : IDependencyAuditService
{
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30);
@@ -207,7 +205,7 @@ private async Task LookUpAsync(IRegistryClient client, AuditDependenc
}
else
{
- // The registry's own spelling of the same version (NuGet writes 1.0 as 1.0.0)
+ // The registry's own spelling of the same version
item.Version = PackageVersion.TryParse(client.VersionScheme, dependency.Version, out var parsed)
? versions.FirstOrDefault(v => v.Equals(parsed))?.Value ?? dependency.Version
: dependency.Version;
@@ -228,8 +226,7 @@ private async Task LookUpAsync(IRegistryClient client, AuditDependenc
return item;
}
- // The lowest version that fixes every advisory: for each advisory its first fixed version above
- // the checked one, and the highest of those
+ // Lowest version fixing every advisory: the highest first-fixed version above the checked one
private static (string? Safe, bool HasUnfixed) SafeVersionOf(VersionScheme scheme, string current, List advisories)
{
if (!PackageVersion.TryParse(scheme, current, out var checkedVersion))
diff --git a/src/backend/PkgDiff.Application/Services/ExportService.cs b/src/backend/PkgDiff.Application/Services/ExportService.cs
index 81f9f9c..99180dd 100644
--- a/src/backend/PkgDiff.Application/Services/ExportService.cs
+++ b/src/backend/PkgDiff.Application/Services/ExportService.cs
@@ -13,7 +13,6 @@ public class ExportService : IExportService
// QuestPDF settings are process-wide: set them once, not on every request that resolves this scoped service
static ExportService()
{
- // Configure QuestPDF license (Community license for open source projects)
QuestPDF.Settings.License = LicenseType.Community;
// Release notes carry arbitrary Unicode; a glyph missing from the bundled font must not fail the export
@@ -42,7 +41,6 @@ public Task ExportToPdfAsync(PackageMetadata metadata)
{
column.Spacing(5);
- // Package Info
column.Item().Text($"Latest Version: {metadata.LatestVersion}").FontSize(12);
if (!string.IsNullOrEmpty(metadata.Description))
{
@@ -57,7 +55,6 @@ public Task ExportToPdfAsync(PackageMetadata metadata)
ComposePackageSecurity(column, metadata);
- // Versions
foreach (var version in metadata.Versions)
{
column.Item().PaddingTop(15).Column(versionColumn =>
@@ -148,13 +145,11 @@ public Task ExportDiffToPdfAsync(AnalysisResult diff)
{
column.Spacing(5);
- // Diff Info
column.Item().Text($"From: {diff.FromVersion} → To: {diff.ToVersion}").FontSize(12);
column.Item().Text($"Total Versions: {diff.TotalVersions}");
column.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
- // Summary
column.Item().PaddingTop(10).Text("Summary").SemiBold().FontSize(14);
column.Item().PaddingTop(5).Table(table =>
{
@@ -192,7 +187,6 @@ static IContainer CellStyle(IContainer container)
ComposeUpgradeSecurity(column, diff);
- // Versions
column.Item().PaddingTop(10).Text("Changes by Version").SemiBold().FontSize(14);
foreach (var version in diff.Versions)
diff --git a/src/backend/PkgDiff.Application/Services/LiveUpdateService.cs b/src/backend/PkgDiff.Application/Services/LiveUpdateService.cs
index afd26b5..ffac963 100644
--- a/src/backend/PkgDiff.Application/Services/LiveUpdateService.cs
+++ b/src/backend/PkgDiff.Application/Services/LiveUpdateService.cs
@@ -36,7 +36,7 @@ public async Task> GetLiveUpdatesAsync(int page = 1,
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
- // A package cached before LatestVersionDate existed is ordered by its cache time until a refresh fills the date in
+ // Packages without a LatestVersionDate are ordered by cache time until a refresh fills it in
var (items, total) = await _unitOfWork.CachedPackages.GetPageDescendingAsync(
p => p.LatestVersion != "",
p => p.LatestVersionDate ?? p.CachedAt,
diff --git a/src/backend/PkgDiff.Application/Services/PackageAnalysisService.cs b/src/backend/PkgDiff.Application/Services/PackageAnalysisService.cs
index addcad7..a8454d1 100644
--- a/src/backend/PkgDiff.Application/Services/PackageAnalysisService.cs
+++ b/src/backend/PkgDiff.Application/Services/PackageAnalysisService.cs
@@ -13,7 +13,6 @@ namespace PkgDiff.Application.Services;
public class PackageAnalysisService : IPackageAnalysisService
{
- // A version whose notes were not found is looked up again after this long, not on every scrape of its package
private static readonly TimeSpan EmptyNotesRetryAfter = TimeSpan.FromDays(1);
private const int MaxScrapedVersions = 300;
@@ -81,7 +80,6 @@ public async Task AnalyzePackageAsync(AnalyzeRequest request, st
await SendProgress(connectionId, $"Initializing analysis for {request.PackageId}...");
await ValidatePackageExistsAsync(request, registry);
- // No target version means "the latest one": the background refresh asks this way
var latestRequested = string.IsNullOrEmpty(request.TargetVersion);
await AutoSelectVersionsIfNeeded(request, registry);
var (fromVersion, toVersion) = ParseVersions(request, registry);
@@ -101,7 +99,7 @@ public async Task AnalyzePackageAsync(AnalyzeRequest request, st
var summary = _analyzerService.GenerateSummary(versionInfoList);
await SaveSearchHistoryAsync(request, versionInfoList, startTime, ipAddress);
- // A version a visitor picked must not become news; it may only supply the date of the version already stored
+ // A visitor-picked version must not become news; it may only date the stored latest
var isStoredLatest = registry.TryParseVersion(cachedPackage.LatestVersion, out var storedLatest) && storedLatest.Equals(toVersion);
if (latestRequested || isStoredLatest)
{
@@ -142,8 +140,7 @@ await _workTracker.RunCoalescedAsync(WorkKeys.Scrape(registry, packageId), async
string? githubRepoUrl = cachedPackage.GitHubRepositoryUrl;
var registryClient = _registryFactory.GetClient(registry);
- // The newest versions only: a package with thousands of them would hold a worker and the GitHub
- // budget for hours. Older versions are still fetched when someone compares a range that has them.
+ // Newest versions only, so a package with thousands of them cannot hold a worker for hours
var versions = (JsonSerializer.Deserialize>(cachedPackage.AllVersionsJson) ?? [])
.Select(v => (Text: v, Parsed: registryClient.TryParseVersion(v, out var parsed) ? parsed : null))
.OrderByDescending(v => v.Parsed is not null)
@@ -174,8 +171,7 @@ await _progressReporter.BroadcastBackgroundProgressAsync(packageId,
}
catch (GitHubRateLimitedException ex)
{
- // The remaining versions are not without notes: the scrape goes on once the limit resets,
- // skipping the versions already done
+ // The scrape resumes once the limit resets, skipping the versions already done
var delay = ex.ResetAt - DateTime.UtcNow + RateLimitResetMargin;
if (delay < RateLimitResetMargin)
delay = RateLimitResetMargin;
@@ -217,7 +213,6 @@ public async Task> GetCachedPackagesAsync()
return await _unitOfWork.ToListAsync(_unitOfWork.CachedPackages.Query()
.Where(p => p.LatestVersion != "")
.OrderByDescending(p => p.CachedAt)
- // Anonymous and served whole: bounded, newest first
.Take(MaxListedPackages)
.Select(p => new PackageMetadata
{
@@ -252,7 +247,6 @@ public async Task> SearchSuggestionsAsync(string registr
var registryId = await _cacheService.ResolveRegistryIdAsync(registry);
var term = query.Trim().ToLowerInvariant();
- // Shortest names first: the closest match to what was typed
return await _unitOfWork.ToListAsync(_unitOfWork.CachedPackages.Query()
.Where(p => p.RegistryId == registryId && p.PackageId.ToLower().Contains(term))
.OrderBy(p => p.PackageId.Length)
@@ -268,8 +262,6 @@ public async Task> SearchSuggestionsAsync(string registr
}));
}
- // ==================== PRIVATE HELPER METHODS ====================
-
private async Task SendProgress(string? connectionId, string message)
{
await _progressReporter.SendProgressAsync(connectionId, message);
@@ -320,7 +312,6 @@ private async Task> ProcessVersionsAsync(
var versionsToProcess = versionsList.Take(maxInitialVersions).ToList();
- // The cached notes of the whole range in one query, instead of one query per version
var versionStrings = versionsToProcess.Select(v => v.ToString()).ToList();
var cachedNotes = (await _unitOfWork.CachedReleaseNotes
.FindAsync(rn => rn.CachedPackageId == cachedPackage.Id && versionStrings.Contains(rn.Version)))
@@ -376,7 +367,6 @@ private async Task> ProcessVersionsAsync(
var versionInfo = CreateVersionInfo(version, releaseNotes, releaseNotesSource, publishedDate, cachedNote, isFromCache);
versionInfoList.Add(versionInfo);
- // Early stopping logic
if (versionInfo.IsEmpty)
{
consecutiveEmpty++;
@@ -423,8 +413,7 @@ private async Task ScrapeVersionReleaseNotesAsync(
{
var cachedNote = await _cacheService.GetCachedReleaseNoteAsync(cachedPackage.Id, versionString);
- // Skip if already has actual release notes and not expired, or was looked up in vain only recently.
- // A row seeded with the version list has no source yet: it was never looked up, however new it is.
+ // A row seeded from the version list has no source yet: it was never looked up
var now = DateTime.UtcNow;
if (cachedNote != null && cachedNote.ExpiresAt > now
&& (!cachedNote.IsEmpty
@@ -436,7 +425,6 @@ private async Task ScrapeVersionReleaseNotesAsync(
var releaseNotes = StoredMarkdown.Normalize(result.ReleaseNotes);
PkgDiffTelemetry.RecordScrape(activity, registry.Id, result.Source, !string.IsNullOrWhiteSpace(releaseNotes));
- // Update githubRepoUrl if newly discovered
if (result.DiscoveredGitHubUrl != null)
{
cachedPackage.GitHubRepositoryUrl = result.DiscoveredGitHubUrl;
@@ -478,7 +466,6 @@ private async Task ScrapeVersionReleaseNotesAsync(
await _cacheService.SaveReleaseNoteAsync(cachedNote);
- // Extract GitHub @mentions and track contributors
if (result.Source == "GitHub" && !string.IsNullOrWhiteSpace(releaseNotes))
{
try
@@ -543,8 +530,6 @@ private async Task SaveSearchHistoryAsync(
DateTime startTime,
string ipAddress)
{
- // Do not log internal background jobs (e.g. Hangfire scheduled tasks) into search history
- // to prevent inflating metrics and obscuring real user data.
if (ipAddress == "System")
{
return;
@@ -581,7 +566,7 @@ private async Task SaveFailedScrapePlaceholderAsync(Domain.Entities.CachedPackag
PublishedDate = null,
IsEmpty = true,
CachedAt = DateTime.UtcNow,
- ExpiresAt = DateTime.UtcNow.AddDays(7) // Cache failure for 7 days
+ ExpiresAt = DateTime.UtcNow.AddDays(7)
};
await _cacheService.SaveReleaseNoteAsync(cachedNote);
}
diff --git a/src/backend/PkgDiff.Application/Services/PackageCacheService.cs b/src/backend/PkgDiff.Application/Services/PackageCacheService.cs
index a605a55..dd3a98e 100644
--- a/src/backend/PkgDiff.Application/Services/PackageCacheService.cs
+++ b/src/backend/PkgDiff.Application/Services/PackageCacheService.cs
@@ -88,7 +88,7 @@ public async Task GetOrCreateCachedPackageAsync(
GitHubRepositoryUrl = pkgMeta?.GitHubRepositoryUrl,
AllVersionsJson = JsonSerializer.Serialize(versionStrings),
CachedAt = DateTime.UtcNow,
- ExpiresAt = DateTime.UtcNow.AddDays(14) // Extended from 7 to 14 days
+ ExpiresAt = DateTime.UtcNow.AddDays(14)
};
await _unitOfWork.CachedPackages.AddAsync(cachedPackage);
@@ -266,19 +266,17 @@ private async Task SaveFreshMetadataToCache(
cached.GitHubRepositoryUrl = metadata.GitHubRepositoryUrl;
cached.AllVersionsJson = JsonSerializer.Serialize(metadata.Versions.Select(v => v.Version));
cached.CachedAt = DateTime.UtcNow;
- cached.ExpiresAt = DateTime.UtcNow.AddDays(14); // Extended from 7 to 14 days
+ cached.ExpiresAt = DateTime.UtcNow.AddDays(14);
await _unitOfWork.CachedPackages.UpdateAsync(cached);
}
await _unitOfWork.SaveChangesAsync();
- // What the registry itself calls latest: stored with its publish date, and pushed to connected visitors when it is new
var latestVersion = metadata.LatestVersion ?? string.Empty;
var latestPublished = metadata.Versions.FirstOrDefault(v => v.Version == latestVersion)?.PublishedDate;
await _liveUpdates.RecordLatestVersionAsync(cached, registry, latestVersion, latestPublished, authoritative: true);
- // Seed CachedReleaseNotes with version metadata. A concurrent request may seed some of the same
- // versions first; then the rows that are still missing are added in a second pass.
+ // A concurrent request may seed some versions first; the missing rows are added in a second pass
for (var attempt = 0; ; attempt++)
{
var existingNotes = (await _unitOfWork.CachedReleaseNotes
@@ -299,7 +297,7 @@ await _unitOfWork.CachedReleaseNotes.AddAsync(new CachedReleaseNote
PublishedDate = v.PublishedDate,
IsEmpty = true,
CachedAt = DateTime.UtcNow,
- ExpiresAt = DateTime.UtcNow.AddDays(90) // Extended from 30 to 90 days - release notes rarely change
+ ExpiresAt = DateTime.UtcNow.AddDays(90)
});
}
diff --git a/src/backend/PkgDiff.Application/Services/PackageTrustService.cs b/src/backend/PkgDiff.Application/Services/PackageTrustService.cs
index 48d17b1..f873c2d 100644
--- a/src/backend/PkgDiff.Application/Services/PackageTrustService.cs
+++ b/src/backend/PkgDiff.Application/Services/PackageTrustService.cs
@@ -9,21 +9,17 @@
namespace PkgDiff.Application.Services;
-// Security data is never fetched while a visitor waits: a request reads what is stored and, when
-// that is missing or old, queues a background refresh. The refresh asks the providers, stores the
-// result and tells the open pages over SignalR.
+// Security data is never fetched inline: requests read stored data and queue a background refresh
public class PackageTrustService : IPackageTrustService
{
private const int DefaultCacheHours = 24;
private const int MinCacheHours = 1;
private const int MaxCacheHours = 168;
- // OSV answers up to 1000 queries per batch; a package with more versions is checked for its newest 1000
+ // OSV takes at most 1000 queries per batch; larger packages are checked for the newest 1000
private const int MaxVersionsChecked = 1000;
private const int ExpiredPerRun = 100;
private const int MaxErrorLength = 1000;
- // One refresh per package is queued at most this often, however many visitors open it
private static readonly TimeSpan RequeueAfter = TimeSpan.FromMinutes(10);
- // A failed refresh is tried again after this long instead of on the next visit
private static readonly TimeSpan RetryAfterFailure = TimeSpan.FromHours(1);
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
diff --git a/src/backend/PkgDiff.Application/Services/RoleAssignmentRules.cs b/src/backend/PkgDiff.Application/Services/RoleAssignmentRules.cs
index a6c9156..e361e82 100644
--- a/src/backend/PkgDiff.Application/Services/RoleAssignmentRules.cs
+++ b/src/backend/PkgDiff.Application/Services/RoleAssignmentRules.cs
@@ -31,8 +31,7 @@ public static (bool Allowed, int StatusCode, string? Error) Evaluate(
return (true, 200, null);
}
- // Permission policies pass on the claim alone, so a non-Admin must never obtain (through a role they assign,
- // or an account they take over) a permission they do not already hold
+ // Policies check the claim alone, so a non-Admin must never obtain a permission they lack
public static (bool Allowed, int StatusCode, string? Error) EvaluatePermissionSubset(
IEnumerable permissions,
bool callerIsAdmin,
diff --git a/src/backend/PkgDiff.Application/Services/VersionFilterService.cs b/src/backend/PkgDiff.Application/Services/VersionFilterService.cs
index c6af243..b3810d4 100644
--- a/src/backend/PkgDiff.Application/Services/VersionFilterService.cs
+++ b/src/backend/PkgDiff.Application/Services/VersionFilterService.cs
@@ -21,14 +21,12 @@ public VersionFilterService(IUnitOfWork unitOfWork, IMemoryCache cache, ILogger<
public async Task> GetActiveExcludePatternsAsync()
{
- // Try to get from cache first
if (_cache.TryGetValue(CacheKey, out IEnumerable? cachedPatterns) && cachedPatterns != null)
{
_logger.LogDebug("[VersionFilter] Using cached patterns: {Patterns}", string.Join(", ", cachedPatterns));
return cachedPatterns;
}
- // Fetch from database
var patterns = await _unitOfWork.VersionExcludePatterns.GetAllAsync();
var activePatterns = patterns
.Where(p => p.IsActive)
@@ -38,7 +36,6 @@ public async Task> GetActiveExcludePatternsAsync()
_logger.LogDebug("[VersionFilter] Loaded {Count} active patterns from DB: {Patterns}",
activePatterns.Count, string.Join(", ", activePatterns));
- // Cache the result
_cache.Set(CacheKey, activePatterns, CacheExpiration);
return activePatterns;
@@ -49,7 +46,6 @@ public bool ShouldExcludeVersion(string version, IEnumerable patterns)
if (string.IsNullOrWhiteSpace(version))
return false;
- // Check if version contains any of the exclude patterns (case-insensitive)
var shouldExclude = patterns.Any(pattern =>
version.Contains(pattern, StringComparison.OrdinalIgnoreCase));
@@ -63,10 +59,8 @@ public bool ShouldExcludeVersion(string version, IEnumerable patterns)
public async Task RefreshPatternsAsync()
{
- // Remove from cache to force refresh on next request
_cache.Remove(CacheKey);
- // Pre-load the cache
await GetActiveExcludePatternsAsync();
}
}
diff --git a/src/backend/PkgDiff.Application/Services/WorkCancellation.cs b/src/backend/PkgDiff.Application/Services/WorkCancellation.cs
index ddad4b7..628c67d 100644
--- a/src/backend/PkgDiff.Application/Services/WorkCancellation.cs
+++ b/src/backend/PkgDiff.Application/Services/WorkCancellation.cs
@@ -1,9 +1,7 @@
namespace PkgDiff.Application.Services;
-// The cancellation token of the work that is running on this async flow. An endpoint that starts
-// long work sets it from the request; the outbound HTTP handler, the repository reads and the rate
-// limiter honour it, so the work stops without every method in between taking a token parameter.
-// Outside such a scope (background jobs, short requests) the token is None.
+// Ambient cancellation token of the long work a request started; None outside such a scope.
+// Outbound HTTP, repository reads and the rate limiter honour it without a token parameter.
public static class WorkCancellation
{
private static readonly AsyncLocal Current = new();
diff --git a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
index 21f93ca..01c1ebd 100644
--- a/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
+++ b/src/backend/PkgDiff.Application/Versioning/PackageVersion.cs
@@ -3,7 +3,6 @@
namespace PkgDiff.Application.Versioning;
-// A version of one package, parsed and ordered by the scheme of its registry
public sealed class PackageVersion : IComparable, IEquatable
{
// Either a Pep440Version or a NuGetVersion; versions of one package always share the kind
diff --git a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
index e0ef4f8..5c85ec2 100644
--- a/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
+++ b/src/backend/PkgDiff.Application/Versioning/VersionScheme.cs
@@ -1,6 +1,5 @@
namespace PkgDiff.Application.Versioning;
-// The rules a registry's versions are parsed and ordered by
public enum VersionScheme
{
// NuGet's own rules; a version is spelled the way NuGet normalizes it
diff --git a/src/backend/PkgDiff.Domain/Common/DuplicateKeyException.cs b/src/backend/PkgDiff.Domain/Common/DuplicateKeyException.cs
index 49b3f58..4bfe94b 100644
--- a/src/backend/PkgDiff.Domain/Common/DuplicateKeyException.cs
+++ b/src/backend/PkgDiff.Domain/Common/DuplicateKeyException.cs
@@ -1,6 +1,5 @@
namespace PkgDiff.Domain.Common;
-// A save was refused because a row with the same unique key already exists
public sealed class DuplicateKeyException : Exception
{
public DuplicateKeyException(Exception? innerException = null)
diff --git a/src/backend/PkgDiff.Domain/Entities/BlogPost.cs b/src/backend/PkgDiff.Domain/Entities/BlogPost.cs
index 36d2538..76c7090 100644
--- a/src/backend/PkgDiff.Domain/Entities/BlogPost.cs
+++ b/src/backend/PkgDiff.Domain/Entities/BlogPost.cs
@@ -15,7 +15,6 @@ public class BlogPost : BaseEntity
public DateTime? PublishedAt { get; set; }
public string LlmModelUsed { get; set; } = string.Empty;
- // Optional relation back to the release note this blog was generated from
public int? CachedReleaseNoteId { get; set; }
public CachedReleaseNote? CachedReleaseNote { get; set; }
}
diff --git a/src/backend/PkgDiff.Domain/Entities/CachedPackage.cs b/src/backend/PkgDiff.Domain/Entities/CachedPackage.cs
index a654511..ed17468 100644
--- a/src/backend/PkgDiff.Domain/Entities/CachedPackage.cs
+++ b/src/backend/PkgDiff.Domain/Entities/CachedPackage.cs
@@ -8,22 +8,20 @@ public class CachedPackage : BaseEntity
public int RegistryId { get; set; }
public string LatestVersion { get; set; } = string.Empty;
public string? PreviousLatestVersion { get; set; }
- public DateTime? LatestVersionDate { get; set; } // Publish date of LatestVersion, or when it was first seen if the registry gave none
+ public DateTime? LatestVersionDate { get; set; } // Falls back to first-seen time when the registry gives none
public string? Description { get; set; }
public string? Authors { get; set; }
public string? IconUrl { get; set; }
public string? ProjectUrl { get; set; }
- public string AllVersionsJson { get; set; } = "[]"; // JSON serialized list
+ public string AllVersionsJson { get; set; } = "[]";
public string? GitHubRepositoryUrl { get; set; }
- public string? ReleaseNotesSource { get; set; } // "NuGet" or "GitHub" or "PubDev"
+ public string? ReleaseNotesSource { get; set; }
public DateTime CachedAt { get; set; }
public DateTime ExpiresAt { get; set; }
- // When a visitor last opened or compared the package (at most hourly); null until then. Only packages
- // looked at recently are refreshed in the background.
+ // Updated at most hourly; only recently viewed packages are refreshed in the background
public DateTime? LastViewedAt { get; set; }
- // Navigation properties
public Registry Registry { get; set; } = null!;
public ICollection ReleaseNotes { get; set; } = new List();
}
diff --git a/src/backend/PkgDiff.Domain/Entities/CachedReleaseNote.cs b/src/backend/PkgDiff.Domain/Entities/CachedReleaseNote.cs
index 881f2e8..c2f5fbb 100644
--- a/src/backend/PkgDiff.Domain/Entities/CachedReleaseNote.cs
+++ b/src/backend/PkgDiff.Domain/Entities/CachedReleaseNote.cs
@@ -7,13 +7,12 @@ public class CachedReleaseNote : BaseEntity
public int CachedPackageId { get; set; }
public string Version { get; set; } = string.Empty;
public string ReleaseNotes { get; set; } = string.Empty;
- public string? ParsedNotesJson { get; set; } // JSON array of {category, text}
- public string? ReleaseNotesSource { get; set; } // "NuGet" or "GitHub"
+ public string? ParsedNotesJson { get; set; }
+ public string? ReleaseNotesSource { get; set; }
public DateTime? PublishedDate { get; set; }
public bool IsEmpty { get; set; }
public DateTime CachedAt { get; set; }
public DateTime ExpiresAt { get; set; }
- // Navigation property
public CachedPackage CachedPackage { get; set; } = null!;
}
diff --git a/src/backend/PkgDiff.Domain/Entities/DependencyAudit.cs b/src/backend/PkgDiff.Domain/Entities/DependencyAudit.cs
index d8e44bd..17b2635 100644
--- a/src/backend/PkgDiff.Domain/Entities/DependencyAudit.cs
+++ b/src/backend/PkgDiff.Domain/Entities/DependencyAudit.cs
@@ -2,9 +2,7 @@
namespace PkgDiff.Domain.Entities;
-// One dependency audit: the packages a visitor pasted and, once the background job is done, the
-// report. Found by PublicId (a random GUID) so a report link cannot be guessed. The pasted file
-// itself is never stored, only the package names and versions taken from it.
+// Found by random PublicId so report links cannot be guessed; the pasted file is never stored
public class DependencyAudit : BaseEntity
{
public Guid PublicId { get; set; }
diff --git a/src/backend/PkgDiff.Domain/Entities/PackageSecurityInfo.cs b/src/backend/PkgDiff.Domain/Entities/PackageSecurityInfo.cs
index 9444a5a..7382d20 100644
--- a/src/backend/PkgDiff.Domain/Entities/PackageSecurityInfo.cs
+++ b/src/backend/PkgDiff.Domain/Entities/PackageSecurityInfo.cs
@@ -2,12 +2,11 @@
namespace PkgDiff.Domain.Entities;
-// Security data of one package as the background job last collected it. The data itself is one
-// JSON document (the PackageTrust DTO): it is always read and written whole.
+// The PackageTrust DTO as one JSON document, always read and written whole
public class PackageSecurityInfo : BaseEntity
{
public string Registry { get; set; } = string.Empty;
- // Lower-case, so "Newtonsoft.Json" and "newtonsoft.json" share one row
+ // Lower-cased so ids differing only in case share one row
public string PackageKey { get; set; } = string.Empty;
// The spelling the package was first asked for with, which is how its cache row is found
public string PackageId { get; set; } = string.Empty;
diff --git a/src/backend/PkgDiff.Domain/Entities/SocialMediaPost.cs b/src/backend/PkgDiff.Domain/Entities/SocialMediaPost.cs
index 5dd1b2d..ff8cc54 100644
--- a/src/backend/PkgDiff.Domain/Entities/SocialMediaPost.cs
+++ b/src/backend/PkgDiff.Domain/Entities/SocialMediaPost.cs
@@ -24,7 +24,6 @@ public class SocialMediaPost : BaseEntity
public string? ErrorMessage { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
- // Optional reference to a blog post if it was auto-generated
public int? BlogPostId { get; set; }
public BlogPost? BlogPost { get; set; }
}
diff --git a/src/backend/PkgDiff.Domain/Entities/User.cs b/src/backend/PkgDiff.Domain/Entities/User.cs
index 0a6d540..0e7adad 100644
--- a/src/backend/PkgDiff.Domain/Entities/User.cs
+++ b/src/backend/PkgDiff.Domain/Entities/User.cs
@@ -4,7 +4,6 @@ namespace PkgDiff.Domain.Entities;
public class ApplicationUser : IdentityUser
{
- // Add custom properties if needed
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
}
diff --git a/src/backend/PkgDiff.Domain/Interfaces/IUnitOfWork.cs b/src/backend/PkgDiff.Domain/Interfaces/IUnitOfWork.cs
index c7caee3..475b591 100644
--- a/src/backend/PkgDiff.Domain/Interfaces/IUnitOfWork.cs
+++ b/src/backend/PkgDiff.Domain/Interfaces/IUnitOfWork.cs
@@ -21,7 +21,6 @@ public interface IUnitOfWork : IDisposable
Task SaveChangesAsync();
- // Run a read-only query composed on IRepository.Query()
Task> ToListAsync(IQueryable query);
Task CountAsync(IQueryable query);
Task BeginTransactionAsync();
diff --git a/src/backend/PkgDiff.Domain/Interfaces/IVersionFilterService.cs b/src/backend/PkgDiff.Domain/Interfaces/IVersionFilterService.cs
index eac1893..7460ce6 100644
--- a/src/backend/PkgDiff.Domain/Interfaces/IVersionFilterService.cs
+++ b/src/backend/PkgDiff.Domain/Interfaces/IVersionFilterService.cs
@@ -2,18 +2,9 @@ namespace PkgDiff.Domain.Interfaces;
public interface IVersionFilterService
{
- ///
- /// Gets all active exclude patterns from database (cached).
- ///
Task> GetActiveExcludePatternsAsync();
- ///
- /// Checks if a version should be excluded based on patterns.
- ///
bool ShouldExcludeVersion(string version, IEnumerable patterns);
- ///
- /// Refreshes the cached patterns from database.
- ///
Task RefreshPatternsAsync();
}
diff --git a/src/backend/PkgDiff.Infrastructure/Data/ApplicationDbContext.cs b/src/backend/PkgDiff.Infrastructure/Data/ApplicationDbContext.cs
index 942b19b..f46fcab 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/ApplicationDbContext.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/ApplicationDbContext.cs
@@ -35,9 +35,8 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
- base.OnModelCreating(modelBuilder); // CRITICAL: Call base for Identity tables
+ base.OnModelCreating(modelBuilder);
- // Apply all configurations from assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}
}
diff --git a/src/backend/PkgDiff.Infrastructure/Data/Configurations/CachedPackageConfiguration.cs b/src/backend/PkgDiff.Infrastructure/Data/Configurations/CachedPackageConfiguration.cs
index 7a235be..8d2c48f 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/Configurations/CachedPackageConfiguration.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/Configurations/CachedPackageConfiguration.cs
@@ -44,7 +44,6 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired()
.HasColumnType("text");
- // Unique index on PackageId and RegistryId
builder.HasIndex(x => new { x.PackageId, x.RegistryId })
.IsUnique();
@@ -54,7 +53,6 @@ public void Configure(EntityTypeBuilder builder)
// Index for the Live Updates feed order
builder.HasIndex(x => x.LatestVersionDate);
- // Navigation
builder.HasMany(x => x.ReleaseNotes)
.WithOne(x => x.CachedPackage)
.HasForeignKey(x => x.CachedPackageId)
diff --git a/src/backend/PkgDiff.Infrastructure/Data/Configurations/SearchHistoryConfiguration.cs b/src/backend/PkgDiff.Infrastructure/Data/Configurations/SearchHistoryConfiguration.cs
index baf2ddf..5659206 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/Configurations/SearchHistoryConfiguration.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/Configurations/SearchHistoryConfiguration.cs
@@ -25,7 +25,6 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(x => x.IpAddress)
.HasMaxLength(45); // IPv6 max length
- // Indexes for common queries
builder.HasIndex(x => x.PackageId);
builder.HasIndex(x => x.SearchedAt);
}
diff --git a/src/backend/PkgDiff.Infrastructure/Data/Configurations/SystemSettingsConfiguration.cs b/src/backend/PkgDiff.Infrastructure/Data/Configurations/SystemSettingsConfiguration.cs
index cdd7083..6335315 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/Configurations/SystemSettingsConfiguration.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/Configurations/SystemSettingsConfiguration.cs
@@ -24,7 +24,6 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(x => x.DataType)
.HasMaxLength(50);
- // Unique index on Key
builder.HasIndex(x => x.Key).IsUnique();
}
}
diff --git a/src/backend/PkgDiff.Infrastructure/Data/Configurations/VersionExcludePatternConfiguration.cs b/src/backend/PkgDiff.Infrastructure/Data/Configurations/VersionExcludePatternConfiguration.cs
index dd53465..13ac08e 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/Configurations/VersionExcludePatternConfiguration.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/Configurations/VersionExcludePatternConfiguration.cs
@@ -21,7 +21,6 @@ public void Configure(EntityTypeBuilder builder)
.IsRequired()
.HasDefaultValue(true);
- // Seed data with common exclude patterns
builder.HasData(
new VersionExcludePattern { Id = 1, Pattern = "dev", Description = "Development builds (e.g., 1.0.0-dev-123)", IsActive = true },
new VersionExcludePattern { Id = 2, Pattern = "ci", Description = "Continuous Integration builds (e.g., 1.0.0-ci-456)", IsActive = true },
diff --git a/src/backend/PkgDiff.Infrastructure/Data/SystemSettingsMaintenance.cs b/src/backend/PkgDiff.Infrastructure/Data/SystemSettingsMaintenance.cs
index f2f1063..e97ce88 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/SystemSettingsMaintenance.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/SystemSettingsMaintenance.cs
@@ -48,8 +48,7 @@ public static int Apply(IEnumerable rows, ISecretProtector prote
}
}
- // An already-prefixed value is never touched, whichever key produced it: a value that
- // cannot be decrypted (rotated or lost key) must survive until an admin re-enters it.
+ // Prefixed values are never touched: an undecryptable one must survive until an admin re-enters it
if (row.IsSecret && !string.IsNullOrEmpty(row.Value) && !protector.IsProtected(row.Value))
{
row.Value = protector.Protect(row.Value);
diff --git a/src/backend/PkgDiff.Infrastructure/Data/UtcDateTimeConverter.cs b/src/backend/PkgDiff.Infrastructure/Data/UtcDateTimeConverter.cs
index 0171ba8..9b0bf8c 100644
--- a/src/backend/PkgDiff.Infrastructure/Data/UtcDateTimeConverter.cs
+++ b/src/backend/PkgDiff.Infrastructure/Data/UtcDateTimeConverter.cs
@@ -2,8 +2,7 @@
namespace PkgDiff.Infrastructure.Data;
-// Every DateTime column holds UTC. A value without a kind is taken as UTC, which is what the columns
-// have always held; a local one is converted. What is read comes back as UTC.
+// Columns hold UTC: a value without a kind is taken as UTC, a local one converted.
public class UtcDateTimeConverter : ValueConverter
{
public UtcDateTimeConverter()
diff --git a/src/backend/PkgDiff.Infrastructure/Repositories/UnitOfWork.cs b/src/backend/PkgDiff.Infrastructure/Repositories/UnitOfWork.cs
index b892ae3..da51eac 100644
--- a/src/backend/PkgDiff.Infrastructure/Repositories/UnitOfWork.cs
+++ b/src/backend/PkgDiff.Infrastructure/Repositories/UnitOfWork.cs
@@ -62,8 +62,7 @@ public async Task SaveChangesAsync()
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
- // The whole batch was rolled back. The rows it tried to insert are forgotten, or the next
- // save of this context would send them again and fail the same way.
+ // The batch was rolled back: forget its inserts, or the next save would resend them and fail
foreach (var entry in _context.ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToList())
entry.State = EntityState.Detached;
diff --git a/src/backend/PkgDiff.Infrastructure/Security/HtmlNestingGuard.cs b/src/backend/PkgDiff.Infrastructure/Security/HtmlNestingGuard.cs
index 3a503f5..4dab9cd 100644
--- a/src/backend/PkgDiff.Infrastructure/Security/HtmlNestingGuard.cs
+++ b/src/backend/PkgDiff.Infrastructure/Security/HtmlNestingGuard.cs
@@ -1,10 +1,7 @@
namespace PkgDiff.Infrastructure.Security;
-// AngleSharp builds and walks the DOM recursively: a page nested some thousands of elements deep
-// overflows the stack while it is parsed or its text is read, and a stack overflow ends the whole
-// process. Pages come from addresses package authors choose, so their depth is measured first, in
-// one pass and without recursion. Elements whose end tag may be left out (p, li, td...) and void
-// elements do not count, so ordinary sloppy HTML stays well below the limit.
+// AngleSharp recurses over the DOM, so a deeply nested page overflows the stack and kills the process.
+// Depth is measured first without recursion; optional-end-tag and void elements do not count.
public static class HtmlNestingGuard
{
public const int MaxDepth = 512;
diff --git a/src/backend/PkgDiff.Infrastructure/Security/SsrfGuard.cs b/src/backend/PkgDiff.Infrastructure/Security/SsrfGuard.cs
index 7014ea0..edd9d03 100644
--- a/src/backend/PkgDiff.Infrastructure/Security/SsrfGuard.cs
+++ b/src/backend/PkgDiff.Infrastructure/Security/SsrfGuard.cs
@@ -31,8 +31,7 @@ public static SocketsHttpHandler CreateHandler(
MaxAutomaticRedirections = MaxRedirects,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
AutomaticDecompression = DecompressionMethods.All,
- // Every new host (including redirect targets) re-enters this callback, and the socket
- // connects only to the addresses validated here, so DNS rebinding cannot swap the target.
+ // Every host, redirects included, re-enters here and connects only to validated addresses
ConnectCallback = async (context, cancellationToken) =>
{
var endPoint = context.DnsEndPoint;
diff --git a/src/backend/PkgDiff.Infrastructure/Services/AnalyzerService.cs b/src/backend/PkgDiff.Infrastructure/Services/AnalyzerService.cs
index 530099e..4792958 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/AnalyzerService.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/AnalyzerService.cs
@@ -6,7 +6,7 @@ namespace PkgDiff.Infrastructure.Services;
public class AnalyzerService : IAnalyzerService
{
- // Line-start prefix patterns → category mapping (most specific first)
+ // Ordered most specific first
private static readonly (string[] Prefixes, ChangeCategory Category)[] LinePrefixRules =
[
(["breaking change", "breaking"], ChangeCategory.BreakingChange),
@@ -18,7 +18,6 @@ private static readonly (string[] Prefixes, ChangeCategory Category)[] LinePrefi
(["change", "changed", "update", "updated", "improvement", "enhanced", "enhancement"], ChangeCategory.Other),
];
- // Fallback keyword matching (for release notes without structured tags)
private static readonly (string[] Keywords, ChangeCategory Category)[] KeywordRules =
[
(["breaking", "incompatible"], ChangeCategory.BreakingChange),
@@ -27,11 +26,6 @@ private static readonly (string[] Keywords, ChangeCategory Category)[] KeywordRu
(["performance", "optimize", "faster"], ChangeCategory.Performance),
];
- ///
- /// Parses release notes into per-line entries with category detection.
- /// Detects tags at line start (e.g., "Fix - ...", "New feature - ...") and strips them from text.
- /// Tags mid-sentence are NOT removed.
- ///
public List ParseReleaseNotes(string releaseNotes)
{
if (string.IsNullOrWhiteSpace(releaseNotes))
@@ -46,7 +40,6 @@ public List ParseReleaseNotes(string releaseNotes)
if (string.IsNullOrWhiteSpace(line))
continue;
- // Strip markdown list markers (-, *, •)
var cleaned = StripListMarker(line);
var (category, text) = DetectAndStripPrefix(cleaned);
@@ -64,10 +57,6 @@ public List ParseReleaseNotes(string releaseNotes)
return entries;
}
- ///
- /// Returns distinct categories found in release notes.
- /// Uses line-start tag detection first, falls back to keyword matching.
- ///
public List CategorizeReleaseNotes(string releaseNotes)
{
if (string.IsNullOrWhiteSpace(releaseNotes))
@@ -79,7 +68,6 @@ public List CategorizeReleaseNotes(string releaseNotes)
.Where(c => c != ChangeCategory.Other)
.ToHashSet();
- // If no specific categories found from line prefixes, try keyword fallback
if (categories.Count == 0)
{
var lower = releaseNotes.ToLowerInvariant();
@@ -133,10 +121,6 @@ public Summary GenerateSummary(List versions)
return summary;
}
- ///
- /// Detects a category tag at the start of a line and strips it.
- /// Returns (category, cleanedText). If no tag found, returns (Other, originalText).
- ///
private static (ChangeCategory Category, string Text) DetectAndStripPrefix(string line)
{
var lower = line.ToLowerInvariant();
@@ -150,7 +134,6 @@ private static (ChangeCategory Category, string Text) DetectAndStripPrefix(strin
var rest = line[prefix.Length..].TrimStart();
- // Must be followed by a separator (-, :, –, —) or be the entire line
if (rest.Length == 0)
return (category, string.Empty);
@@ -160,14 +143,11 @@ private static (ChangeCategory Category, string Text) DetectAndStripPrefix(strin
return (category, text);
}
- // Also handle "Fix #123" or "Fixed something" patterns
- // (tag directly followed by description without separator)
if (rest[0] is ' ' or '#')
return (category, rest.TrimStart());
}
}
- // Check for markdown heading patterns: ### Bug Fixes, ## New Features
if (lower.StartsWith('#'))
{
var heading = line.TrimStart('#').Trim();
@@ -176,25 +156,20 @@ private static (ChangeCategory Category, string Text) DetectAndStripPrefix(strin
foreach (var (prefixes, category) in LinePrefixRules)
{
if (prefixes.Any(p => headingLower.StartsWith(p)))
- return (category, string.Empty); // Headings are section titles, no text to keep
+ return (category, string.Empty);
}
}
return (ChangeCategory.Other, line);
}
- ///
- /// Strips markdown list markers (-, *, •, numbered) from the start of a line.
- ///
private static string StripListMarker(string line)
{
var trimmed = line.TrimStart();
- // "- text", "* text", "• text"
if (trimmed.Length > 1 && trimmed[0] is '-' or '*' or '•' && trimmed[1] == ' ')
return trimmed[2..].TrimStart();
- // "1. text", "2) text"
var i = 0;
while (i < trimmed.Length && char.IsDigit(trimmed[i])) i++;
if (i > 0 && i < trimmed.Length && trimmed[i] is '.' or ')')
@@ -207,10 +182,8 @@ private static string StripListMarker(string line)
return trimmed;
}
- // Common annotations, framework keywords, and bot names that are NOT GitHub usernames
private static readonly HashSet _mentionBlocklist = new(StringComparer.OrdinalIgnoreCase)
{
- // JSDoc / JavaDoc annotations
"param", "returns", "return", "deprecated", "since", "see", "throws", "throw",
"type", "typedef", "property", "prop", "readonly", "example", "override",
"ignore", "event", "callback", "fires", "listens", "constructor", "extends",
@@ -223,37 +196,26 @@ private static string StripListMarker(string line)
"private", "protected", "public", "requires", "static", "summary",
"this", "todo", "tutorial", "variation", "version", "yields", "yield",
- // CSS / SCSS / PostCSS directives
"media", "import", "keyframes", "include", "mixin", "extend", "use",
"forward", "each", "for", "while", "if", "else", "function", "return",
"charset", "namespace", "supports", "page", "font-face", "viewport",
"layer", "apply", "tailwind", "screen", "responsive",
- // Common GitHub bots / CI accounts (add more as needed)
"dependabot", "renovate", "renovate-bot", "github-actions", "actions-user",
"snyk-bot", "greenkeeper", "semantic-release-bot", "allcontributors",
"renovateapp", "imgbot", "deepsource-autofix",
- // npm scoped packages (e.g. @types/node, @babel/core) — org names not people
"types", "babel", "angular", "storybook", "nestjs", "vitest",
- // Email domains commonly appearing in @user@domain.com patterns
"gmail", "outlook", "hotmail", "yahoo", "icloud",
- // JavaScript / Node.js built-in symbols, methods, and reserved words
"match", "iterator", "dispose", "species", "latest", "next", "current",
"deprecated", "experimental", "stable", "beta", "alpha", "rc",
- // Misc words often used in changelog prose
"here", "everyone", "all", "team", "me", "you", "us", "channel",
"mention", "thanks", "thank",
};
- ///
- /// Extracts GitHub @username mentions from release note text.
- /// Valid GitHub usernames: alphanumeric and hyphens, 1-39 chars.
- /// Filters out JSDoc/CSS annotations, known bots, and email-like patterns.
- ///
private const int MaxMentionsPerNote = 20;
public IEnumerable ExtractMentions(string text)
@@ -264,13 +226,13 @@ public IEnumerable ExtractMentions(string text)
var matches = Regex.Matches(text, @"@([a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,37}[a-zA-Z0-9])?)", RegexOptions.Compiled);
return matches
.Select(m => m.Groups[1].Value)
- .Where(u => u.Length >= 3) // GH minimum meaningful length (filters '0','1','qq' etc)
- .Where(u => !System.Text.RegularExpressions.Regex.IsMatch(u, @"^\d+$")) // skip pure numbers (e.g. version refs like @16, @18)
- .Where(u => !u.Contains('.')) // skip email-like patterns
- .Where(u => !_mentionBlocklist.Contains(u)) // skip annotations & bots
- .Where(u => !u.StartsWith('-') && !u.EndsWith('-')) // invalid username chars
+ .Where(u => u.Length >= 3)
+ .Where(u => !System.Text.RegularExpressions.Regex.IsMatch(u, @"^\d+$"))
+ .Where(u => !u.Contains('.'))
+ .Where(u => !_mentionBlocklist.Contains(u))
+ .Where(u => !u.StartsWith('-') && !u.EndsWith('-'))
.Distinct(StringComparer.OrdinalIgnoreCase)
- // Each mention costs a GitHub lookup and the text is written by the package author
+ // Each mention costs a GitHub lookup and the text is untrusted
.Take(MaxMentionsPerNote);
}
}
diff --git a/src/backend/PkgDiff.Infrastructure/Services/ContributorService.cs b/src/backend/PkgDiff.Infrastructure/Services/ContributorService.cs
index 6765744..fb1c3b4 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/ContributorService.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/ContributorService.cs
@@ -45,7 +45,6 @@ public async Task> GetLeaderboardAsync(int limit = 100)
.Take(pageSize)
.ToListAsync();
- // Global rank = position in full ordered set
var offset = (page - 1) * pageSize;
var items = contributors
.Select((c, i) => new ContributorDto(
@@ -65,7 +64,6 @@ public async Task UpsertContributorMentionsAsync(IEnumerable usernames)
foreach (var username in usernames)
{
- // Validate against GitHub API - skip if not a real user
if (!await _validator.IsValidGitHubUserAsync(username))
continue;
@@ -109,7 +107,6 @@ public async Task BackfillFromExistingReleaseNotesAsync()
var mentions = _analyzerService.ExtractMentions(note.ReleaseNotes);
foreach (var username in mentions)
{
- // Validate against GitHub API during backfill too
if (!await _validator.IsValidGitHubUserAsync(username))
continue;
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubChangelogReader.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubChangelogReader.cs
index 3dd207c..76ff4f4 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubChangelogReader.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubChangelogReader.cs
@@ -7,8 +7,7 @@
namespace PkgDiff.Infrastructure.Services;
-// Finds the notes of a version in the changelog file of a repository, for releases whose own notes
-// are empty or only point to the changelog
+// Reads a version's notes from the repo changelog when its release notes are empty or a link
public partial class GitHubChangelogReader
{
private static readonly string[] CandidateFiles =
@@ -56,15 +55,12 @@ public static bool IsRedirection(string notes)
{
if (string.IsNullOrWhiteSpace(notes)) return false;
- // If notes are very long, they probably contain actual notes alongside a link
if (notes.Length > 250) return false;
var lower = notes.ToLowerInvariant();
- // Check if it contains changelog/history/changes reference
if (lower.Contains("changelog") || lower.Contains("history") || lower.Contains("changes"))
{
- // Check for redirection indicators
if (lower.Contains("see") ||
lower.Contains("refer") ||
lower.Contains("view") ||
@@ -79,8 +75,7 @@ public static bool IsRedirection(string notes)
return false;
}
- // Where a changelog can be: the folder of the package in a monorepo (from the repository URL,
- // e.g. .../tree/main/packages/core), the repository root, and the repository wiki
+ // Looks in the package's monorepo folder, then the repository root, then the wiki
public async Task FetchAsync(string githubRepoUrl, string version, string? packageId = null)
{
var (owner, repo) = GitHubUrls.Parse(githubRepoUrl, _logger);
@@ -104,25 +99,21 @@ public static bool IsRedirection(string notes)
var knowledge = _cache.Changelog(repoKey);
IReadOnlyList changelogFiles = CandidateFiles;
- // Try API first to get exact list of existing changelog files
await ResolveExistingFilesViaApiAsync(owner, repo, folder, repoKey, knowledge);
var existingFiles = knowledge.ExistingFiles();
if (knowledge.IsFullyResolved && existingFiles != null)
{
- // If the cache says there are no changelog files, we can skip immediately!
if (existingFiles.Count == 0)
{
_logger.LogDebug("[GitHubScraper] Skipping changelog search for {RepoKey} because cache indicates no changelog files exist.", repoKey);
return null;
}
- // Otherwise, only try files that are known to exist
changelogFiles = existingFiles;
}
else if (existingFiles is { Count: > 0 })
{
- // Prioritize already discovered existing files even if repository is not fully resolved
var prioritizedList = new List(existingFiles);
foreach (var file in CandidateFiles)
{
@@ -135,7 +126,7 @@ public static bool IsRedirection(string notes)
}
var cleanVersion = version.TrimStart('v', 'V');
- // The version tag, then the default branch: HEAD is whichever branch the repository uses (master, main, develop)
+ // HEAD resolves to the repository's default branch, whatever it is named
var refNames = new[] { $"v{cleanVersion}", cleanVersion, "HEAD" };
var attemptedFiles = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -143,7 +134,6 @@ public static bool IsRedirection(string notes)
foreach (var fileName in changelogFiles)
{
- // Skip if we already know this file does not exist in this repo
if (knowledge.IsMissing(fileName))
{
continue;
@@ -164,7 +154,6 @@ public static bool IsRedirection(string notes)
{
succeededFiles.Add(fileName);
- // Since it succeeded, we know this file exists
knowledge.AddExisting(fileName);
var content = await response.Content.ReadAsStringAsync();
@@ -180,7 +169,7 @@ public static bool IsRedirection(string notes)
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
- // Missing on the default branch: the file is not in the repository
+ // Only a miss on the default branch means the file is not in the repository
if (refName == "HEAD")
{
knowledge.MarkMissing(fileName);
@@ -191,7 +180,6 @@ public static bool IsRedirection(string notes)
}
catch (OperationCanceledException) when (WorkCancellation.Token.IsCancellationRequested)
{
- // The work this lookup belongs to was cancelled: no point in trying the other files
throw;
}
catch (Exception ex)
@@ -205,7 +193,7 @@ public static bool IsRedirection(string notes)
return null;
}
- // GitHub wiki pages are plain Markdown files of a separate repository (lodash keeps its changelog there)
+ // Wiki pages are plain Markdown files served from a separate repository
private static readonly string[] WikiPages = { "Changelog", "CHANGELOG", "Change-Log", "Release-Notes", "Release-notes", "Changes", "History" };
private async Task FetchFromWikiAsync(string owner, string repo, string version, string? packageId)
@@ -304,7 +292,7 @@ private async Task ResolveExistingFilesViaApiAsync(string owner, string repo, st
}
catch (GitHubRateLimitedException)
{
- // The candidate files are tried directly instead; raw file downloads do not count against the API limit
+ // Raw downloads do not count against the API limit, so candidates are tried directly
_logger.LogDebug("[GitHubScraper] Rate limited, not listing repo contents of {RepoKey}", repoKey);
}
catch (Exception ex)
@@ -340,14 +328,10 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
var lines = changelogText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var cleanVer = version.TrimStart('v', 'V');
- // A heading names the version, optionally after a word ("Version", "Release", the package name),
- // and ends the version with a space, a bracket, a link or the end of the line:
- // ## v2.34.1 ## [2.34.1] - 2026-05-13 ## [10.0.0](https://...compare/v9.0.0...v10.0.0) (2024-06-07)
- // ## Version 1.1.4 ## socket.io@4.8.3 ## react-dom 18.3.1 1.1.4 (2021-05-13) + underline
+ // Version heading, optionally after a word or package name, ending at a space, bracket or EOL
var title = $@"(?:(?:version|release|v)\s*|[\w@/.-]+(?:\s+v?|@v?))?\[?[vV]?{Regex.Escape(cleanVer)}\]?(?=$|[\s(\[\]:,;])(?:.*)$";
var atxRegex = new Regex($@"^(?#+)\s+{title}", RegexOptions.IgnoreCase);
- // A line that could be a Setext heading, e.g. "2.34.1 (2026-05-13)" or "Version 1.1.4"
var setextTitleRegex = new Regex("^" + title, RegexOptions.IgnoreCase);
var setextUnderlineRegex = new Regex(@"^[-=]{3,}$");
@@ -359,7 +343,6 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
{
var line = HtmlTag().Replace(lines[i], string.Empty).Trim();
- // Check ATX first
var match = atxRegex.Match(line);
if (match.Success)
{
@@ -369,7 +352,6 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
break;
}
- // Check Setext
if (setextTitleRegex.IsMatch(line) && i + 1 < lines.Length && setextUnderlineRegex.IsMatch(lines[i + 1].Trim()))
{
startIndex = i;
@@ -382,8 +364,6 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
var sectionLines = new List();
- // If ATX heading of level N: terminate on any heading of level <= N (e.g. # or ##)
- // If Setext heading: terminate on any ATX heading of level 1 or 2, or another Setext heading
Regex terminatorRegex;
if (isAtx)
{
@@ -391,18 +371,16 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
}
else
{
- // Setext heading is equivalent to h1 or h2. Terminate on any h1 or h2 (e.g. # or ##)
terminatorRegex = new Regex(@"^#{1,2}\s+\S+");
}
- int startReadingFrom = isAtx ? startIndex + 1 : startIndex + 2; // Skip the underline for Setext
+ int startReadingFrom = isAtx ? startIndex + 1 : startIndex + 2;
for (int i = startReadingFrom; i < lines.Length; i++)
{
var rawLine = lines[i];
var trimmedLine = rawLine.Trim();
- // Check if we hit another header
if (isAtx)
{
if (terminatorRegex.IsMatch(trimmedLine))
@@ -410,13 +388,11 @@ private string ExtractVersionFromChangelog(string changelogText, string version,
}
else
{
- // For Setext, we also terminate if we find another Setext header
if (terminatorRegex.IsMatch(trimmedLine))
break;
if (i + 1 < lines.Length && setextUnderlineRegex.IsMatch(lines[i + 1].Trim()))
{
- // This line and next line form a Setext header, which terminates our section
break;
}
}
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubFirstScrapingStrategy.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubFirstScrapingStrategy.cs
index 91fb04a..a34fc76 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubFirstScrapingStrategy.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubFirstScrapingStrategy.cs
@@ -5,7 +5,6 @@
namespace PkgDiff.Infrastructure.Services;
// GitHub releases first, then the notes the registry itself publishes, when it publishes any.
-// Used for registries whose notes only come from a repository and for NuGet packages with a known repository.
public class GitHubFirstScrapingStrategy : IReleaseNotesScrapingStrategy
{
private readonly IGitHubScraperService _githubScraperService;
@@ -36,7 +35,6 @@ public async Task GetReleaseNotesAsync(
string? discoveredUrl = null;
- // If no GitHub URL yet, try to discover from registry
if (string.IsNullOrWhiteSpace(githubRepoUrl))
{
_logger.LogDebug("[GitHubFirstStrategy] No GitHub URL, trying registry lookup for {PackageId} (Registry: {Registry})", packageId, registry.Id);
@@ -56,12 +54,11 @@ public async Task GetReleaseNotesAsync(
}
}
- // Try GitHub first
if (!string.IsNullOrWhiteSpace(githubRepoUrl))
{
_logger.LogDebug("[GitHubFirstStrategy] Trying GitHub releases for {PackageId} v{Version} from {GitHubUrl}",
packageId, version, githubRepoUrl);
- // A monorepo package keeps its changelog in its own folder; the URL tells the scraper where
+ // A monorepo package keeps its changelog in its own folder, which the URL names
var lookupUrl = githubRepoUrl;
if (GitHubUrls.SubPath(githubRepoUrl) is null)
{
@@ -81,7 +78,6 @@ public async Task GetReleaseNotesAsync(
packageId, version, registry.PublishesReleaseNotes);
}
- // Fall back to the registry's own notes, when it publishes any
if (registry.PublishesReleaseNotes)
{
_logger.LogDebug("[GitHubFirstStrategy] Trying {Registry} notes for {PackageId} v{Version}", registry.Id, packageId, version);
@@ -91,7 +87,6 @@ public async Task GetReleaseNotesAsync(
_logger.LogDebug("[GitHubFirstStrategy] {Registry} returned notes for {PackageId} v{Version} (length: {Length}): {Preview}...",
registry.Id, packageId, version, ownNotes.Length, ownNotes[..Math.Min(150, ownNotes.Length)]);
- // Check if the registry's notes are URL-dominated and try to resolve actual content
var resolved = await _urlResolver.ResolveAsync(ownNotes, packageId, version, githubRepoUrl);
if (resolved.WasResolved)
{
@@ -109,7 +104,6 @@ public async Task GetReleaseNotesAsync(
_logger.LogWarning("[GitHubFirstStrategy] ALL SOURCES EMPTY for {PackageId} v{Version}", packageId, version);
- // Persist failure to scrape logs for admin review
var allReleases = !string.IsNullOrWhiteSpace(githubRepoUrl)
? await _githubScraperService.GetAllReleasesAsync(githubRepoUrl)
: new Dictionary();
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubMilestoneReader.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubMilestoneReader.cs
index a2e9ee7..52bb9de 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubMilestoneReader.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubMilestoneReader.cs
@@ -8,9 +8,8 @@
namespace PkgDiff.Infrastructure.Services;
-// Release notes from GitHub milestones, for projects that publish neither releases nor a changelog: hapi's
-// packages (joi, hapi, boom...) close one milestone per version, named after it, and its closed issues and
-// pull requests are the changes of that version (https://joi.dev/resources/changelog is built from them)
+// Release notes from closed GitHub milestones named after versions, for projects with neither
+// releases nor a changelog
public class GitHubMilestoneReader
{
private const int PerPage = 100;
@@ -76,7 +75,7 @@ public GitHubMilestoneReader(
return CompiledReleaseNotes.WithNotice(notes, $"{version.TrimStart('v', 'V')} milestone", milestoneUrl);
}
- // Closed milestones by version ("v18.3.0" and "18.3.0" alike); an empty map for a repository without any
+ // Closed milestones by version, with or without a "v"; an empty map for a repository without any
private async Task> MilestonesAsync(string owner, string repo)
{
var repoKey = $"{owner}/{repo}".ToLowerInvariant();
@@ -87,8 +86,7 @@ private async Task> MilestonesAsync(string owner, string
for (var page = 1; ; page++)
{
using var document = await GetJsonAsync($"{RepoApi(owner, repo)}/milestones?state=closed&per_page={PerPage}&page={page}");
- // A failed page leaves the map unknown rather than empty: it is asked again soon, but not
- // once for every version of the package
+ // A failed page leaves the map unknown: retried soon, but not for every version
if (document is null || document.RootElement.ValueKind != JsonValueKind.Array)
{
_cache.SetMilestones(repoKey, milestones, answered: false);
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitHandler.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitHandler.cs
index 7e6b66a..592b1cc 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitHandler.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitHandler.cs
@@ -3,18 +3,15 @@
namespace PkgDiff.Infrastructure.Services;
-///
-/// Singleton service for tracking GitHub API rate limits from response headers
-///
public class GitHubRateLimitHandler : IGitHubRateLimitHandler
{
- private const int SafetyBufferThreshold = 10; // Minimum requests before waiting for reset
- private const int WarningThreshold = 100; // Threshold for logging warnings
+ private const int SafetyBufferThreshold = 10;
+ private const int WarningThreshold = 100;
private readonly ILogger _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
- private int _remaining = 5000; // Default for authenticated
+ private int _remaining = 5000;
private int _limit = 5000;
private DateTime? _resetTime;
@@ -37,8 +34,7 @@ public async Task CanMakeRequestAsync()
_resetTime = null;
}
- // Safety buffer: the caller stops instead of waiting, so background workers are not held
- // for up to an hour and its work can be scheduled for the reset
+ // Stops instead of waiting so background workers are not held until the reset
if (_remaining <= SafetyBufferThreshold && _resetTime.HasValue)
{
_logger.LogDebug(
@@ -50,7 +46,6 @@ public async Task CanMakeRequestAsync()
// Counted before the answer arrives, so requests running in parallel cannot spend the buffer
_remaining--;
- // Log warning when approaching limit
if (_remaining < WarningThreshold && _remaining > SafetyBufferThreshold)
{
_logger.LogWarning(
@@ -71,7 +66,6 @@ public async Task UpdateFromHeadersAsync(HttpResponseMessage response)
await _semaphore.WaitAsync();
try
{
- // Parse x-ratelimit-remaining
if (response.Headers.TryGetValues("x-ratelimit-remaining", out var remainingValues))
{
var remainingStr = remainingValues.FirstOrDefault();
@@ -81,7 +75,6 @@ public async Task UpdateFromHeadersAsync(HttpResponseMessage response)
}
}
- // Parse x-ratelimit-limit
if (response.Headers.TryGetValues("x-ratelimit-limit", out var limitValues))
{
var limitStr = limitValues.FirstOrDefault();
@@ -91,7 +84,6 @@ public async Task UpdateFromHeadersAsync(HttpResponseMessage response)
}
}
- // Parse x-ratelimit-reset (Unix timestamp)
if (response.Headers.TryGetValues("x-ratelimit-reset", out var resetValues))
{
var resetStr = resetValues.FirstOrDefault();
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitResponse.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitResponse.cs
index 739f6e0..d8a85fd 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitResponse.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubRateLimitResponse.cs
@@ -5,8 +5,7 @@ namespace PkgDiff.Infrastructure.Services;
public static class GitHubRateLimitResponse
{
- // A 403 or 429 with no requests left is GitHub refusing because of the limit, not an answer about
- // the repository: the caller must not take it for "no releases" or "no milestones"
+ // A rate-limit refusal says nothing about the repository: not "no releases" or "no milestones"
public static void ThrowIfLimited(HttpResponseMessage response)
{
if (response.StatusCode is not (HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests))
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseFileReader.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseFileReader.cs
index fad54b5..4ed34da 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseFileReader.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseFileReader.cs
@@ -6,9 +6,7 @@
namespace PkgDiff.Infrastructure.Services;
-// Release notes kept as one file per version in a folder of the repository, e.g. Django's
-// docs/releases/5.2.1.txt. The folder listings are read once per repository through the API, so a
-// version costs one raw download, and a repository without such a folder nothing after the first look.
+// Release notes kept as one file per version in a repository folder (listed once per repo)
public class GitHubReleaseFileReader
{
private static readonly string[] Folders =
@@ -80,7 +78,7 @@ public GitHubReleaseFileReader(
}
}
- // "5.2.1" is 5.2.1.txt; "5.2" and "5.2.0" are each other's file too
+ // A trailing ".0" is optional: x.y and x.y.0 name the same file
private static string? FileFor(Dictionary files, string version)
{
if (files.TryGetValue(version, out var path))
@@ -172,8 +170,7 @@ private async Task> ReleaseFilesAsync(string owner, s
}
}
- // The page title of the file repeats the version: an RST title between two rules
- // ("====\nDjango 5.2.1 release notes\n====") or a first Markdown heading
+ // The file's title repeats the version: an RST title between rules or a first Markdown heading
private static string WithoutTitle(string text)
{
var lines = text.Replace("\r\n", "\n").Split('\n').ToList();
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseHtmlScraper.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseHtmlScraper.cs
index 99eab26..39dc72a 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseHtmlScraper.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleaseHtmlScraper.cs
@@ -31,13 +31,11 @@ public async Task> GetAllReleasesAsync(string githubR
_logger.LogDebug("[GitHubScraper] Fetching all releases via HTML: {ReleasesUrl}", baseReleasesUrl);
- // OPTIMIZATION: Add timeout to prevent long waits due to rate limiting
- // If timeout occurs, return partial results (user shouldn't wait too long)
+ // Bounded: on timeout the releases read so far are returned
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_htmlScrapingTimeoutSeconds));
try
{
- // Scrape first page with rate limiting
using var document = await _rateLimiter.ExecuteAsync("github-scraping", () => _htmlDocumentLoader.LoadAsync(baseReleasesUrl));
if (document == null)
{
@@ -47,25 +45,20 @@ public async Task> GetAllReleasesAsync(string githubR
_logger.LogDebug("[GitHubScraper] Page title: {Title}", document.Title);
- // Extract releases from first page
GitHubReleasePage.ExtractReleases(document, releases, _logger);
- // Check for pagination
var totalPages = GitHubReleasePage.GetTotalPages(document);
_logger.LogDebug("[GitHubScraper] Total pages detected: {TotalPages}", totalPages);
- // Scrape all pages (rate limiter will manage speed)
var maxPagesToScrape = totalPages;
for (int page = 2; page <= maxPagesToScrape; page++)
{
- // Check for timeout before each page fetch
if (cts.Token.IsCancellationRequested)
{
_logger.LogWarning("[GitHubScraper] TIMEOUT after {Timeout}s - Returning partial results ({ReleasesCount} releases from {PagesScraped}/{TotalPages} pages)",
_htmlScrapingTimeoutSeconds, releases.Count, page - 1, totalPages);
- // Return partial results to user (don't make them wait)
return releases;
}
@@ -74,7 +67,6 @@ public async Task> GetAllReleasesAsync(string githubR
try
{
- // Use rate limiter - it will automatically wait if needed
using var pageDocument = await _rateLimiter.ExecuteAsync("github-scraping", () => _htmlDocumentLoader.LoadAsync(pageUrl));
if (pageDocument != null)
{
@@ -106,13 +98,11 @@ public async Task> GetAllReleasesAsync(string githubR
_logger.LogDebug("[GitHubScraper] Using LAZY HTML scraping for version {Version}", cleanVersion);
- // Add timeout for lazy scraping too (half of full scraping timeout)
var timeoutSeconds = _htmlScrapingTimeoutSeconds / 2;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
try
{
- // Fetch first page
using var document = await _rateLimiter.ExecuteAsync("github-scraping", () => _htmlDocumentLoader.LoadAsync(baseReleasesUrl));
if (document == null)
{
@@ -120,11 +110,9 @@ public async Task> GetAllReleasesAsync(string githubR
return null;
}
- // Extract releases from first page and check if our version is there
var releases = new Dictionary();
GitHubReleasePage.ExtractReleases(document, releases, _logger);
- // Check if we found it on first page
var result = TryFindVersion(releases, cleanVersion);
if (result != null)
{
@@ -132,15 +120,12 @@ public async Task> GetAllReleasesAsync(string githubR
return result;
}
- // Check pagination
var totalPages = GitHubReleasePage.GetTotalPages(document);
_logger.LogDebug("[GitHubScraper] Version not found on page 1, checking up to {TotalPages} more pages", totalPages - 1);
- // Fetch additional pages until we find the version or timeout
for (int page = 2; page <= totalPages; page++)
{
- // Check for timeout before each page fetch
if (cts.Token.IsCancellationRequested)
{
_logger.LogWarning("[GitHubScraper] TIMEOUT after {Timeout}s while searching for version {Version} - Version not found in first {Pages} pages",
@@ -152,14 +137,13 @@ public async Task> GetAllReleasesAsync(string githubR
try
{
- releases.Clear(); // Clear previous page releases
+ releases.Clear();
using var pageDocument = await _rateLimiter.ExecuteAsync("github-scraping", () => _htmlDocumentLoader.LoadAsync(pageUrl));
if (pageDocument != null)
{
GitHubReleasePage.ExtractReleases(pageDocument, releases, _logger);
- // Check if we found it on this page
result = TryFindVersion(releases, cleanVersion);
if (result != null)
{
@@ -187,19 +171,16 @@ public async Task> GetAllReleasesAsync(string githubR
private static string? TryFindVersion(Dictionary releases, string targetVersion)
{
- // Try exact match
if (releases.TryGetValue(targetVersion, out var notes))
{
return notes;
}
- // Try with 'v' prefix
if (releases.TryGetValue($"v{targetVersion}", out notes))
{
return notes;
}
- // Try fuzzy match
var fuzzyMatch = releases.FirstOrDefault(kvp =>
kvp.Key.Replace(".", "").Replace("-", "").Replace("_", "")
.Equals(targetVersion.Replace(".", "").Replace("-", "").Replace("_", ""),
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleasePage.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleasePage.cs
index abc4b78..9d0b28e 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubReleasePage.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubReleasePage.cs
@@ -7,20 +7,17 @@ namespace PkgDiff.Infrastructure.Services;
// Reads a GitHub releases page: the releases on it, its page count, and the notes as Markdown
public static class GitHubReleasePage
{
- // Regex to extract semantic version from release titles like "Aspire 13.1.1", "v9.5.0 Release", "Package Name 1.2.3-preview.1"
private static readonly Regex SemVerRegex = new(
@"(?\d+\.\d+\.\d+(?:[-\.][a-zA-Z0-9\.]+)?)",
RegexOptions.Compiled);
public static void ExtractReleases(IDocument document, Dictionary releases, ILogger logger)
{
- // Find all release sections
var releaseSections = document.QuerySelectorAll("section[aria-labelledby]");
logger.LogDebug("[GitHubScraper] Found {Count} release sections on current page", releaseSections.Length);
foreach (var section in releaseSections)
{
- // Extract version from the heading
var versionElement = section.QuerySelector(".f1.text-bold a, h2 a");
if (versionElement == null)
{
@@ -30,20 +27,17 @@ public static void ExtractReleases(IDocument document, Dictionary "releases:" + repositoryUrl;
}
-// Which changelog files a repository has, as far as earlier lookups could tell. Shared between
-// requests, so every access is locked.
+// Which changelog files a repository has, as far as lookups could tell; shared, so locked
public sealed class RepoChangelogKnowledge
{
public const int FailuresBeforeMissing = 3;
diff --git a/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs b/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs
index 65af6ab..9028542 100644
--- a/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs
+++ b/src/backend/PkgDiff.Infrastructure/Services/GitHubScraperService.cs
@@ -42,20 +42,17 @@ public GitHubScraperService(
_htmlDocumentLoader = htmlDocumentLoader;
_cache = cache;
- // Load GitHub settings from configuration
var githubSettings = configuration.GetSection("GitHubSettings");
var personalAccessToken = githubSettings.GetValue("PersonalAccessToken");
_enableAuthentication = githubSettings.GetValue("EnableAuthentication", true);
- _useGitHubApi = githubSettings.GetValue