diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f55f22a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,126 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet add *)", + "Bash(echo \"Moq added \\($?\\)\")", + "Bash(echo \"Mvc.Testing added \\($?\\)\")", + "Bash(echo \"EF InMemory added \\($?\\)\")", + "Bash(dotnet build *)", + "Bash(dotnet test *)", + "Bash(dotnet ef *)", + "Bash(sqllocaldb info *)", + "Bash(npm run *)", + "Bash(curl -sk -o /dev/null -w \"backend /api/auth/me → HTTP %{http_code}\\\\n\" https://localhost:7443/api/auth/me)", + "Bash(curl -sk -o /dev/null -w 'GET / → HTTP %{http_code}\\\\n' https://localhost:4200/)", + "Bash(curl -sk -o /dev/null -w 'HTTP %{http_code}\\\\n' https://localhost:4200/api/auth/me)", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/proxy_cookies.txt -X POST https://localhost:4200/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}' -D - -o /dev/null)", + "Bash(echo \"exit=$?\")", + "Bash(MEM=\"C:/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory\"; ls -la \"$MEM\" 2>/dev/null && echo \"--- MEMORY.md ---\" && cat \"$MEM/MEMORY.md\" 2>/dev/null || echo \"no MEMORY.md yet\")", + "Read(//c/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory/**)", + "Bash(echo \"=== remaining in memory dir ===\")", + "Bash(ls -A C:/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory)", + "Bash(echo \"\\(empty\\)\")", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_admin.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}')", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_admin.txt https://localhost:7443/api/admin/courses)", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}')", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt https://localhost:7443/api/viaubc01/probe/notes)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt -o /dev/null -w 'cross-course viaubb01 → %{http_code} \\(expect 403\\)\\\\n' https://localhost:7443/api/viaubb01/probe/notes)", + "PowerShell(sqlcmd -S \"\\(localdb\\)\\\\MSSQLLocalDB\" -d AhkWeb -Q $q -h -1 -W)", + "Bash(dotnet new *)", + "Bash(rm -f Ahk.Web.Services/Class1.cs)", + "Bash(dotnet sln *)", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/statuses)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/grades)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/grades/csv)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubc01/grades)", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}' -o /dev/null)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -o /dev/null -w ' /api/viaubb01/grades -> %{http_code} \\(expect 403\\)\\\\n' https://localhost:7443/api/viaubb01/grades)", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -o /dev/null -w ' /api/viaubc01/statuses -> %{http_code} \\(expect 200\\)\\\\n' https://localhost:7443/api/viaubc01/statuses)", + "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/p.txt -X POST https://localhost:4200/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null -w 'login via proxy -> %{http_code}\\\\n')", + "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/p.txt https://localhost:4200/api/viaubb01/statuses -w '\\\\nHTTP %{http_code}\\\\n')", + "Bash(grep -rl \"MapUniqueJsonKey\" \"C:/Program Files/dotnet/packs/Microsoft.AspNetCore.App.Ref/\" 2>/dev/null | head -3 *)", + "Read(//c/Program Files/dotnet/packs/Microsoft.AspNetCore.App.Ref/**)", + "Bash(DLL=\"C:/Program Files/dotnet/shared/Microsoft.AspNetCore.App/10.0.7/Microsoft.AspNetCore.Authentication.OAuth.dll\" *)", + "Read(//c/Program Files/dotnet/shared/Microsoft.AspNetCore.App/10.0.7/**)", + "Bash(ls ~/.nuget/packages/ 2>/dev/null | grep -i -E \"octokit|github\" ; echo \"---\"; cd ahk-frontend && cat package.json)", + "Read(//c/Users/beny314/.nuget/packages/**)", + "Read(//c/Users/beny314/.nuget/packages/octokit/**)", + "Read(//c/Users/beny314/.nuget/packages/microsoft.extensions.http/**)", + "Read(//c/Program Files/Microsoft SQL Server//**)", + "Bash(cp Ahk.Web.Services/Ahk.Web.Services.csproj /tmp/svc.bak)", + "Bash(ls -la ~/.claude/ 2>/dev/null | head -40; echo \"=== plugins ===\"; ls -la ~/.claude/plugins/ 2>/dev/null | head -40)", + "Read(//c/Users/beny314/.claude/**)", + "Read(//c/Users/beny314/.claude/plugins/**)", + "Bash(cd ~/.claude/plugins && cat known_marketplaces.json && echo \"=== installed ===\" && cat installed_plugins.json && echo \"=== marketplaces/ ===\" && ls marketplaces/ && echo \"=== cache/ ===\" && ls cache/ 2>/dev/null && echo \"=== repos/ ===\" && ls repos/ 2>/dev/null)", + "Bash(python -c ' *)", + "Bash(dotnet run *)", + "Bash(curl -sk https://localhost:7443/swagger/v1/swagger.json -o /dev/null)", + "Bash(curl -sk https://localhost:7443/swagger/v1/swagger.json -o /dev/null -w \"%{http_code}\\\\n\")", + "Bash(rm -f c.txt)", + "Bash(curl -sk -c c.txt -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}')", + "Bash(python -m json.tool)", + "Bash(curl -sk -b c.txt https://localhost:7443/api/admin/health)", + "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; \"freed\")", + "Bash(python -)", + "Bash(curl -sk https://localhost:4200/ -o /dev/null)", + "PowerShell(foreach \\($p in 7443,4200\\) { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } }; \"ports freed\")", + "PowerShell(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue)", + "Bash(sqlcmd -S \"\\(localdb\\)\\\\MSSQLLocalDB\" -d AhkWeb -Q \"SET NOCOUNT ON; SELECT Id, UserName, NormalizedUserName, LockoutEnd, AccessFailedCount, LockoutEnabled, CASE WHEN PasswordHash IS NULL THEN 'NULL' ELSE 'set' END AS Pwd FROM AspNetUsers;\" -W -s\"|\")", + "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null -w \"HTTP %{http_code}\\\\n\")", + "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"nope\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(rm -f v.txt)", + "Bash(curl -sk -c v.txt -X POST https://localhost:4200/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "PowerShell(foreach \\($p in 7443,4200\\) { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } }; Start-Sleep -Milliseconds 500; $left = Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue; if \\($null -eq $left\\) { \"both ports free\" } else { $left | Select-Object LocalPort, OwningProcess })", + "PowerShell(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Milliseconds 500; if \\($null -eq \\(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue\\)\\) { \"both ports free\" })", + "Bash(grep -nE \"^[0-9.]+ [0-9.]+ [0-9.]+ [0-9.]+ \\(k|K\\)$\")", + "Bash(sort -u -t: -k2)", + "Bash(python3 -)", + "Bash(python -c \"import yaml,sys; yaml.safe_load\\(open\\('.github/workflows/ahk-web-deploy.yaml'\\)\\); print\\('YAML OK'\\)\")", + "Bash(cd c:/Source/ahk-github-automation/ahk-backend && dotnet publish Ahk.Web.Server/Ahk.Web.Server.csproj -p:PublishProfile=Mezga -o /tmp/ahk-webconfig-test 2>&1 | tail -3 && echo \"=== generated web.config ===\" && cat /tmp/ahk-webconfig-test/web.config 2>/dev/null || echo \"NO web.config generated\")", + "Read(//tmp/ahk-webconfig-test/**)", + "Bash(rm -rf /tmp/ahk-webconfig-test && cd c:/Source/ahk-github-automation/ahk-backend && dotnet publish Ahk.Web.Server/Ahk.Web.Server.csproj -p:PublishProfile=Mezga -o /tmp/ahk-webconfig-test 2>&1 | tail -2 && echo \"=== published web.config ===\" && cat /tmp/ahk-webconfig-test/web.config)", + "Bash(rm -rf /tmp/ahk-webconfig-test && echo cleaned)", + "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; \"backend stopped\")", + "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; \"checked\")", + "Bash(python -c \"import yaml; d=yaml.safe_load\\(open\\('.github/workflows/ahk-web-deploy.yaml'\\)\\); print\\('YAML OK'\\); print\\('inputs:', d['on']['workflow_dispatch']['inputs']\\)\")", + "Bash(awk '{print length\\($0\\)}')", + "Bash(where.exe rsync *)", + "Bash(wsl.exe rsync *)", + "Bash(wsl.exe -l -v)", + "Bash(wsl.exe bash -c ' *)", + "Bash(npm start *)", + "Bash(node shot.mjs)", + "Read(//c/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/f74052ba-9e93-4760-8fe5-7c53f23ddecb/scratchpad/**)", + "Bash(sed 's#/admin/help/github?org=ahk-viaubc01#/admin/courses/1#; s/help-page.png/course-editor.png/' shot.mjs)", + "Bash(node shot2.mjs)", + "PowerShell(foreach \\($port in 7443,4200\\) { try { Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } catch {} }; Write-Output \"stopped\")", + "Bash(node -e \"const {readFileSync}=require\\('fs'\\); const b=readFileSync\\('help-page.png'\\); console.log\\('png bytes:', b.length\\);\")", + "Bash(node shot3.mjs)", + "PowerShell(try { Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } catch {}; Write-Output \"backend stopped\")", + "Bash(node shot4.mjs)", + "Bash(wsl.exe bash *)", + "Bash(node shot5.mjs)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(curl -sk -o /dev/null -w \"\" https://localhost:7443/swagger/v1/swagger.json)", + "Bash(break)", + "Bash(curl -sk -X POST https://localhost:7443/api/integrations/github -H 'Content-Type: application/json' -d '{}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(curl -sk -X POST https://localhost:7443/api/integrations/evaluation-result -H 'Content-Type: application/json' -d '{}' -w \"\\\\nHTTP %{http_code}\\\\n\")", + "Bash(node -e ' *)", + "Bash(python3 -c ' *)", + "Bash(MSYS_NO_PATHCONV=1 wsl.exe bash __TRACKED_VAR__/harness.sh __TRACKED_VAR__/apply-step.sh)", + "Bash(MSYS_NO_PATHCONV=1 wsl.exe bash __TRACKED_VAR__/harness-edge.sh __TRACKED_VAR__/apply-step.sh)" + ], + "additionalDirectories": [ + "\\tmp", + "C:\\Users\\beny314\\AppData\\Local\\Temp\\claude\\c--Source-ahk-github-automation\\f74052ba-9e93-4760-8fe5-7c53f23ddecb\\scratchpad" + ] + }, + "enabledPlugins": { + "frontend-design@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true, + "security-guidance@claude-plugins-official": true + } +} diff --git a/.github/workflows/ahk-web-deploy.yaml b/.github/workflows/ahk-web-deploy.yaml index 901c1c6..28dfbb9 100644 --- a/.github/workflows/ahk-web-deploy.yaml +++ b/.github/workflows/ahk-web-deploy.yaml @@ -1,7 +1,13 @@ name: AHK Web - Deploy to Mezga # Manual trigger only: this deploys to the on-prem IIS server behind the BME VPN. -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + force_full: + description: "Ignore the stored deployment manifest and copy everything (use after a manual change on the server)" + type: boolean + default: false env: DOTNET_VERSION: "10.0.x" @@ -12,6 +18,10 @@ env: FRONTEND_DIR: ahk-frontend OFFLINE_PAGE: ahk-backend/Ahk.Web.Server/app_offline.htm MOUNT_POINT: /mnt/deploy + # Lives at the CIFS mount's root — a sibling of wwwroot/, the .exe and appsettings.json, never inside + # the ASP.NET Core app's own static-file root, so it is never web-reachable and never touched by the + # targeted copy/delete steps below. + MANIFEST_NAME: .deploy-manifest.sha256 jobs: deploy: @@ -63,9 +73,6 @@ jobs: mkdir -p publish/wwwroot cp -r ${{ env.FRONTEND_DIR }}/dist/ahk-frontend/browser/. publish/wwwroot/ - - name: Stage offline page alongside the publish output - run: cp ${{ env.OFFLINE_PAGE }} publish/app_offline.htm - - name: Generate idempotent migration script (artifact only — applied manually) run: | dotnet tool install --global dotnet-ef @@ -93,6 +100,11 @@ jobs: publish/appsettings.json > publish/appsettings.json.tmp mv publish/appsettings.json.tmp publish/appsettings.json + # Last content-mutating step before the diff: hashing anything after this point (e.g. re-injecting + # config) would make the manifest describe a tree that no longer matches what gets copied. + - name: Compute local deployment manifest + run: (cd publish && find . -type f -printf '%P\0' | sort -z | xargs -0 sha256sum) > new.manifest + # ---- D. VPN (SSTP) up ---- - name: Install SSTP client @@ -105,11 +117,6 @@ jobs: VPN_USER: ${{ vars.VPN_USER }} VPN_PASSWORD: ${{ secrets.VPN_PASSWORD }} run: | - # sstpc is a daemon that never returns. Background it AND redirect its stdout/stderr to a - # logfile: otherwise the backgrounded process keeps this step's output pipe open and the step - # hangs forever despite the '&'. No --cert-warn — the endpoint's cert validates against the - # system CA store. No 'defaultroute' — it is inert here (the runner's eth0 default wins), which - # is why internal traffic was not tunneled; the explicit host route in the next step handles it. sudo sstpc --log-stderr \ --user "$VPN_USER" --password "$VPN_PASSWORD" \ "$VPN_ADDRESS" \ @@ -131,16 +138,13 @@ jobs: env: VPN_ROUTES: ${{ vars.VPN_ROUTES }} run: | - # Split tunnel: only the configured internal hosts (e.g. mezga 152.66.188.8/32) go through - # ppp0. The runner->GitHub control channel and the public ahk.aut.bme.hu warm-up stay on the - # runner's default route. Automates the manual `ip route replace` from the first live run. for cidr in $VPN_ROUTES; do echo "Routing $cidr via ppp0" sudo ip route replace "$cidr" dev ppp0 done ip route show - # ---- E. Mount share, swap site ---- + # ---- E. Mount share, diff against last deploy, swap site ---- - name: Mount deployment share env: @@ -150,36 +154,203 @@ jobs: FILE_PASSWORD: ${{ secrets.FILE_PASSWORD }} run: | sudo mkdir -p "$MOUNT_POINT" - sudo mount -t cifs "$DEPLOYMENT_PATH" "$MOUNT_POINT" \ - -o username="$FILE_USERNAME",domain="$FILE_USERDOMAIN",password="$FILE_PASSWORD",vers=3.0,uid=$(id -u),gid=$(id -g) + # Pass credentials via a file, not inline -o. mount.cifs splits -o on commas, so a comma or + # other special char in the password is parsed as an option separator, silently truncating the + # password and failing auth with mount error(13) Permission denied. The file sidesteps that. + CREDS=$(mktemp) + chmod 600 "$CREDS" + { + echo "username=$FILE_USERNAME" + echo "password=$FILE_PASSWORD" + echo "domain=$FILE_USERDOMAIN" + } > "$CREDS" + if ! sudo mount -t cifs "$DEPLOYMENT_PATH" "$MOUNT_POINT" \ + -o credentials="$CREDS",vers=3.0,sec=ntlmssp,uid=$(id -u),gid=$(id -g); then + echo "::error::CIFS mount failed; recent kernel (dmesg) messages:" + sudo dmesg | tail -30 || true + rm -f "$CREDS" + exit 1 + fi + rm -f "$CREDS" + + # Reads exactly one small file from the share — regardless of link speed this is fast, unlike + # reading file content to compare it (the actual reason the old rsync mirror was slow). + - name: Fetch previous deployment manifest + env: + FORCE_FULL: ${{ inputs.force_full }} + run: | + if [ "$FORCE_FULL" = "true" ]; then + echo "force_full requested — treating the stored manifest as empty (full re-copy)." + : > old.manifest + elif [ -f "$MOUNT_POINT/$MANIFEST_NAME" ]; then + cp "$MOUNT_POINT/$MANIFEST_NAME" old.manifest + else + echo "No manifest found on the share yet — treating this as a first-time full deploy." + : > old.manifest + fi + + # Pure local text processing — no network cost. sha256sum's format is 64 hex chars + two spaces + + # path, so "cut -c 67-" reliably extracts the path column regardless of spaces in a filename. + - name: Diff manifests + id: diff + run: | + sort old.manifest -o old.sorted + sort new.manifest -o new.sorted + + # Changed or added: whole lines (hash+path) present in the new build but not the old one — + # covers both "hash changed at an existing path" and "brand-new path". + comm -13 old.sorted new.sorted | cut -c 67- > changed.list + + # Removed: paths that existed before and are entirely absent from the new build — this is what + # correctly prunes Angular's abandoned content-hashed chunk files, unlike a size comparison. + cut -c 67- old.manifest | sort -u > old.paths + cut -c 67- new.manifest | sort -u > new.paths + comm -23 old.paths new.paths > removed.list + + changed=$(wc -l < changed.list) + removed=$(wc -l < removed.list) + echo "Changed/added: $changed, removed: $removed" + + if [ "$changed" -eq 0 ] && [ "$removed" -eq 0 ]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi - name: Stop site (drop app_offline.htm) + if: steps.diff.outputs.has_changes == 'true' run: | cp "$OFFLINE_PAGE" "$MOUNT_POINT/app_offline.htm" - # Give IIS a moment to notice and release file locks before mirroring. + # Give IIS a moment to notice and release file locks before copying. sleep 5 - - name: Mirror new files (keep app_offline.htm) + # Copies exactly the changed/added files (preserving their relative subdirectories) and deletes + # exactly the removed ones. --ignore-times/--whole-file/--inplace stop rsync from doing any of its + # own (CIFS-costly) comparison or delta-transfer logic — we already know these files need moving. + - name: Apply changed files + if: steps.diff.outputs.has_changes == 'true' run: | - ok=0 - for i in $(seq 1 10); do - if rsync -a --delete --exclude 'app_offline.htm' publish/ "$MOUNT_POINT/"; then - ok=1; break + set -o pipefail + + changed_count=$(wc -l < changed.list) + removed_count=$(wc -l < removed.list) + + # Bytes about to cross the tunnel. A self-contained publish is a handful of very large files + # plus a long tail, so "which files" explains a slow run far better than "how many". + changed_bytes=0 + if [ -s changed.list ]; then + changed_bytes=$(sed 's|^|publish/|' changed.list | tr '\n' '\0' \ + | xargs -0 -r stat -c %s 2>/dev/null | awk '{s+=$1} END {print s+0}') + fi + + echo "Copying $changed_count file(s), $(numfmt --to=iec --suffix=B "$changed_bytes"); deleting $removed_count." + + if [ -s changed.list ]; then + echo "::group::Ten largest files in this transfer" + sed 's|^|publish/|' changed.list | tr '\n' '\0' \ + | xargs -0 -r stat -c '%s %n' 2>/dev/null \ + | sort -rn | head -10 \ + | awk '{ size=$1; $1=""; sub(/^ /,""); printf "%10s %s\n", size, $0 }' \ + | numfmt --to=iec --suffix=B --field=1 || true + echo "::endgroup::" + fi + + # Millisecond resolution: a fast deploy still has to report a throughput figure, otherwise + # there is no baseline to compare a slow one against. + copy_ms=0 + attempts=0 + if [ -s changed.list ]; then + ok=0 + copy_start=$(date +%s%3N) + for i in $(seq 1 10); do + attempts=$i + echo "::group::rsync attempt $i" + attempt_start=$(date +%s%3N) + # --out-format stamps every file with the wall-clock time it landed, which is what turns + # "the deploy was slow" into "it stalled for 90s on this one file". --stats reports the + # effective transfer rate over the tunnel. + if rsync -R --files-from=changed.list --ignore-times --whole-file --inplace \ + --no-perms --no-owner --no-group --no-times \ + --stats --human-readable --out-format='%t %12l %n' \ + publish/ "$MOUNT_POINT/"; then + ok=1 + echo "Attempt $i succeeded in $(( ($(date +%s%3N) - attempt_start) / 1000 ))s" + echo "::endgroup::" + break + fi + echo "::endgroup::" + echo "::warning::rsync attempt $i failed after $(( ($(date +%s%3N) - attempt_start) / 1000 ))s (likely a locked file); retrying..." + sleep 2 + done + copy_ms=$(( $(date +%s%3N) - copy_start )) + + if [ "$ok" -ne 1 ]; then + echo "::error::Copying changed files failed after $attempts attempts and $(( copy_ms / 1000 ))s" + exit 1 fi - echo "rsync attempt $i failed (likely a locked file); retrying..." - sleep 2 - done - if [ "$ok" -ne 1 ]; then - echo "::error::Mirroring the deployment failed after retries" - exit 1 fi + # Deletes are one CIFS round-trip each, so a large prune is latency-bound rather than + # bandwidth-bound and is worth timing separately from the copy. + delete_ms=0 + if [ -s removed.list ]; then + delete_start=$(date +%s%3N) + while IFS= read -r path; do + rm -f "$MOUNT_POINT/$path" + done < removed.list + delete_ms=$(( $(date +%s%3N) - delete_start )) + echo "Deleted $removed_count file(s) in $(( delete_ms / 1000 ))s" + fi + + # Both rates matter, and which one is poor says where the bottleneck is: low MB/s with + # healthy files/s means the link is the limit; low files/s with healthy MB/s means CIFS + # round-trip latency is, and the fix is fewer files rather than smaller ones. + summary=$(awk -v n="$changed_count" -v b="$changed_bytes" -v cms="$copy_ms" \ + -v dn="$removed_count" -v dms="$delete_ms" ' + BEGIN { + mb = b / 1048576 + cs = cms / 1000 + ds = dms / 1000 + printf "| Metric | Value |\n|---|---|\n" + printf "| Files copied | %d |\n", n + printf "| Bytes copied | %.1f MB |\n", mb + printf "| Copy time | %.1fs |\n", cs + if (cs > 0) { + printf "| Throughput | %.2f MB/s |\n", mb / cs + printf "| File rate | %.1f files/s |\n", n / cs + } + printf "| Files deleted | %d |\n", dn + printf "| Delete time | %.1fs |\n", ds + if (ds > 0) printf "| Delete rate | %.1f files/s |\n", dn / ds + printf "| Total | %.1fs |\n", cs + ds + }') + + echo "$summary" + { + echo "### Applying changed files" + echo "" + echo "$summary" + if [ "$attempts" -gt 1 ]; then + echo "" + echo "⚠️ rsync needed **$attempts attempts** — retries re-send every file, so each one costs a full copy." + fi + } >> "$GITHUB_STEP_SUMMARY" + - name: Wake site (remove app_offline.htm) + if: steps.diff.outputs.has_changes == 'true' run: rm -f "$MOUNT_POINT/app_offline.htm" - name: Warm up and verify run: curl --fail --show-error --retry 5 --retry-delay 3 https://ahk.aut.bme.hu/ + # Last content-mutating step, and only reached after the copy/delete above succeeded. A run that + # fails partway leaves the OLD manifest in place, so the next run still sees those files as + # "changed" and retries them — a failed run can only ever under-count what's already been done, + # never understate future work. + - name: Publish new deployment manifest + if: steps.diff.outputs.has_changes == 'true' + run: cp new.manifest "$MOUNT_POINT/$MANIFEST_NAME" + # ---- F. Teardown (always) ---- - name: Unmount share diff --git a/.github/workflows/publish-result-pr-build.yaml b/.github/workflows/publish-result-pr-build.yaml index 3756453..58a5d39 100644 --- a/.github/workflows/publish-result-pr-build.yaml +++ b/.github/workflows/publish-result-pr-build.yaml @@ -5,20 +5,26 @@ on: paths: - "publish-results-pr/**" +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + # setup-go v4 and later cache the module and build caches itself, keyed on the checksum file, which + # is why the separate actions/cache step this workflow used to carry is gone. go.sum is not at the + # repository root in this monorepo, so the path has to be given explicitly. - name: Go setup - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: "1.17" - - uses: actions/cache@v3 + - uses: actions/cache@v2 with: path: | ~/.cache/go-build diff --git a/.github/workflows/publish-result-pr-docker-publish.yaml b/.github/workflows/publish-result-pr-docker-publish.yaml index d531a98..230af3c 100644 --- a/.github/workflows/publish-result-pr-docker-publish.yaml +++ b/.github/workflows/publish-result-pr-docker-publish.yaml @@ -1,19 +1,84 @@ name: Publish Result PR - Docker publish -on: [workflow_dispatch] +on: + workflow_dispatch: + inputs: + tag: + description: "Moving tag to publish. Student repositories resolve this at run time, so publishing it changes every course at once." + type: string + default: "v1" + +env: + # Published under bmeaut, the organization that owns this source. + # + # It previously lived in a personal namespace (ghcr.io/akosdudas/...) belonging to someone who has since + # left the university, so the account — and with it every course's evaluation pipeline — was outside our + # control. Images built here are course-critical; they belong to the organization, not to a person. + # + # ⚠️ Student repositories generated before this move still reference the old image and keep working until + # that account's package disappears. Migrating them means editing the `uses:` line in each repository's + # workflow — the same pass that sets AHK_APPURL. See ahk-backend/docs/ci-callback.md. + IMAGE: ghcr.io/bmeaut/ahk-publish-results-pr jobs: publish: runs-on: ubuntu-latest + # GITHUB_TOKEN is read-only by default in many organizations; without packages:write the push fails + # with a permission error that reads like an authentication problem. + permissions: + contents: read + packages: write + steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + # The moving tag is what every student repository resolves at run time, so overwriting it changes + # behaviour everywhere simultaneously and there is no built-in way back. Park the outgoing image + # under a fixed name first, so a bad publish is one `docker buildx imagetools create` from undone. + - name: Preserve the outgoing image as :${{ inputs.tag }}-previous + run: | + if docker pull "$IMAGE:${{ inputs.tag }}"; then + docker tag "$IMAGE:${{ inputs.tag }}" "$IMAGE:${{ inputs.tag }}-previous" + docker push "$IMAGE:${{ inputs.tag }}-previous" + echo "Previous image preserved as $IMAGE:${{ inputs.tag }}-previous" + else + echo "No existing $IMAGE:${{ inputs.tag }} — nothing to preserve (first publish under this namespace)." + fi - - name: Build and push to GHCR + - name: Build working-directory: publish-results-pr run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin - docker build -t ghcr.io/akosdudas/ahk-publish-results-pr:v1 . - docker push ghcr.io/akosdudas/ahk-publish-results-pr:v1 - docker logout https://ghcr.io + # Also tagged with the commit, which is immutable: it gives every publish a permanent rollback + # point, unlike the moving tag that student repositories follow. + docker build \ + -t "$IMAGE:${{ inputs.tag }}" \ + -t "$IMAGE:sha-${GITHUB_SHA::7}" \ + . + + - name: Push + run: | + docker push "$IMAGE:${{ inputs.tag }}" + docker push "$IMAGE:sha-${GITHUB_SHA::7}" + + - name: Summary + run: | + { + echo "### Published" + echo "" + echo "| Tag | Purpose |" + echo "|---|---|" + echo "| \`$IMAGE:${{ inputs.tag }}\` | Moving tag student repositories follow |" + echo "| \`$IMAGE:sha-${GITHUB_SHA::7}\` | Immutable, this commit |" + echo "| \`$IMAGE:${{ inputs.tag }}-previous\` | The image this run replaced |" + echo "" + echo "Roll back with:" + echo "" + echo '```' + echo "docker buildx imagetools create -t $IMAGE:${{ inputs.tag }} $IMAGE:${{ inputs.tag }}-previous" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 88dbff1..bf90ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .vs/ + +## Local deploy script (scripts/deploy-local.sh) — never commit secrets or build output +deploy.local.json +publish-local/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c15c9bb --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp", + "angular.ng-template" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b956e0d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Backend (ASP.NET Core)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-backend", + "program": "${workspaceFolder}/ahk-backend/Ahk.Web.Server/bin/Debug/net10.0/Ahk.Web.Server.dll", + "args": [], + "cwd": "${workspaceFolder}/ahk-backend/Ahk.Web.Server", + "stopAtEntry": false, + "console": "integratedTerminal", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "https://localhost:7443" + } + }, + { + "name": "Frontend (Angular)", + "type": "node-terminal", + "request": "launch", + "command": "npm start", + "cwd": "${workspaceFolder}/ahk-frontend" + } + ], + "compounds": [ + { + "name": "Full stack (backend + frontend)", + "configurations": ["Backend (ASP.NET Core)", "Frontend (Angular)"], + "stopAll": true + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..88ea0d1 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-backend", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a94a82 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,183 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Working in this repo + +⚠️ **Do not run git write commands.** No `git add`, `git commit`, `git checkout`, `git reset`, `git stash`, `git merge`, `git push` — the maintainer commits by hand, deliberately, and wants to review and stage the working tree themselves. Leave changes uncommitted in the working tree and say what you changed. Read-only inspection (`git status`, `git diff`, `git log`, `git show`) is fine and useful. + +## What this is + +**Ahk** = automated homework evaluation. A toolset that automates homework submission, evaluation, and grading using GitHub, GitHub Classroom, and GitHub Actions. Concept docs: + +The repo is a monorepo of independent applications, each in its own top-level directory with its own solution/module, README, and CI workflows. There is no root-level build — each app builds separately. + +It is mid-transition: the **four original apps** (`github-monitor`, `grade-management`, `review-ui`, `publish-results-pr`) are the production system today, and a **new centralized portal** (`ahk-backend` + `ahk-frontend`) is being built to replace the per-course Azure deployments with a single multi-course site at `ahk.aut.bme.hu`. The portal now has auth (local + BME OIDC), the full course-scoped **data model**, the domain **services**, the read endpoints (`grades`, `statuses`, CSV export), the **site administration surface** (course CRUD, per-course GitHub integration, CI callback tokens, staff, users/roles, health checks) with its Angular console, and a one-time Cosmos→MSSQL importer. The write-side entry points are now ported too — the **GitHub webhook receiver**, **`/ahk ok` chatops** and the **HMAC-verified CI callback** — so the portal is feature-complete and a course can run on it with no Azure Functions. The four apps stay deployed for courses that have not migrated; **a GitHub App has one webhook URL, so per course the switch is a flip, not a parallel run** (cutover checklist in `ahk-backend/docs/github-app.md`). When working in this repo, be clear which of the two systems a task targets. + +## The four applications and how they connect + +``` +Student pushes / opens PR + │ + ▼ +GitHub org (managed by GitHub Classroom) + │ webhooks GitHub Actions runs evaluator container + ▼ │ produces result.txt + images +[github-monitor] (.NET Azure Function) ▼ + - enforces workflow rules on repos [publish-results-pr] (Go container) + - /ahk ok chatops approval/grading - formats results as PR comment + │ Azure Queue Storage messages - POSTs results to grade-management + │ │ HMAC-signed HTTP + ▼ ▼ +[grade-management] (.NET Azure Function) ◄───────┘ + - queue-triggered: grade + status events → CosmosDB + - http: evaluation-result webhook, list-grades, list-statuses + │ HTTP API (Function master key) + ▼ +[review-ui] (Blazor WebAssembly, browser-only) + - teacher dashboard of statuses + grades +``` + +Key integration contracts (keep these in sync when changing either side): +- **github-monitor → grade-management**: Azure Queue Storage. Queue names are hard-coded in the `[QueueTrigger(...)]` attributes in `grade-management/.../Functions/**` (e.g. `ahksetgrade`, `ahkconfirmautograde`, `ahkstatustracking*`) and must match what github-monitor's `Services/GradeStore` and `Services/StatusTrackingStore` write. Both sides share duplicated DTO shapes (`Services/.../Dto/*.cs` vs `Functions/.../Dto/*.cs`). +- **publish-results-pr → grade-management**: HMAC-SHA256 signed HTTP to the `evaluation-result` webhook. The signing scheme (verb\nurl\ndate\npayload, base64 HMAC, `X-Ahk-Token`/`X-Ahk-Sha256`/`Date` headers, 10-min skew) is implemented in Go (`internal/publishtoapi`) and validated in .NET (`grade-management/.../Helpers/HmacSha256Validator.cs`). Changing one requires changing the other. See `grade-management/README.md` for the full spec. +- **github-monitor** authenticates to GitHub as a GitHub App per-installation (`Services/GitHubClientFactory`), using Octokit. + +## The new centralized portal (ahk-backend + ahk-frontend) + +Replaces the per-course Azure Function deployments with one site whose domain data is assigned to **Courses** (each course = what used to be a separate deployment, e.g. `viaubc01`). This is **not** multitenant isolation — it's course-scoped data with membership-based authorization. + +- **ahk-backend** — ASP.NET Core **.NET 10** (`Ahk.Web.slnx`). Layering: `Ahk.Web.Server` (controllers/middleware/auth, thin) → `Ahk.Web.Services` (domain logic) → `Ahk.Web.Data` (EF Core + **MSSQL**, Identity, migrations, seeder, `Normalize`). `Ahk.Web.Import` is a **throwaway** Cosmos-JSON→MSSQL console importer that references Data directly and **bypasses Services** (bulk movement, not domain ops). Tests in `Ahk.Web.Server.Tests` (xUnit + Moq). +- **No repository layer** — `DbContext` is already Unit of Work + repository. The legacy Cosmos repositories exist only because Cosmos has no composable LINQ context; that reason is gone. +- **ahk-frontend** — **Angular 21** SPA (standalone components + signals). API clients/DTOs under `src/app/api/` are **NSwag-generated** from the backend's OpenAPI doc — never hand-edit them. +- **Octokit is the portal's single GitHub API client**, built by `ICourseGitHubClientFactory` (`Ahk.Web.Services/GitHub/`). One exception: `CourseGitHubAppTokenProvider` stays on the named `"github"` `HttpClient` because it is the auth bootstrap (hand-rolled RS256 App JWT → installation token), not an API call, and moving it would change the permissions shape `GitHubAppInstallationHealthCheck` reads. `IGitHubRepositoryService`'s interface and its four record projections are deliberately Octokit-free — that is what lets `AssignmentInviteTests` mock it strictly. + +Design decisions (rationale in the architecture plan and `ahk-backend/README.md`): +- **Auth**: cookie-based ASP.NET Identity (returns 401/403, never redirects) + an **OIDC** external provider (config `Authentication:Oidc:*`; disabled when empty). Deliberately **not** `MapIdentityApi` (no OIDC support, bearer-token focus, fixed shapes). +- **OIDC provider is BME's Shibboleth IdP** (`https://idp.bme.hu`, client `AUTAhkClient`). Non-obvious constraints, all encoded in `OidcOptions` defaults — do not "fix" them back: + - Request **exactly** the registered scopes (`openid email userinfo`). `profile` is **not** registered; asking for it gets the request rejected. + - **PKCE off** — the IdP does not advertise `code_challenge_methods_supported`. We are a confidential client (`client_secret_post`), so PKCE is defence-in-depth only. + - **`ResponseMode = query`** — ASP.NET's `form_post` default makes the callback a cross-site POST, which drops the correlation cookie under `SameSite=Lax` ("Correlation failed"). + - **No `end_session_endpoint`** is advertised, so logout is local-only; `POST /api/auth/logout` returns `{endSessionUrl}` (null today) and the SPA navigates there if set. + - `OpenIdConnectOptions` ships **only `DeleteClaim` actions** — claims arriving from the *userinfo* endpoint (BME sends `email`, `name`, `neptun_code`, `eduperson_scoped_affiliation` there) are silently dropped unless explicitly mapped in `Program.cs`. + - Persisted onto `ApplicationUser`: `NeptunCode` and `Affiliation` (multi-valued, joined with `;`), re-synced on every login by `Auth/ExternalClaimsMapper.cs`. + - **eduID identifies a user by Neptun code, not email/username.** `ApplicationUser.NeptunCode` is filtered-unique (blank stored as null, may repeat; any value unique). First OIDC login matches an existing account by Neptun and links the external login, so an admin-pre-created account is never duplicated (`ExternalAuthController`). Admin create/update enforce the same rule (blank→null, else `Normalize.Neptun`, 400 on clash). eduID **always refreshes email**; the claim sync never writes username. + - The client secret is never in config — `dotnet user-secrets` locally, `Authentication__Oidc__ClientSecret` in production. +- **Dev mock OIDC provider** (`Ahk.Web.Server/MockOidc/`): only the production redirect URI is registered with BME, so localhost cannot use the real IdP. Enabled by `Authentication:Oidc:UseMockProvider` in Development; serves discovery/authorize/token/userinfo/JWKS at `/mock-oidc` and issues **genuinely signed** RS256 id_tokens so the real validation path runs. Switch persona with `GET /mock-oidc/persona?user=instructor|student|noclaims`. +- **Course scoping**: path segment `/api/{course}/...`; `CourseResolutionMiddleware` resolves the `Course` and an `ICurrentCourseProvider` drives an EF Core global query filter over `ICourseScoped` entities; the `CourseMember` authorization policy gates access. Host/admin routes live under `/api/admin/...`. Machine-to-machine endpoints have **no** `{course}` segment and resolve their course from the payload/token instead: `POST /api/integrations/github` (from `repository.full_name`, via `ICourseResolutionService.ResolveByRepositoryAsync`) and `POST /api/integrations/evaluation-result` (from the `X-Ahk-Token` header). Both are `[AllowAnonymous]` — a signature is the authentication, and `Program.cs` deliberately has no `FallbackPolicy` — and both are `[ApiExplorerSettings(IgnoreApi = true)]` so NSwag emits no client for them. +- **Site admins can open any course.** `CourseMembershipAuthorizationHandler` grants `CourseMember` to the `Admin` role, so `GET /api/auth/me` lists *every* course for an admin (flagged `viaSiteAdmin` where there is no membership row). The SPA's switcher and `courseGuard` read that one list and need no admin special case — keep it that way rather than branching on `isAdmin()` in new screens. +- **Admin API credential rule**: stored secrets are never returned (only `has*` flags plus a last-four hint). On update, each credential field means: `null` = leave alone, `""` = clear, anything else = replace. That is what makes saving an untouched form safe. +- **Course health checks** (`Ahk.Web.Services/Health/`): each is an `ICourseHealthCheck` registered in DI and discovered by `CourseHealthService`, so adding one is a class plus a registration line — controller and UI unchanged. Four today: webhook settings (local), GitHub access token (real call to api.github.com, 10s timeout), GitHub App installation, CI callback token. Checks must not throw; return a `Failed` result instead, or one unreachable course takes the whole dashboard down. A course's status is the worst of its checks. +- **Dev**: both run HTTPS. The Angular dev proxy (`proxy.conf.js`) forwards `/api/*` to the backend so calls are **same-origin — no CORS**, and `API_BASE_URL` is provided as `''` so generated clients issue relative requests. Press F5 with the **"Full stack (backend + frontend)"** compound in `.vscode/launch.json` to start both. +- **Domain model** (`Ahk.Web.Data/Entities`): `Course` 1:1 `CourseGitHubConfig` (per-course GitHub App creds + `WorkflowRunThreshold` — was per-deployment `AHK_*` — plus `GitHubAccessToken`, a PAT used for REST calls that need no installation token, today only the health check); `Student`/`Submission` replace the neptun/repo-name strings; `SubmissionEvent` (TPH: Repository/Branch/PullRequest/WorkflowRun) and `GradeRecord`+`GradeExercisePoint` are **append-only** — current state is projected, never updated. `CourseWebhookToken.Token` is globally unique because the CI callback carries no `{course}` segment; its `Secret` is a plaintext column (HMAC needs the raw key) and the admin API **returns it** (list + detail) so the console can re-copy it later — a deliberate exception to the never-return rule, justified because any admin can mint an equivalent token anyway. +- **Assignments** (`Ahk.Web.Data/Entities/Assignment.cs`): student repos are named `{RepoNamePrefix}-{neptun}`, falling back to the template repo's bare name when `RepoNamePrefix` is blank (keeps pre-prefix assignments working) — logic in `AssignmentInviteService.BuildRepositoryName`. The template repo is validated **advisorily** (`AssignmentService.CheckTemplateAsync` → `POST api/{course}/assignments/check-template`): it reports existence + `is_template` as a warning and **never blocks saving** (an assignment may be drafted before its template exists). A bad template otherwise only fails at student-accept time as a 502. +- **Webhook receiver** (`Ahk.Web.Services/GitHubWebhooks/`): `GitHubWebhookDispatcher` selects handlers by `IGitHubWebhookHandler.GitHubEventName`; per-delivery state (course id, delivery id, body, `IGitHubClient`, run threshold) travels on `GitHubWebhookContext`, so handlers are stateless scoped DI registrations. **DI registration order is dispatch order** — that is what replaced github-monitor's explicit config builder, and handlers post comments whose order is visible to students. The `.github/ahk-monitor.yml` `enabled: true` gate and its **12-hour** per-repo-id cache are kept verbatim: adding the file to an already-seen repo takes up to half a day to take effect, and an app restart is the only faster flush. This is the most common cause of "webhook delivers 200 but nothing happens" — read the `WebhookResult` body, it says `no ahk-monitor.yml or disabled`. +- ⚠️ **At most one handler per GitHub event may write a `SubmissionEvent`.** `GitHubDeliveryId` is globally unique (the redelivery guard) but one delivery fans out to several handlers, so a second writer's rows are silently swallowed by the guard, or rejected by the unique index on SQL Server. Handlers that write are marked `IStatusEventWriter` and `WebhookHandlerRegistrationTests` fails the build if two share an event. +- ⚠️ **Course resolution necessarily precedes signature validation** in the webhook receiver: the secret is per course (`CourseGitHubConfig.GitHubWebhookSecret`) and the only thing identifying the course is `repository.full_name` *inside* the body. Everything before the HMAC check is deliberately inert — one property read from a `JsonDocument` that is then disposed, two indexed reads, no writes/GitHub calls/body logging — and every signature failure returns the same 400. Don't add work to that stretch. Benign cases (repo in no course, integration off) answer **202, not 4xx**, because GitHub colours non-2xx red in the delivery log. +- ⚠️ **Both signature comparisons must stay constant-time** (`SignatureComparison.FixedTimeEquals`). The Azure Functions used `string.Equals`, which returns at the first differing character and lets a forged signature be refined a character at a time by timing — do not "restore parity" here. Same reason the CI callback logs only a masked last-four of the caller's token, where grade-management logged it whole. +- ⚠️ **`AHK_APPURL` must byte-match the CI callback's public URL** (`https://ahk.aut.bme.hu/api/integrations/evaluation-result`): the Go client signs the URL, so scheme, host, path and trailing slash all matter (casing does not — both sides lower it). `http://` fails confusingly, via a 307 and then a scheme mismatch. `UseForwardedHeaders` must stay first in the pipeline for `GetDisplayUrl()` to yield the public URL behind IIS. Full contract in `ahk-backend/docs/ci-callback.md`. +- ⚠️ **`ICourseGitHubAppTokenProvider.GetForCourseAsync(Course)` returns null unless the course was loaded with `Include(c => c.GitHubConfig)`** — it reads `course.GitHubConfig?.GitHubAppId`. `ResolveByRepositoryAsync` does *not* include it, so m2m paths must use the `(int courseId, …)` overload or they silently degrade to "course not connected to GitHub". +- ⚠️ **The course query filter matches nothing when no course is resolved.** `ICurrentCourseProvider` is set by whichever entry point resolved the course. Anything without HTTP course context — dev seeder, importer, services — must use `IgnoreQueryFilters()` and filter on `courseId` itself, or it silently reads **zero rows**. Service methods therefore always take an explicit `int courseId` and never read the provider. + +## Build, test, run + +The four original apps target **.NET 6** (Azure Functions v4) except publish-results-pr which is **Go 1.17**. The new portal targets **.NET 10** (ahk-backend) and **Angular 21 / Node** (ahk-frontend). + +### github-monitor / grade-management / review-ui (.NET) +```bash +# from the app directory (github-monitor, grade-management, or review-ui) +dotnet build +dotnet test # run all tests +dotnet test --filter "FullyQualifiedName~HmacSha256ValidatorTest" # single test class +dotnet test --filter "Name=SomeTestMethod" # single test method +``` +- github-monitor and grade-management are Azure Functions — run locally with `func start` (Azure Functions Core Tools) from the function project directory. They need `local.settings.json` (gitignored) supplying the `AHK_*` env vars. +- review-ui: `dotnet run` (or `dotnet watch`) from `review-ui/Ahk.Review.Ui`. It is a standalone WASM app; configure the backend URL in `wwwroot/appsettings.json`. +- Tests use **xUnit + Moq**; handler tests mock the GitHub client, memory cache, and stores (see `Tests/.../Helpers/*MockFactory.cs`). + +### publish-results-pr (Go) +```bash +# from publish-results-pr +go build +go test ./... -test.v +``` +Ships as a container (`Dockerfile`) published to `ghcr.io/bmeaut/ahk-publish-results-pr` (moved out of a former employee's personal namespace; **student repos generated before the move still reference `ghcr.io/akosdudas/...`** and need their `uses:` line edited), invoked as a GitHub Action step. `.devcontainer` is provided for development. + +### ahk-backend (.NET 10) +```bash +# from ahk-backend +dotnet build +dotnet test # xUnit +dotnet run --project Ahk.Web.Server --launch-profile https # https://localhost:7443, Swagger at /swagger + +# EF Core migrations use the design-time factory in Ahk.Web.Data (both --project and --startup-project point there) +dotnet ef database update --project Ahk.Web.Data --startup-project Ahk.Web.Data +dotnet ef migrations add --project Ahk.Web.Data --startup-project Ahk.Web.Data --output-dir Migrations +``` +Needs MSSQL (LocalDB by default; `ConnectionStrings:Default` in `appsettings.Development.json`). The dev seeder (Development env only) creates `admin`/`Admin123!` (site admin), `instructor`/`Instructor123!` (member of `viaubc01` only), courses `viaubc01`/`viaubb01` with GitHub config + CI token, and sample students/submissions/events/grades. The two courses are deliberately configured differently so the admin health dashboard shows a mix of states. + +⚠️ **The seeder is create-if-missing.** Editing seeded values changes nothing on a dev database that already has those rows — delete the course (or the database) to see the new values. It also calls `db.Database.MigrateAsync()` on startup, so running the backend in Development **auto-applies pending migrations** to the dev DB (prod still applies the `migrate.sql` artifact by hand). + +```bash +# one-time Cosmos-export import (throwaway tool; delete once every course is migrated) +dotnet run --project Ahk.Web.Import -- --course --connection "" \ + --grades grades.json --events events.json --tokens tokens.json # --repo-prefix, --force +``` + +### ahk-frontend (Angular 21) +```bash +# from ahk-frontend +npm install +npm start # ng serve --ssl on https://localhost:4200, proxy → https://localhost:7443 +npm run build +npm test # vitest +npm run generate-api # regenerate src/app/api from the backend's OpenAPI (backend must be running) +``` +`nswag.json` pins `"runtime": "Net100"` (the default Net90 binary needs .NET 9, which is not installed here). + +### Portal gotchas +- **A running server locks its binary** — `dotnet build` fails with MSB3027; stop it first. `pkill -f` is unreliable here; free ports with PowerShell `Get-NetTCPConnection -LocalPort 7443 -State Listen | ... Stop-Process -Force`. +- **Never hand-delete a migration file** — the model snapshot stays advanced and the next `migrations add` scaffolds an empty diff. Use `dotnet ef migrations remove`, or delete the whole `Migrations/` dir and regenerate. +- **SQL Server rejects multiple cascade paths**: `Course`→`Student`→`Submission` alongside `Course`→`Submission` forces the `Student` FKs to `DeleteBehavior.NoAction`. Consequence: **deleting a course cannot rely on the database alone.** `CoursesAdminController.Delete` removes grade points, grades, events and submissions explicitly with `ExecuteDeleteAsync` before the course row goes — a new course-scoped entity whose FK is `NoAction` must be added to that list, or the delete fails on a foreign-key violation. +- **`sqlcmd` needs `SET QUOTED_IDENTIFIER ON`** before DML on `AspNetUsers`/`SubmissionEvents` — the filtered unique index on `GitHubDeliveryId` makes the default fail. +- **Auth cookies are named `ahk.auth` / `ahk.auth.external`, not the framework defaults** (`Program.ApplicationCookieName`). Browsers scope cookies by host and **ignore the port**, so on `localhost` every ASP.NET Identity app shares `.AspNetCore.Identity.Application`. A cookie from another project whose user id is a GUID reaches this int-keyed app and throws `"… is not a valid value for Int32"` inside `SecurityStampValidator` — a 500 on *every* request, including login. `OnValidatePrincipal` also wraps the stamp validator so an unreadable cookie signs the caller out instead of throwing. Do not revert either to the defaults. +- **Windows PowerShell 5.1 cannot load .NET 10 assemblies** (`Add-Type` throws); to find a type's namespace, `grep -ao` the DLL instead. +- **To probe a NuGet package's API surface**, read `~/.nuget/packages///lib//*.xml` (`grep -o 'name="M:Type\.[^"]*"'`) — but it is **partial**, many members have no doc entry, so absence there proves nothing; fall back to `grep -a` on the DLL for a member name, or just write the call and let the compiler answer. Octokit 14 facts found this way: `NewRepositoryFromTemplate` has **no** `IncludeAllBranches` (GitHub defaults it false, which is what we want), and `Repository.IsTemplate` / `RepositoryInvitation.Expired` are **non-nullable** `bool`. +- **No Docker in this environment** — dev dependencies are built in-app (e.g. the mock OIDC provider), not containerized. +- **Verify UI changes by screenshotting the running app**, not by trusting the build. Chrome is at `/c/Program Files/Google/Chrome/Application/chrome.exe`. A plain `--headless=new --screenshot` fires before Angular hydrates and yields a near-empty page — add `--virtual-time-budget=4000`. For screens behind login, start Chrome with `--remote-debugging-port=9222` and drive the DevTools protocol from a Node script: Node 24 ships a built-in `WebSocket`, so this needs no npm dependency. Always pass `--ignore-certificate-errors` (self-signed dev cert). When reading a value back from `Runtime.evaluate`, the RemoteObject is doubly nested at `msg.result.result.value` — reading `msg.result.value` silently yields `undefined` (the clicks still fire, so screenshots look fine while probes read blank). +- **Exercise the m2m endpoints by hand** against the running backend — faster than the GitHub UI and it works offline. Sign with `openssl`: ``SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'dev-webhook-secret' -hex | sed 's/.*= //')"``, then `curl -sk -X POST https://localhost:7443/api/integrations/github -H 'X-GitHub-Event: ping' -H "X-Hub-Signature-256: $SIG" -d "$BODY"`. The dev seeder's `viaubc01` is org **`ahk-viaubc01`**, secret **`dev-webhook-secret`**; `viaubb01` deliberately has **no** secret, so it exercises the 500 branch. +- **No image tooling** — no PIL, no ImageMagick. To read a PNG's pixels (sampling a brand colour, checking dimensions) decode it by hand with `zlib` + `struct`. EPS files are text-ish: `%%CMYKCustomColor` in the header carries the print colour spec. +- **Prod hosting is same-origin**: the backend serves the Angular SPA — `UseDefaultFiles`/`UseStaticFiles` + `MapFallbackToFile("index.html")` in `Program.cs`, `ng build` output copied into `wwwroot`. Only dev uses the proxy. +- **`web.config` is checked in with its SDK transform disabled** (`IsTransformWebConfigDisabled=true`): Mezga registers ANCM under the **V1** name `AspNetCoreModule`, so the SDK-generated V2 web.config fails to start the app there. Don't delete the file or re-enable the transform. +- **`SelfContained=true` needs a `RuntimeIdentifier`** — pinned to `win-x64` in `Mezga.pubxml`; the server has no .NET runtime. + +### CI +`.github/workflows/*-build.yaml` build+test each app (path-filtered so only the changed app runs). `*-azure-publish.yaml` / `*-docker-publish.yaml` deploy. Several azure-publish workflows are per-instance (e.g. `VIAUBC01`, `viaubb01`) — deployments are duplicated per course/organization instance. +The portal has its own `ahk-web-deploy.yaml` (manual `workflow_dispatch`): publishes self-contained `win-x64` via the `Mezga.pubxml` profile, tunnels to the on-prem IIS server **Mezga** over **SSTP VPN**, mirrors the build over a CIFS share (`rsync`, `app_offline.htm` bracketing). Migrations are **not** auto-applied — CI emits an idempotent `migrate.sql` artifact applied by hand to a fresh DB. + +## github-monitor architecture (the most complex app) + +- **Entry point**: `GitHubMonitorFunction.cs` — single anonymous HTTP webhook. It validates the `X-Hub-Signature-256` HMAC against `AHK_GitHubWebhookSecret` before doing anything, then hands the raw body to `EventDispatchService`. +- **Dispatch**: `Services/EventDispatch/EventDispatchService` maps a GitHub event name → list of handler types (registered in `Startup.cs` via `EventDispatchConfigBuilder`). Handlers run independently; one throwing is caught and logged, others still run. +- **Handlers**: `EventHandlers/**`. Most extend `RepositoryEventBase`, which: + - deserializes the Octokit payload, + - creates a per-installation `GitHubClient`, + - **short-circuits unless the repo has `.github/ahk-monitor.yml` with `enabled: true`** (cached 12h). New handlers should derive from this base to inherit the enablement gate and the `neptun.txt` / org-membership caching helpers. +- **Two handler families** run side-by-side: rule-enforcement handlers (branch protection, duplicate PR, review→assignee, comment edit/delete, workflow-run limit) and `StatusTracking/**` + `GradeComment/**` handlers that emit events to the queues for grade-management. +- **Store abstraction**: `IGradeStore` / `IStatusTrackingStore` have `*AzureQueue` and `*Noop` implementations. `Startup.cs` wires the Noop variants when `AHK_EventsQueueConnectionString` is absent, so the app runs fully without grade-management. + +## Conventions + +- **Config**: all runtime config comes from `AHK_`-prefixed environment variables (bound via `AddEnvironmentVariables("AHK_")` in github-monitor; direct `AHK_*` names elsewhere). See each app's README for the exact variables. Never commit secrets; `local.settings.json` is gitignored. +- **Enabling a repo for github-monitor**: the repo needs `.github/ahk-monitor.yml` containing `enabled: true` on its default branch, otherwise all its events are ignored. +- **Teacher grading chatops**: `/ahk ok`, `/ahk ok 5`, `/ahk ok 5 3.5 0` in a PR comment approves/merges and records grades (numbers map positionally to exercises). Parsing lives in `Helpers/GradeCommentParser.cs`. +- **Style is enforced at build time**: StyleCop.Analyzers + `EnforceCodeStyleInBuild` + `AnalysisMode=AllEnabledByDefault`, and grade-management/review-ui set `TreatWarningsAsErrors=true`. A large root `.editorconfig` defines the rules — match existing style exactly or the build fails. Match idiom per project (e.g. github-monitor uses explicit namespaces; review-ui uses `ImplicitUsings`/`Nullable` enabled). +- **result.txt evaluation format** (produced by evaluators, parsed by publish-results-pr): lines of `###ahk#taskname#result#comment`, with optional `group@` prefix on taskname for grouped totals. Full spec in `publish-results-pr/README.md`. +- **Portal conventions differ from the original apps**: ahk-backend uses standard ASP.NET config (`appsettings*.json` — `ConnectionStrings:Default`, `Authentication:Oidc:*`), **not** `AHK_` env vars, and uses the default .NET 10 SDK analyzers (no `TreatWarningsAsErrors`), so it is not bound by the root `.editorconfig`'s StyleCop rules. ahk-frontend follows the Angular style (2-space, standalone components + signals). +- **Portal UI**: the design system lives in `ahk-frontend/src/styles.scss` — tokens plus the shared classes (`page`, `card`, `field`, `btn`, `table.data`, `badge`, `notice`, `dot`). Component stylesheets are Angular-scoped, so compose from those classes instead of restyling buttons/tables per screen. Enums cross the wire as **names** (`JsonStringEnumConverter` in `Program.cs`), which is why NSwag emits string-literal unions like `CourseRole = 'Instructor' | 'Admin'`. +- **Portal look is the BME AUT identity**, derived from aut.bme.hu — crimson Georgia headings, Verdana body, parchment (`#dbd9c0`) table headers, the department logo. Type does three jobs: `--font-display` (Georgia) for headings, `--font-ui` (Verdana) for labels/controls, `--font-mono` for machine identifiers (slugs, orgs, repos, Neptun codes, tokens) — do not "unify" them. `--brand` (`#a4001e`, headings + primary actions), `--link` (`#074371` navy, navigation) and `--bad` (`#801b1b`, broken) are **three deliberately different reds**; collapsing them loses meaning. The logo masters and every colour's provenance live in `ahk-frontend/brand/` (BME AUT identity pack + the eduID login logo); the web copies the app loads are in `ahk-frontend/public/`. No square favicon exists yet (the mark is 2.86:1); a follow-up needs a square crop from the EPS. The **login screen leads with eduID** (the federated `/api/auth/external/challenge` flow); local username/password is collapsed behind an "I don't have an eduID account" link. The eduID button follows the [eduID brand](https://eduid.hu/hu/depo/) but renders its own English "Login" rather than the official Hungarian-label PNG; `--eduid` blue is scoped to that button, never in the global palette. +- **Portal code style**: **no top-level statements** (`Program` is an explicit class with `Main`); **`int` keys everywhere**, including Identity (`IdentityUser`); the domain term is **Course**, never "tenant". +- **Frontend API errors**: use `readApiError(err, fallback)` from `ahk-frontend/src/app/core/api-error.ts` instead of parsing a `SwaggerException` inline. It pulls out the API's own `{error}`/`{errors}` message and reports status 0 as "the server is not responding" — without that, a generated-client failure surfaces as a misleading domain error (a stopped backend once looked exactly like "wrong password"). +- **Portal tests**: course-scoping is tested against `ApplicationDbContext` directly with EF InMemory + a mutable `ICurrentCourseProvider` double. `WebApplicationFactory` DbContext swaps must remove **both** `DbContextOptions` and EF 9+'s `IDbContextOptionsConfiguration` descriptors, else two providers register. `Ahk.Web.Services` exposes internals via `InternalsVisibleTo`. Frontend single run: `npx ng test --watch=false`. Controller tests can run over a real `UserManager` on EF InMemory (see `UserNeptunTests`) — but InMemory does **not** enforce filtered unique indexes, so uniqueness is proven via the controller's pre-check, not the DB. +- **Testing the m2m endpoints**: build Octokit models by **deserializing the JSON GitHub would send** — `new SimpleJsonSerializer().Deserialize(json)` — rather than through their long constructors. `WebApplicationFactory` must also remove `ICourseGitHubAppTokenProvider` / `ICourseGitHubClientFactory` (so nothing reaches api.github.com) and `TimeProvider` (swap in `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`). Two porting traps: xUnit `[InlineData]` has **no `params`** — MSTest `[DataRow(…, 1, 2)]` becomes `[InlineData(…, new[] { 1d, 2d })]`; and `HttpClient` parses the `Date` header itself and **refuses to send a malformed one**, so reaching the "not valid RFC1123" branch needs `TryAddWithoutValidation`. +- **Legacy parity is a hard constraint when porting**: grade semantics (append-only, positional `ex0`/`ex1` name carry-forward, per-exercise summing) and the CSV layout are covered by parity tests — changing them changes existing courses' grades. One deliberate deviation: `CsvExporter` sorts columns `Ordinal` rather than culture-sensitively. diff --git a/README.md b/README.md index 3d930f0..dfbb3cd 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,17 @@ Please refer to for the concept and details. -## Applications +## The portal + +**[ahk-backend](./ahk-backend)** + **[ahk-frontend](./ahk-frontend)**: a single ASP.NET Core (.NET 10) site with an Angular front end, at `ahk.aut.bme.hu`, replacing the per-course Azure deployments below with one multi-course installation. It now hosts every entry point the original system had — the GitHub webhook receiver, the `/ahk ok` chatops commands and the HMAC-verified CI callback — as well as taking over from GitHub Classroom for handing out student repositories, so a course can run on it with **no Azure Functions**. + +The four applications below remain deployed for courses that have not migrated yet; courses move one at a time. See [Cutover per course](./ahk-backend/docs/github-app.md#cutover-per-course). + +## Applications (the original system) **[GitHub Monitor](./github-monitor)**: An Azure function written in .NET with an http webhook registered as a GitHub Application that manages the workflow of homework submissions. Performs automatic actions on repositories acting as submissions and monitors proper usage of pull requests. -**[Publish Results to PR](./publish-results-pr)**: A [containerized](https://github.com/users/akosdudas/packages/container/package/ahk-publish-results-pr) Go application that processes the output of evaluator applications and publishes the results into a pull request for the student to see, as well as forwarding it to the _grade management_ application. +**[Publish Results to PR](./publish-results-pr)**: A [containerized](https://github.com/orgs/bmeaut/packages/container/package/ahk-publish-results-pr) Go application that processes the output of evaluator applications and publishes the results into a pull request for the student to see, as well as forwarding it to the _grade management_ application. **[Grade Management](./grade-management)**: An Azure function written in .NET that accepts events from the other applications and stores them in Azure CosmosDB database. Helps teachers by reducing the administration of tracking the status of submissions and exporting final grades. diff --git a/ahk-backend/.gitignore b/ahk-backend/.gitignore new file mode 100644 index 0000000..c2c3706 --- /dev/null +++ b/ahk-backend/.gitignore @@ -0,0 +1,8 @@ +## Build output +bin/ +obj/ + +## Local/dev secrets (never commit) +appsettings.*.local.json +local.settings.json +*.user diff --git a/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj b/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj new file mode 100644 index 0000000..ddb40d7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs b/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs new file mode 100644 index 0000000..068ce30 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs @@ -0,0 +1,232 @@ +using Ahk.Web.Data.Entities; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Data; + +/// +/// EF Core context backing ASP.NET Identity plus the course-scoped domain model. Applies a global query filter +/// on every entity so a request only sees rows for the resolved current course. +/// +/// Note: the filter follows , which is populated by whichever entry point +/// resolved the course (route segment, webhook payload, or CI token). When no course is resolved the filter +/// matches nothing — callers with no course context (e.g. the one-time importer) must set a provider or use +/// IgnoreQueryFilters(). +/// +public class ApplicationDbContext : IdentityDbContext +{ + private readonly ICurrentCourseProvider currentCourse; + + public ApplicationDbContext(DbContextOptions options, ICurrentCourseProvider currentCourse) + : base(options) + { + this.currentCourse = currentCourse; + } + + public DbSet Courses => Set(); + + public DbSet CourseGitHubConfigs => Set(); + + public DbSet CourseMemberships => Set(); + + public DbSet CourseWebhookTokens => Set(); + + public DbSet Students => Set(); + + public DbSet Submissions => Set(); + + public DbSet SubmissionEvents => Set(); + + public DbSet GradeRecords => Set(); + + public DbSet GradeExercisePoints => Set(); + + public DbSet Assignments => Set(); + + public DbSet AssignmentAcceptances => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(e => + { + e.Property(u => u.DisplayName).HasMaxLength(256); + e.Property(u => u.NeptunCode).HasMaxLength(32); + e.Property(u => u.Affiliation).HasMaxLength(256); + e.Property(u => u.GitHubUsername).HasMaxLength(128); + + // Filtered unique index: a Neptun code identifies a person, so no two accounts may share one. + // NULL means "no code" (directory/local accounts may have none) and is allowed many times — + // which is why the admin controllers store null, never "", for a blank code. + e.HasIndex(u => u.NeptunCode).IsUnique().HasFilter("[NeptunCode] IS NOT NULL"); + }); + + builder.Entity(e => + { + e.HasIndex(c => c.Slug).IsUnique(); + e.Property(c => c.Slug).HasMaxLength(64).IsRequired(); + e.Property(c => c.Name).HasMaxLength(256).IsRequired(); + e.Property(c => c.GitHubOrganization).HasMaxLength(256); + e.Property(c => c.RepoNamePrefix).HasMaxLength(256); + + // Machine-to-machine course resolution: organization first, then repo-name prefix. + e.HasIndex(c => c.GitHubOrganization); + e.HasIndex(c => c.RepoNamePrefix); + + e.HasOne(c => c.GitHubConfig) + .WithOne(g => g.Course!) + .HasForeignKey(g => g.CourseId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(e => + { + e.HasIndex(g => g.CourseId).IsUnique(); + e.Property(g => g.GitHubAppId).HasMaxLength(64); + e.Property(g => g.GitHubAccessToken).HasMaxLength(512); + e.Property(g => g.GitHubWebhookSecret).HasMaxLength(512); + }); + + builder.Entity(e => + { + e.HasKey(m => new { m.UserId, m.CourseId }); + e.HasOne(m => m.User).WithMany(u => u.CourseMemberships).HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(m => m.Course).WithMany(c => c.Memberships).HasForeignKey(m => m.CourseId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(e => + { + e.Property(t => t.Token).HasMaxLength(128).IsRequired(); + e.Property(t => t.Secret).HasMaxLength(512).IsRequired(); + e.Property(t => t.Description).HasMaxLength(512); + + // Globally unique: the CI callback carries no {course} segment, so the token resolves the course. + e.HasIndex(t => t.Token).IsUnique(); + e.HasOne(t => t.Course).WithMany().HasForeignKey(t => t.CourseId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(t => t.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => + { + e.Property(s => s.Neptun).HasMaxLength(32).IsRequired(); + e.Property(s => s.GitHubUsername).HasMaxLength(128); + e.Property(s => s.Name).HasMaxLength(256); + + e.HasIndex(s => new { s.CourseId, s.Neptun }).IsUnique(); + e.HasOne(s => s.Course).WithMany().HasForeignKey(s => s.CourseId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(s => s.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => + { + e.Property(s => s.GitHubRepoName).HasMaxLength(400).IsRequired(); + + e.HasIndex(s => new { s.CourseId, s.GitHubRepoName }).IsUnique(); + e.HasIndex(s => new { s.CourseId, s.StudentId }); + + e.HasOne(s => s.Course).WithMany().HasForeignKey(s => s.CourseId).OnDelete(DeleteBehavior.Cascade); + + // NoAction (not SetNull): Course cascades to both Student and Submission, and SQL Server rejects + // the resulting multiple cascade paths. Deleting a course still removes both. + e.HasOne(s => s.Student).WithMany(st => st!.Submissions).HasForeignKey(s => s.StudentId).OnDelete(DeleteBehavior.NoAction); + e.HasQueryFilter(s => s.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => + { + // Table-per-hierarchy, mirroring the original polymorphic event log. + e.HasDiscriminator("EventType") + .HasValue(nameof(RepositoryCreatedEvent)) + .HasValue(nameof(BranchCreatedEvent)) + .HasValue(nameof(PullRequestEvent)) + .HasValue(nameof(WorkflowRunEvent)); + + e.Property(x => x.GitHubDeliveryId).HasMaxLength(128); + + e.HasIndex(x => new { x.CourseId, x.SubmissionId, x.Timestamp }); + + // Filtered unique index: redelivered webhooks must not duplicate events. + e.HasIndex(x => x.GitHubDeliveryId).IsUnique().HasFilter("[GitHubDeliveryId] IS NOT NULL"); + + e.HasOne(x => x.Course).WithMany().HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.NoAction); + e.HasOne(x => x.Submission).WithMany(s => s!.Events).HasForeignKey(x => x.SubmissionId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(x => x.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => e.Property(x => x.Branch).HasMaxLength(400)); + builder.Entity(e => e.Property(x => x.Conclusion).HasMaxLength(64)); + builder.Entity(e => + { + e.Property(x => x.Action).HasMaxLength(64); + e.Property(x => x.HtmlUrl).HasMaxLength(1024); + e.Property(x => x.Neptun).HasMaxLength(32); + e.PrimitiveCollection(x => x.Assignees); // JSON column + }); + + builder.Entity(e => + { + e.Property(g => g.Neptun).HasMaxLength(32).IsRequired(); + e.Property(g => g.PrUrl).HasMaxLength(1024); + e.Property(g => g.Actor).HasMaxLength(256); + e.Property(g => g.Origin).HasMaxLength(1024); + + // "Latest result for this submission/PR" — the GetLastResultOf access path. + e.HasIndex(g => new { g.CourseId, g.SubmissionId, g.PrNumber, g.Date }); + + // Confirmed-grade listing and CSV export. + e.HasIndex(g => new { g.CourseId, g.Confirmed, g.Date }); + + e.HasOne(g => g.Course).WithMany().HasForeignKey(g => g.CourseId).OnDelete(DeleteBehavior.NoAction); + e.HasOne(g => g.Submission).WithMany(s => s!.Grades).HasForeignKey(g => g.SubmissionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(g => g.Student).WithMany().HasForeignKey(g => g.StudentId).OnDelete(DeleteBehavior.NoAction); + e.HasQueryFilter(g => g.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => + { + e.Property(p => p.Name).HasMaxLength(256).IsRequired(); + e.HasOne(p => p.GradeRecord).WithMany(g => g!.Points).HasForeignKey(p => p.GradeRecordId).OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(e => + { + e.Property(a => a.Name).HasMaxLength(256).IsRequired(); + e.Property(a => a.Description).HasMaxLength(1024); + e.Property(a => a.TemplateRepoName).HasMaxLength(400).IsRequired(); + e.Property(a => a.InviteToken).HasMaxLength(128).IsRequired(); + + // Globally unique: the invite link carries the token as its only identifier of the assignment, and + // it is the capability that lets a stranger provision a repository — collisions are not an option. + e.HasIndex(a => a.InviteToken).IsUnique(); + + // The instructor listing reads "this course's open assignments". + e.HasIndex(a => new { a.CourseId, a.ArchivedAt }); + + e.HasOne(a => a.Course).WithMany().HasForeignKey(a => a.CourseId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(a => a.CourseId == this.currentCourse.CurrentCourseId); + }); + + builder.Entity(e => + { + e.Property(a => a.GitHubRepoName).HasMaxLength(400).IsRequired(); + e.Property(a => a.RepoUrl).HasMaxLength(1024).IsRequired(); + e.Property(a => a.GitHubUsername).HasMaxLength(128).IsRequired(); + + // One repository per student per assignment. This index is the concurrency guard: a double click or + // a second tab loses the race here rather than creating a second repository on GitHub. + e.HasIndex(a => new { a.AssignmentId, a.UserId }).IsUnique(); + e.HasIndex(a => new { a.CourseId, a.GitHubRepoName }); + + // The student home page reads every acceptance of one user across all courses. + e.HasIndex(a => a.UserId); + + // NoAction on Course: Course cascades to Assignment which cascades to here, and SQL Server rejects + // the second path. CoursesAdminController.Delete removes these rows explicitly because of it. + e.HasOne(a => a.Course).WithMany().HasForeignKey(a => a.CourseId).OnDelete(DeleteBehavior.NoAction); + e.HasOne(a => a.Assignment).WithMany(x => x!.Acceptances).HasForeignKey(a => a.AssignmentId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(a => a.User).WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(a => a.CourseId == this.currentCourse.CurrentCourseId); + }); + } +} diff --git a/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs b/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..1409c30 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Ahk.Web.Data; + +/// +/// Lets dotnet ef build the context directly from this project (no web host needed). Uses a +/// LocalDB design-time connection; the runtime connection string comes from configuration in the web app. +/// +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=AhkWeb;Trusted_Connection=True;TrustServerCertificate=True") + .Options; + + return new ApplicationDbContext(options, new NullCurrentCourseProvider()); + } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs b/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs new file mode 100644 index 0000000..223f9d3 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Identity; + +namespace Ahk.Web.Data.Entities; + +/// Application-wide (site-level) role, e.g. the super-admin role. Course-level roles live on . +public class ApplicationRole : IdentityRole +{ + public ApplicationRole() + { + } + + public ApplicationRole(string roleName) + : base(roleName) + { + } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs b/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs new file mode 100644 index 0000000..7d2cca9 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Identity; + +namespace Ahk.Web.Data.Entities; + +/// +/// Application user. Extends the ASP.NET Identity user with app-specific profile fields. +/// A user may be a member of many s via . +/// +public class ApplicationUser : IdentityUser +{ + public string? DisplayName { get; set; } + + /// + /// Neptun code — from the IdP's neptun_code claim, or set by an admin when creating the account. + /// The key of the domain model: it links a signed-in user to their rows and is how + /// an eduID login is matched to a pre-provisioned account. Unique when present (filtered unique index); + /// null means "no code" and may repeat. + /// + public string? NeptunCode { get; set; } + + /// + /// The IdP's eduperson_scoped_affiliation claim (e.g. "staff@bme.hu"). Multi-valued at the source; + /// all values are stored joined with ';'. Kept so login can be restricted by affiliation later. + /// + public string? Affiliation { get; set; } + + /// + /// GitHub login, verified against the GitHub API when the user first supplies it. Site-wide rather than + /// per-course: a person has one GitHub account, so once it is known no course asks for it again. + /// Copied onto when an assignment is accepted. + /// + public string? GitHubUsername { get; set; } + + /// GitHub's numeric account id — stable across a rename, which the login is not. + public long? GitHubUserId { get; set; } + + public ICollection CourseMemberships { get; } = new List(); +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs b/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs new file mode 100644 index 0000000..0856a38 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs @@ -0,0 +1,53 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// A piece of homework a course hands out: a template repository plus the invite link students use to get their +/// own copy of it. This is the part of the lifecycle GitHub Classroom used to own. +/// +/// Assignments are deliberately *additive*: a repository does not need one. Submissions created by external +/// tooling (or by Classroom before the migration) keep working, so nothing downstream may assume that a +/// has an assignment behind it. +/// +public class Assignment : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + /// Shown to the student on the accept screen ("Accept the assignment — {Name}"). + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// + /// The template repository students are given a copy of, as full "owner/name", normalized with + /// . It must be marked is_template on GitHub. + /// + public string TemplateRepoName { get; set; } = string.Empty; + + /// + /// Prefix for the student repositories generated from this assignment: each clone is named + /// {RepoNamePrefix}-{neptun}. When blank, the template repository's own name is used instead (the + /// original behaviour), so assignments created before this field keep their naming unchanged. + /// + public string? RepoNamePrefix { get; set; } + + /// + /// Random, unguessable segment of the invite URL (/{course}/invite/{token}). A readable slug would + /// let any signed-in user guess another course's assignment and provision themselves a repository, so the + /// link itself is the capability. Regenerating it invalidates every copy already handed out. + /// + public string InviteToken { get; set; } = string.Empty; + + /// + /// Set when the assignment is archived. Archived assignments drop out of the default listing *and* stop + /// accepting new students; those who already accepted keep their repository link. + /// + public DateTimeOffset? ArchivedAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Acceptances { get; } = new List(); +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs b/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs new file mode 100644 index 0000000..ec24c64 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs @@ -0,0 +1,52 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// One student's acceptance of one : which repository was created for them and when. +/// The unique index on (AssignmentId, UserId) is what makes accepting twice — a double click, a second tab — +/// produce one repository rather than two. +/// +/// The identity link is the , not : the signed-in account is +/// who accepted, and the course-scoped student row is reachable through their Neptun code when grading needs it. +/// No submission link either — one repository can carry many submissions over a semester, and none of them are +/// this record's business. +/// +public class AssignmentAcceptance : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + public int AssignmentId { get; set; } + + public Assignment? Assignment { get; set; } + + /// The account that clicked Accept. + public int UserId { get; set; } + + public ApplicationUser? User { get; set; } + + /// Full "owner/name" of the created repository, normalized with . + public string GitHubRepoName { get; set; } = string.Empty; + + public string RepoUrl { get; set; } = string.Empty; + + /// The GitHub login the repository was shared with, as it stood at accept time. + public string GitHubUsername { get; set; } = string.Empty; + + public DateTimeOffset AcceptedAt { get; set; } = DateTimeOffset.UtcNow; + + // --- Collaborator invitation state --- + // A student who is already an organization member is added to the repository outright (GitHub answers 204). + // Anyone else only gets an *invitation* (201) which they must accept, and which expires. Until then the + // repository is invisible to them, so the portal has to track and be able to re-send it. + + /// True while GitHub has an outstanding invitation the student has not accepted yet. + public bool InvitationPending { get; set; } + + /// GitHub's invitation id — needed to delete the stale one before issuing a replacement. + public long? InvitationId { get; set; } + + public DateTimeOffset? InvitationSentAt { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/Course.cs b/ahk-backend/Ahk.Web.Data/Entities/Course.cs new file mode 100644 index 0000000..1d77a67 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/Course.cs @@ -0,0 +1,37 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// A university course (e.g. BME subject code "viaubc01"). This is what used to be a separate +/// per-course Azure Functions deployment; each course now lives as one record in the central site +/// and holds its own GitHub-environment configuration. Domain data is assigned to a course via +/// . +/// +public class Course +{ + public int Id { get; set; } + + /// URL-safe unique identifier used in the path segment: ahk.aut.bme.hu/{Slug}/... + public string Slug { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + // --- Repository routing --- + // These two live on Course (not CourseGitHubConfig) because machine-to-machine entry points resolve + // the course from them, and CourseResolutionMiddleware loads Course on every course-scoped request. + // Credentials deliberately live in CourseGitHubConfig so they are not on that hot path. + + /// GitHub organization owning this course's repositories — the primary resolution key. + public string? GitHubOrganization { get; set; } + + /// + /// Optional repository-name prefix, used to disambiguate when one organization hosts several courses. + /// This is the explicit form of what used to be the implicit "repo prefix = course" convention. + /// + public string? RepoNamePrefix { get; set; } + + public CourseGitHubConfig? GitHubConfig { get; set; } + + public ICollection Memberships { get; } = new List(); +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs new file mode 100644 index 0000000..89db783 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs @@ -0,0 +1,39 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// Per-course GitHub integration configuration — what used to be the per-deployment AHK_* environment +/// variables of github-monitor (GitHubMonitorConfig.cs). Kept in its own table (1:1 with +/// ) so the per-request course lookup never loads the GitHub App private key. +/// +public class CourseGitHubConfig +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + /// GitHub App id (was AHK_GitHubAppId). + public string? GitHubAppId { get; set; } + + /// GitHub App private key (was AHK_GitHubAppPrivateKey). Stored as a plain column by decision. + public string? GitHubAppPrivateKey { get; set; } + + /// + /// Personal / fine-grained access token used for REST calls that do not need a per-installation token — + /// today only the connectivity health check. Stored as a plain column, like the other credentials. + /// + public string? GitHubAccessToken { get; set; } + + /// Secret used to validate the X-Hub-Signature-256 webhook signature (was AHK_GitHubWebhookSecret). + public string? GitHubWebhookSecret { get; set; } + + /// Maximum allowed Actions workflow runs per repository; was the const WorkflowRunThreshold = 5. + public int WorkflowRunThreshold { get; set; } = 5; + + /// When false, incoming webhooks for this course are ignored. + public bool Enabled { get; set; } = true; + + /// Last time an administrator changed these settings; shown in the admin UI. + public DateTimeOffset? UpdatedAt { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs new file mode 100644 index 0000000..5abc1af --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs @@ -0,0 +1,15 @@ +namespace Ahk.Web.Data.Entities; + +/// Assigns a user to a course with a course-level role. A user may belong to many courses. +public class CourseMembership +{ + public int UserId { get; set; } + + public int CourseId { get; set; } + + public CourseRole Role { get; set; } = CourseRole.Instructor; + + public ApplicationUser? User { get; set; } + + public Course? Course { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs new file mode 100644 index 0000000..9459cd9 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs @@ -0,0 +1,11 @@ +namespace Ahk.Web.Data.Entities; + +/// Role a user holds within a specific course (distinct from site-level roles). +public enum CourseRole +{ + /// Can view the course's submissions, statuses and grades. + Instructor = 0, + + /// Can additionally manage the course's configuration and members. + Admin = 1, +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs new file mode 100644 index 0000000..c916452 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs @@ -0,0 +1,31 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// Token/secret pair authenticating a course's CI callbacks (publish-results-pr → evaluation-result webhook). +/// Relational form of the webhooktokens container's WebhookToken. +/// +/// is globally unique because the CI callback carries no {course} path segment — the token +/// itself is how that request resolves to a course. is the HMAC-SHA256 key, verified with +/// the scheme ported from grade-management/.../Helpers/HmacSha256Validator.cs. +/// +public class CourseWebhookToken : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + /// Public identifier sent in the X-Ahk-Token header. + public string Token { get; set; } = string.Empty; + + /// HMAC signing key. Stored as a plain column by decision. + public string Secret { get; set; } = string.Empty; + + public string? Description { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// When set, the token is no longer accepted. + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs b/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs new file mode 100644 index 0000000..3f4292c --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs @@ -0,0 +1,67 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// Append-only grade result — the relational form of StudentResult. Never updated: every evaluation +/// or teacher action inserts a new row, and the current grade is the latest one. +/// distinguishes an automated evaluation result (false) from a teacher-approved grade (true). +/// +public class GradeRecord : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + public int SubmissionId { get; set; } + + public Submission? Submission { get; set; } + + public int? StudentId { get; set; } + + public Student? Student { get; set; } + + /// + /// Neptun as recorded at grading time. Denormalized deliberately: the CSV export reports the code that was + /// on the result, and a grade is a point-in-time record. + /// + public string Neptun { get; set; } = string.Empty; + + public int? PrNumber { get; set; } + + public string? PrUrl { get; set; } + + public DateTimeOffset Date { get; set; } + + /// Who produced it: a teacher's GitHub login, or "grade-management-api" for automated results. + public string? Actor { get; set; } + + /// Where it came from: the commit URL for automated results, or the PR comment for chatops. + public string? Origin { get; set; } + + /// False for automated evaluation results; true once a teacher approves/overrides via /ahk ok. + public bool Confirmed { get; set; } + + public ICollection Points { get; } = new List(); +} + +/// +/// Points for one exercise of a — the relational form of the embedded +/// ExerciseWithPoint collection. Exercise names stay free-form (positional "ex0"/"ex1" carried forward +/// from the previous result, or the evaluator's exerciseName), matching the original semantics. +/// +public class GradeExercisePoint +{ + public int Id { get; set; } + + public int GradeRecordId { get; set; } + + public GradeRecord? GradeRecord { get; set; } + + public string Name { get; set; } = string.Empty; + + public double Point { get; set; } + + /// Preserves positional order, which is significant for the /ahk ok "5 3.5 0" chatops form. + public int Order { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs b/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs new file mode 100644 index 0000000..428d856 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs @@ -0,0 +1,11 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// Marker for domain entities whose rows belong to a single course. The +/// applies an EF Core global query filter on so queries only see the active course's +/// rows. Authorization (course membership) remains the real access gate; the filter is a scoping convenience. +/// +public interface ICourseScoped +{ + int CourseId { get; } +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/Student.cs b/ahk-backend/Ahk.Web.Data/Entities/Student.cs new file mode 100644 index 0000000..1a9c805 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/Student.cs @@ -0,0 +1,27 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// A student within a course, keyed by Neptun code (BME's student identifier). Replaces the neptun string +/// that was denormalized onto every grade and pull-request event in the original system. +/// Rows are created on first sighting (from neptun.txt or a pull-request payload); no roster import +/// is required. +/// +public class Student : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + /// Normalized with (uppercase, trimmed). + public string Neptun { get; set; } = string.Empty; + + public string? GitHubUsername { get; set; } + + public string? Name { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Submissions { get; } = new List(); +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/Submission.cs b/ahk-backend/Ahk.Web.Data/Entities/Submission.cs new file mode 100644 index 0000000..af24c16 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/Submission.cs @@ -0,0 +1,34 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// One student's GitHub repository within a course — the anchor that status events and grades hang off. +/// Replaces the raw repository-name string that the original system used as its grouping key +/// (StudentResult.GitHubRepoName / StatusEventBase.Repository). +/// +public class Submission : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + /// Null while the student is unknown — the repository often exists before neptun.txt is pushed. + public int? StudentId { get; set; } + + public Student? Student { get; set; } + + /// Full "owner/name", normalized with (lowercase, trimmed). + public string GitHubRepoName { get; set; } = string.Empty; + + /// GitHub's numeric repository id, when known. + public long? GitHubRepoId { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? LastEventAt { get; set; } + + public ICollection Events { get; } = new List(); + + public ICollection Grades { get; } = new List(); +} diff --git a/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs b/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs new file mode 100644 index 0000000..d5e7c72 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs @@ -0,0 +1,64 @@ +namespace Ahk.Web.Data.Entities; + +/// +/// Append-only status event for a submission. Mirrors the polymorphic StatusEventBase log of the +/// original CosmosDB events container, mapped table-per-hierarchy. Rows are never mutated; the +/// current status of a submission is a projection over this log (see the status projection in +/// Ahk.Web.Services), exactly as StatusTrackingService.createStatus did. +/// +public abstract class SubmissionEvent : ICourseScoped +{ + public int Id { get; set; } + + public int CourseId { get; set; } + + public Course? Course { get; set; } + + public int SubmissionId { get; set; } + + public Submission? Submission { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + /// + /// GitHub's X-GitHub-Delivery id. Unique (where present) so webhook redeliveries do not duplicate rows — + /// a guard the original Cosmos model lacked. + /// + public string? GitHubDeliveryId { get; set; } +} + +/// Repository was created for the student (was RepositoryCreateEvent). +public class RepositoryCreatedEvent : SubmissionEvent +{ +} + +/// A branch was pushed/created (was BranchCreateEvent). +public class BranchCreatedEvent : SubmissionEvent +{ + public string Branch { get; set; } = string.Empty; +} + +/// Pull request activity (was PullRequestEvent). +public class PullRequestEvent : SubmissionEvent +{ + public int Number { get; set; } + + /// GitHub action name (opened, closed, assigned, ...); the latest one is the PR's status. + public string Action { get; set; } = string.Empty; + + public string? HtmlUrl { get; set; } + + /// Neptun as seen at event time; kept as a snapshot because the original log recorded it per event. + public string? Neptun { get; set; } + + /// Assignees at event time. Mapped to a JSON column — only ever concatenated for display. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "EF Core primitive collections require a settable List.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "EF Core primitive collections require List.")] + public List Assignees { get; set; } = new(); +} + +/// An Actions workflow run finished (was WorkflowRunEvent). +public class WorkflowRunEvent : SubmissionEvent +{ + public string? Conclusion { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs b/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs new file mode 100644 index 0000000..71867e8 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs @@ -0,0 +1,16 @@ +namespace Ahk.Web.Data; + +/// +/// Supplies the course the current request is scoped to. Implemented in the web layer (resolved from the +/// {course} route segment) and consumed by to drive the course query filter. +/// +public interface ICurrentCourseProvider +{ + int? CurrentCourseId { get; } +} + +/// No-op provider (no active course). Used at design time and in host/admin contexts. +public sealed class NullCurrentCourseProvider : ICurrentCourseProvider +{ + public int? CurrentCourseId => null; +} diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs new file mode 100644 index 0000000..420c03f --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs @@ -0,0 +1,1008 @@ +// +using System; +using Ahk.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ahk.Web.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260730062811_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Affiliation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NeptunCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NeptunCode") + .IsUnique() + .HasFilter("[NeptunCode] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("InviteToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TemplateRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("CourseId", "ArchivedAt"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AssignmentId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("GitHubUsername") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("InvitationId") + .HasColumnType("bigint"); + + b.Property("InvitationPending") + .HasColumnType("bit"); + + b.Property("InvitationSentAt") + .HasColumnType("datetimeoffset"); + + b.Property("RepoUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("AssignmentId", "UserId") + .IsUnique(); + + b.HasIndex("CourseId", "GitHubRepoName"); + + b.ToTable("AssignmentAcceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubOrganization") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RepoNamePrefix") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubOrganization"); + + b.HasIndex("RepoNamePrefix"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GitHubAccessToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GitHubAppId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GitHubAppPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubWebhookSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("WorkflowRunThreshold") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId") + .IsUnique(); + + b.ToTable("CourseGitHubConfigs"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("UserId", "CourseId"); + + b.HasIndex("CourseId"); + + b.ToTable("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("CourseWebhookTokens"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GradeRecordId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Point") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.ToTable("GradeExercisePoints"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Confirmed") + .HasColumnType("bit"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Origin") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PrNumber") + .HasColumnType("int"); + + b.Property("PrUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "Confirmed", "Date"); + + b.HasIndex("CourseId", "SubmissionId", "PrNumber", "Date"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId", "Neptun") + .IsUnique(); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("LastEventAt") + .HasColumnType("datetimeoffset"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("CourseId", "GitHubRepoName") + .IsUnique(); + + b.HasIndex("CourseId", "StudentId"); + + b.ToTable("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("nvarchar(34)"); + + b.Property("GitHubDeliveryId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GitHubDeliveryId") + .IsUnique() + .HasFilter("[GitHubDeliveryId] IS NOT NULL"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "SubmissionId", "Timestamp"); + + b.ToTable("SubmissionEvents"); + + b.HasDiscriminator("EventType").HasValue("SubmissionEvent"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.BranchCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Branch") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasDiscriminator().HasValue("BranchCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.PullRequestEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.PrimitiveCollection("Assignees") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HtmlUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Neptun") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Number") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue("PullRequestEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.RepositoryCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.HasDiscriminator().HasValue("RepositoryCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.WorkflowRunEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Conclusion") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasDiscriminator().HasValue("WorkflowRunEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.HasOne("Ahk.Web.Data.Entities.Assignment", "Assignment") + .WithMany("Acceptances") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithOne("GitHubConfig") + .HasForeignKey("Ahk.Web.Data.Entities.CourseGitHubConfig", "CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany("Memberships") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany("CourseMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.HasOne("Ahk.Web.Data.Entities.GradeRecord", "GradeRecord") + .WithMany("Points") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Grades") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Student"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany("Submissions") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Course"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Events") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Navigation("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Navigation("Acceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Navigation("GitHubConfig"); + + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Navigation("Points"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Navigation("Events"); + + b.Navigation("Grades"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs new file mode 100644 index 0000000..4841872 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs @@ -0,0 +1,703 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ahk.Web.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DisplayName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NeptunCode = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + Affiliation = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + GitHubUsername = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + GitHubUserId = table.Column(type: "bigint", nullable: true), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Courses", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Slug = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + GitHubOrganization = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + RepoNamePrefix = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Courses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "int", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "int", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "int", nullable: false), + RoleId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "int", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Assignments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + TemplateRepoName = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + InviteToken = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ArchivedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Assignments", x => x.Id); + table.ForeignKey( + name: "FK_Assignments_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CourseGitHubConfigs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + GitHubAppId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + GitHubAppPrivateKey = table.Column(type: "nvarchar(max)", nullable: true), + GitHubAccessToken = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + GitHubWebhookSecret = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + WorkflowRunThreshold = table.Column(type: "int", nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseGitHubConfigs", x => x.Id); + table.ForeignKey( + name: "FK_CourseGitHubConfigs_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CourseMemberships", + columns: table => new + { + UserId = table.Column(type: "int", nullable: false), + CourseId = table.Column(type: "int", nullable: false), + Role = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseMemberships", x => new { x.UserId, x.CourseId }); + table.ForeignKey( + name: "FK_CourseMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CourseMemberships_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CourseWebhookTokens", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + Token = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Secret = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + Description = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + RevokedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseWebhookTokens", x => x.Id); + table.ForeignKey( + name: "FK_CourseWebhookTokens_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Students", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + Neptun = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + GitHubUsername = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Students", x => x.Id); + table.ForeignKey( + name: "FK_Students_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AssignmentAcceptances", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + AssignmentId = table.Column(type: "int", nullable: false), + UserId = table.Column(type: "int", nullable: false), + GitHubRepoName = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + RepoUrl = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + GitHubUsername = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + AcceptedAt = table.Column(type: "datetimeoffset", nullable: false), + InvitationPending = table.Column(type: "bit", nullable: false), + InvitationId = table.Column(type: "bigint", nullable: true), + InvitationSentAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AssignmentAcceptances", x => x.Id); + table.ForeignKey( + name: "FK_AssignmentAcceptances_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AssignmentAcceptances_Assignments_AssignmentId", + column: x => x.AssignmentId, + principalTable: "Assignments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AssignmentAcceptances_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Submissions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + StudentId = table.Column(type: "int", nullable: true), + GitHubRepoName = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + GitHubRepoId = table.Column(type: "bigint", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + LastEventAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Submissions", x => x.Id); + table.ForeignKey( + name: "FK_Submissions_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Submissions_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "GradeRecords", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + SubmissionId = table.Column(type: "int", nullable: false), + StudentId = table.Column(type: "int", nullable: true), + Neptun = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + PrNumber = table.Column(type: "int", nullable: true), + PrUrl = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Date = table.Column(type: "datetimeoffset", nullable: false), + Actor = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Origin = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Confirmed = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GradeRecords", x => x.Id); + table.ForeignKey( + name: "FK_GradeRecords_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_GradeRecords_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_GradeRecords_Submissions_SubmissionId", + column: x => x.SubmissionId, + principalTable: "Submissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SubmissionEvents", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CourseId = table.Column(type: "int", nullable: false), + SubmissionId = table.Column(type: "int", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false), + GitHubDeliveryId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + EventType = table.Column(type: "nvarchar(34)", maxLength: 34, nullable: false), + Branch = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: true), + Number = table.Column(type: "int", nullable: true), + Action = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + HtmlUrl = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + Neptun = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + Assignees = table.Column(type: "nvarchar(max)", nullable: true), + Conclusion = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SubmissionEvents", x => x.Id); + table.ForeignKey( + name: "FK_SubmissionEvents_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_SubmissionEvents_Submissions_SubmissionId", + column: x => x.SubmissionId, + principalTable: "Submissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "GradeExercisePoints", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + GradeRecordId = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Point = table.Column(type: "float", nullable: false), + Order = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GradeExercisePoints", x => x.Id); + table.ForeignKey( + name: "FK_GradeExercisePoints_GradeRecords_GradeRecordId", + column: x => x.GradeRecordId, + principalTable: "GradeRecords", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_NeptunCode", + table: "AspNetUsers", + column: "NeptunCode", + unique: true, + filter: "[NeptunCode] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AssignmentAcceptances_AssignmentId_UserId", + table: "AssignmentAcceptances", + columns: new[] { "AssignmentId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AssignmentAcceptances_CourseId_GitHubRepoName", + table: "AssignmentAcceptances", + columns: new[] { "CourseId", "GitHubRepoName" }); + + migrationBuilder.CreateIndex( + name: "IX_AssignmentAcceptances_UserId", + table: "AssignmentAcceptances", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Assignments_CourseId_ArchivedAt", + table: "Assignments", + columns: new[] { "CourseId", "ArchivedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Assignments_InviteToken", + table: "Assignments", + column: "InviteToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseGitHubConfigs_CourseId", + table: "CourseGitHubConfigs", + column: "CourseId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseMemberships_CourseId", + table: "CourseMemberships", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_Courses_GitHubOrganization", + table: "Courses", + column: "GitHubOrganization"); + + migrationBuilder.CreateIndex( + name: "IX_Courses_RepoNamePrefix", + table: "Courses", + column: "RepoNamePrefix"); + + migrationBuilder.CreateIndex( + name: "IX_Courses_Slug", + table: "Courses", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CourseWebhookTokens_CourseId", + table: "CourseWebhookTokens", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_CourseWebhookTokens_Token", + table: "CourseWebhookTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GradeExercisePoints_GradeRecordId", + table: "GradeExercisePoints", + column: "GradeRecordId"); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_CourseId_Confirmed_Date", + table: "GradeRecords", + columns: new[] { "CourseId", "Confirmed", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_CourseId_SubmissionId_PrNumber_Date", + table: "GradeRecords", + columns: new[] { "CourseId", "SubmissionId", "PrNumber", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_StudentId", + table: "GradeRecords", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_GradeRecords_SubmissionId", + table: "GradeRecords", + column: "SubmissionId"); + + migrationBuilder.CreateIndex( + name: "IX_Students_CourseId_Neptun", + table: "Students", + columns: new[] { "CourseId", "Neptun" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SubmissionEvents_CourseId_SubmissionId_Timestamp", + table: "SubmissionEvents", + columns: new[] { "CourseId", "SubmissionId", "Timestamp" }); + + migrationBuilder.CreateIndex( + name: "IX_SubmissionEvents_GitHubDeliveryId", + table: "SubmissionEvents", + column: "GitHubDeliveryId", + unique: true, + filter: "[GitHubDeliveryId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SubmissionEvents_SubmissionId", + table: "SubmissionEvents", + column: "SubmissionId"); + + migrationBuilder.CreateIndex( + name: "IX_Submissions_CourseId_GitHubRepoName", + table: "Submissions", + columns: new[] { "CourseId", "GitHubRepoName" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Submissions_CourseId_StudentId", + table: "Submissions", + columns: new[] { "CourseId", "StudentId" }); + + migrationBuilder.CreateIndex( + name: "IX_Submissions_StudentId", + table: "Submissions", + column: "StudentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AssignmentAcceptances"); + + migrationBuilder.DropTable( + name: "CourseGitHubConfigs"); + + migrationBuilder.DropTable( + name: "CourseMemberships"); + + migrationBuilder.DropTable( + name: "CourseWebhookTokens"); + + migrationBuilder.DropTable( + name: "GradeExercisePoints"); + + migrationBuilder.DropTable( + name: "SubmissionEvents"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "Assignments"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "GradeRecords"); + + migrationBuilder.DropTable( + name: "Submissions"); + + migrationBuilder.DropTable( + name: "Students"); + + migrationBuilder.DropTable( + name: "Courses"); + } + } +} diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.Designer.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.Designer.cs new file mode 100644 index 0000000..9100f84 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.Designer.cs @@ -0,0 +1,1011 @@ +// +using System; +using Ahk.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ahk.Web.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260805005038_AssignmentRepoNamePrefix")] + partial class AssignmentRepoNamePrefix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Affiliation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NeptunCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NeptunCode") + .IsUnique() + .HasFilter("[NeptunCode] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("InviteToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RepoNamePrefix") + .HasColumnType("nvarchar(max)"); + + b.Property("TemplateRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("CourseId", "ArchivedAt"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AssignmentId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("GitHubUsername") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("InvitationId") + .HasColumnType("bigint"); + + b.Property("InvitationPending") + .HasColumnType("bit"); + + b.Property("InvitationSentAt") + .HasColumnType("datetimeoffset"); + + b.Property("RepoUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("AssignmentId", "UserId") + .IsUnique(); + + b.HasIndex("CourseId", "GitHubRepoName"); + + b.ToTable("AssignmentAcceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubOrganization") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RepoNamePrefix") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubOrganization"); + + b.HasIndex("RepoNamePrefix"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GitHubAccessToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GitHubAppId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GitHubAppPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubWebhookSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("WorkflowRunThreshold") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId") + .IsUnique(); + + b.ToTable("CourseGitHubConfigs"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("UserId", "CourseId"); + + b.HasIndex("CourseId"); + + b.ToTable("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("CourseWebhookTokens"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GradeRecordId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Point") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.ToTable("GradeExercisePoints"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Confirmed") + .HasColumnType("bit"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Origin") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PrNumber") + .HasColumnType("int"); + + b.Property("PrUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "Confirmed", "Date"); + + b.HasIndex("CourseId", "SubmissionId", "PrNumber", "Date"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId", "Neptun") + .IsUnique(); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("LastEventAt") + .HasColumnType("datetimeoffset"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("CourseId", "GitHubRepoName") + .IsUnique(); + + b.HasIndex("CourseId", "StudentId"); + + b.ToTable("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("nvarchar(34)"); + + b.Property("GitHubDeliveryId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GitHubDeliveryId") + .IsUnique() + .HasFilter("[GitHubDeliveryId] IS NOT NULL"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "SubmissionId", "Timestamp"); + + b.ToTable("SubmissionEvents"); + + b.HasDiscriminator("EventType").HasValue("SubmissionEvent"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.BranchCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Branch") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasDiscriminator().HasValue("BranchCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.PullRequestEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.PrimitiveCollection("Assignees") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HtmlUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Neptun") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Number") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue("PullRequestEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.RepositoryCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.HasDiscriminator().HasValue("RepositoryCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.WorkflowRunEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Conclusion") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasDiscriminator().HasValue("WorkflowRunEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.HasOne("Ahk.Web.Data.Entities.Assignment", "Assignment") + .WithMany("Acceptances") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithOne("GitHubConfig") + .HasForeignKey("Ahk.Web.Data.Entities.CourseGitHubConfig", "CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany("Memberships") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany("CourseMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.HasOne("Ahk.Web.Data.Entities.GradeRecord", "GradeRecord") + .WithMany("Points") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Grades") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Student"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany("Submissions") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Course"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Events") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Navigation("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Navigation("Acceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Navigation("GitHubConfig"); + + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Navigation("Points"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Navigation("Events"); + + b.Navigation("Grades"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.cs new file mode 100644 index 0000000..7a7c77d --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Migrations/20260805005038_AssignmentRepoNamePrefix.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ahk.Web.Data.Migrations +{ + /// + public partial class AssignmentRepoNamePrefix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RepoNamePrefix", + table: "Assignments", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RepoNamePrefix", + table: "Assignments"); + } + } +} diff --git a/ahk-backend/Ahk.Web.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/ahk-backend/Ahk.Web.Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..9c7c2dc --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,1008 @@ +// +using System; +using Ahk.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Ahk.Web.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Affiliation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NeptunCode") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NeptunCode") + .IsUnique() + .HasFilter("[NeptunCode] IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArchivedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("InviteToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RepoNamePrefix") + .HasColumnType("nvarchar(max)"); + + b.Property("TemplateRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("InviteToken") + .IsUnique(); + + b.HasIndex("CourseId", "ArchivedAt"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AssignmentId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("GitHubUsername") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("InvitationId") + .HasColumnType("bigint"); + + b.Property("InvitationPending") + .HasColumnType("bit"); + + b.Property("InvitationSentAt") + .HasColumnType("datetimeoffset"); + + b.Property("RepoUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("AssignmentId", "UserId") + .IsUnique(); + + b.HasIndex("CourseId", "GitHubRepoName"); + + b.ToTable("AssignmentAcceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubOrganization") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RepoNamePrefix") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubOrganization"); + + b.HasIndex("RepoNamePrefix"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GitHubAccessToken") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GitHubAppId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GitHubAppPrivateKey") + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubWebhookSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("WorkflowRunThreshold") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId") + .IsUnique(); + + b.ToTable("CourseGitHubConfigs"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("UserId", "CourseId"); + + b.HasIndex("CourseId"); + + b.ToTable("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("CourseWebhookTokens"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GradeRecordId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Point") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.ToTable("GradeExercisePoints"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Confirmed") + .HasColumnType("bit"); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetimeoffset"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Origin") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("PrNumber") + .HasColumnType("int"); + + b.Property("PrUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "Confirmed", "Date"); + + b.HasIndex("CourseId", "SubmissionId", "PrNumber", "Date"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubUsername") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Neptun") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId", "Neptun") + .IsUnique(); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("GitHubRepoName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("LastEventAt") + .HasColumnType("datetimeoffset"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("CourseId", "GitHubRepoName") + .IsUnique(); + + b.HasIndex("CourseId", "StudentId"); + + b.ToTable("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("nvarchar(34)"); + + b.Property("GitHubDeliveryId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SubmissionId") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GitHubDeliveryId") + .IsUnique() + .HasFilter("[GitHubDeliveryId] IS NOT NULL"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("CourseId", "SubmissionId", "Timestamp"); + + b.ToTable("SubmissionEvents"); + + b.HasDiscriminator("EventType").HasValue("SubmissionEvent"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.BranchCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Branch") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasDiscriminator().HasValue("BranchCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.PullRequestEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.PrimitiveCollection("Assignees") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HtmlUrl") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Neptun") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Number") + .HasColumnType("int"); + + b.HasDiscriminator().HasValue("PullRequestEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.RepositoryCreatedEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.HasDiscriminator().HasValue("RepositoryCreatedEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.WorkflowRunEvent", b => + { + b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent"); + + b.Property("Conclusion") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasDiscriminator().HasValue("WorkflowRunEvent"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b => + { + b.HasOne("Ahk.Web.Data.Entities.Assignment", "Assignment") + .WithMany("Acceptances") + .HasForeignKey("AssignmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignment"); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithOne("GitHubConfig") + .HasForeignKey("Ahk.Web.Data.Entities.CourseGitHubConfig", "CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany("Memberships") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User") + .WithMany("CourseMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b => + { + b.HasOne("Ahk.Web.Data.Entities.GradeRecord", "GradeRecord") + .WithMany("Points") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Grades") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Student"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Student", "Student") + .WithMany("Submissions") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Course"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b => + { + b.HasOne("Ahk.Web.Data.Entities.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission") + .WithMany("Events") + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b => + { + b.Navigation("CourseMemberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b => + { + b.Navigation("Acceptances"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b => + { + b.Navigation("GitHubConfig"); + + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b => + { + b.Navigation("Points"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b => + { + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b => + { + b.Navigation("Events"); + + b.Navigation("Grades"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ahk-backend/Ahk.Web.Data/Normalize.cs b/ahk-backend/Ahk.Web.Data/Normalize.cs new file mode 100644 index 0000000..90a2a26 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Normalize.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Ahk.Web.Data; + +/// +/// Canonical forms for the two natural keys carried over from the original system. Ported verbatim from +/// grade-management/Ahk.GradeManagement.Data/Normalize.cs so imported rows and runtime-written rows +/// are byte-identical; lookups depend on it. +/// +public static class Normalize +{ + public static string Neptun(string value) => value is null ? string.Empty : value.ToUpperInvariant().Trim(); + + [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Repo name is normalized to lowercase.")] + public static string RepoName(string value) => value is null ? string.Empty : value.ToLowerInvariant().Trim(); +} diff --git a/ahk-backend/Ahk.Web.Data/Roles.cs b/ahk-backend/Ahk.Web.Data/Roles.cs new file mode 100644 index 0000000..23e168a --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Roles.cs @@ -0,0 +1,10 @@ +namespace Ahk.Web.Data; + +/// Site-level (application-wide) role names. Course-level roles are on the membership record. +public static class Roles +{ + /// Super-admin: manages courses and their connected GitHub environments across the whole site. + public const string Admin = "Admin"; + + public static readonly IReadOnlyList All = new[] { Admin }; +} diff --git a/ahk-backend/Ahk.Web.Data/Seed/DevDataSeeder.cs b/ahk-backend/Ahk.Web.Data/Seed/DevDataSeeder.cs new file mode 100644 index 0000000..58a79a0 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/Seed/DevDataSeeder.cs @@ -0,0 +1,212 @@ +using Ahk.Web.Data.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Ahk.Web.Data.Seed; + +/// +/// Seeds development data: applies migrations, ensures roles, a super-admin, an instructor scoped to one +/// course, two sample courses with GitHub config + CI token, and a small amount of realistic domain data +/// (students, submissions, status events, grades) so the dashboards and exports have something to show. +/// +/// All reads use IgnoreQueryFilters(): there is no HTTP request here, so no current course is set and +/// the course query filter would otherwise match nothing. +/// +public static class DevDataSeeder +{ + public const string AdminUserName = "admin"; + public const string AdminPassword = "Admin123!"; + + public static async Task SeedAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + var db = sp.GetRequiredService(); + await db.Database.MigrateAsync(); + + var roleManager = sp.GetRequiredService>(); + foreach (var role in Roles.All) + { + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new ApplicationRole(role)); + } + + var userManager = sp.GetRequiredService>(); + var admin = await userManager.FindByNameAsync(AdminUserName); + if (admin is null) + { + admin = new ApplicationUser { UserName = AdminUserName, Email = "admin@ahk.aut.bme.hu", DisplayName = "Site Admin", EmailConfirmed = true }; + await userManager.CreateAsync(admin, AdminPassword); + await userManager.AddToRoleAsync(admin, Roles.Admin); + } + + // A non-admin instructor who is a member of exactly one course (used to verify course-scoping / 403s). + var instructor = await userManager.FindByNameAsync("instructor"); + if (instructor is null) + { + instructor = new ApplicationUser { UserName = "instructor", Email = "instructor@ahk.aut.bme.hu", DisplayName = "Sample Instructor", EmailConfirmed = true }; + await userManager.CreateAsync(instructor, "Instructor123!"); + } + + // The two courses are deliberately configured differently so the admin health dashboard shows a mix of + // states in development: one fully wired up bar the access token, one with nothing filled in yet. + var courseA = await EnsureCourseAsync(db, "viaubc01", "Sample Course VIAUBC01", "ahk-viaubc01", webhookSecret: "dev-webhook-secret"); + await EnsureCourseAsync(db, "viaubb01", "Sample Course VIAUBB01", "ahk-viaubb01", webhookSecret: null); + + // Instructor is a member of the first course only. + var hasMembership = await db.CourseMemberships.IgnoreQueryFilters() + .AnyAsync(m => m.UserId == instructor.Id && m.CourseId == courseA.Id); + if (!hasMembership) + { + db.CourseMemberships.Add(new CourseMembership { UserId = instructor.Id, CourseId = courseA.Id, Role = CourseRole.Instructor }); + await db.SaveChangesAsync(); + } + + await EnsureSampleDomainDataAsync(db, courseA); + await EnsureSampleAssignmentsAsync(db, courseA); + } + + /// + /// One open and one archived assignment, so the instructor listing, the "show archived" toggle and the + /// closed-invite branch all have something to render. + /// + private static async Task EnsureSampleAssignmentsAsync(ApplicationDbContext db, Course course) + { + if (await db.Assignments.IgnoreQueryFilters().AnyAsync(a => a.CourseId == course.Id)) + return; + + db.Assignments.AddRange( + new Assignment + { + CourseId = course.Id, + Name = "Homework 1 — Data access", + Description = "Implement the repository layer and open a pull request from the solution branch.", + TemplateRepoName = Normalize.RepoName($"{course.GitHubOrganization}/{course.Slug}-hw1"), + InviteToken = $"dev-invite-{course.Slug}-hw1", + }, + new Assignment + { + CourseId = course.Id, + Name = "Homework 0 — Warm-up (archived)", + Description = "Last semester's warm-up assignment. Archived: the invite link no longer accepts students.", + TemplateRepoName = Normalize.RepoName($"{course.GitHubOrganization}/{course.Slug}-hw0"), + InviteToken = $"dev-invite-{course.Slug}-hw0", + ArchivedAt = DateTimeOffset.UtcNow.AddDays(-30), + }); + + await db.SaveChangesAsync(); + } + + private static async Task EnsureCourseAsync(ApplicationDbContext db, string slug, string name, string org, string? webhookSecret) + { + var course = await db.Courses.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Slug == slug); + if (course is not null) + return course; + + course = new Course + { + Slug = slug, + Name = name, + GitHubOrganization = org, + RepoNamePrefix = slug, + GitHubConfig = new CourseGitHubConfig + { + GitHubWebhookSecret = webhookSecret, + WorkflowRunThreshold = 5, + Enabled = true, + }, + }; + + db.Courses.Add(course); + await db.SaveChangesAsync(); + + db.CourseWebhookTokens.Add(new CourseWebhookToken + { + CourseId = course.Id, + Token = $"dev-token-{slug}", + Secret = $"dev-secret-{slug}", + Description = "Development CI callback token", + }); + await db.SaveChangesAsync(); + + return course; + } + + /// Two students with submissions, a realistic event stream, and both an automated and a confirmed grade. + private static async Task EnsureSampleDomainDataAsync(ApplicationDbContext db, Course course) + { + if (await db.Submissions.IgnoreQueryFilters().AnyAsync(s => s.CourseId == course.Id)) + return; + + var samples = new[] + { + (Neptun: "ABC123", Repo: $"{course.GitHubOrganization}/{course.Slug}-hw1-abc123", Points: new[] { 2d, 3d }, Conclusion: "success"), + (Neptun: "XYZ789", Repo: $"{course.GitHubOrganization}/{course.Slug}-hw1-xyz789", Points: new[] { 1d, 0d }, Conclusion: "failure"), + }; + + var now = DateTimeOffset.UtcNow; + + foreach (var (neptun, repo, points, conclusion) in samples) + { + var student = new Student { CourseId = course.Id, Neptun = Normalize.Neptun(neptun) }; + db.Students.Add(student); + await db.SaveChangesAsync(); + + var submission = new Submission + { + CourseId = course.Id, + StudentId = student.Id, + GitHubRepoName = Normalize.RepoName(repo), + LastEventAt = now, + }; + db.Submissions.Add(submission); + await db.SaveChangesAsync(); + + db.SubmissionEvents.AddRange( + new RepositoryCreatedEvent { CourseId = course.Id, SubmissionId = submission.Id, Timestamp = now.AddDays(-7) }, + new BranchCreatedEvent { CourseId = course.Id, SubmissionId = submission.Id, Timestamp = now.AddDays(-6), Branch = "solution" }, + new PullRequestEvent + { + CourseId = course.Id, + SubmissionId = submission.Id, + Timestamp = now.AddDays(-5), + Number = 1, + Action = "opened", + HtmlUrl = $"https://github.com/{submission.GitHubRepoName}/pull/1", + Neptun = student.Neptun, + Assignees = new List { "instructor" }, + }, + new WorkflowRunEvent { CourseId = course.Id, SubmissionId = submission.Id, Timestamp = now.AddDays(-5), Conclusion = conclusion }); + + // Automated evaluation result, then the teacher's confirmed grade (append-only history). + AddGrade(db, course, submission, student, points, confirmed: false, date: now.AddDays(-5), actor: "grade-management-api", origin: $"https://github.com/{submission.GitHubRepoName}/commit/abc1234"); + AddGrade(db, course, submission, student, points, confirmed: true, date: now.AddDays(-1), actor: "instructor", origin: $"https://github.com/{submission.GitHubRepoName}/pull/1"); + + await db.SaveChangesAsync(); + } + } + + private static void AddGrade(ApplicationDbContext db, Course course, Submission submission, Student student, double[] points, bool confirmed, DateTimeOffset date, string actor, string origin) + { + var grade = new GradeRecord + { + CourseId = course.Id, + SubmissionId = submission.Id, + StudentId = student.Id, + Neptun = student.Neptun, + PrNumber = 1, + PrUrl = $"https://github.com/{submission.GitHubRepoName}/pull/1", + Date = date, + Actor = actor, + Origin = origin, + Confirmed = confirmed, + }; + + for (var i = 0; i < points.Length; i++) + grade.Points.Add(new GradeExercisePoint { Name = $"ex{i}", Point = points[i], Order = i }); + + db.GradeRecords.Add(grade); + } +} diff --git a/ahk-backend/Ahk.Web.Data/TokenGenerator.cs b/ahk-backend/Ahk.Web.Data/TokenGenerator.cs new file mode 100644 index 0000000..13c4d37 --- /dev/null +++ b/ahk-backend/Ahk.Web.Data/TokenGenerator.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; + +namespace Ahk.Web.Data; + +/// +/// Random identifiers that travel in URLs and HTTP headers: CI callback tokens and assignment invite links. +/// Base64 with the two URL-hostile characters swapped and the padding dropped, so the value can be pasted +/// anywhere without escaping. +/// +public static class TokenGenerator +{ + public static string UrlSafe(int byteLength) => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); +} diff --git a/ahk-backend/Ahk.Web.Import/Ahk.Web.Import.csproj b/ahk-backend/Ahk.Web.Import/Ahk.Web.Import.csproj new file mode 100644 index 0000000..fa08ba0 --- /dev/null +++ b/ahk-backend/Ahk.Web.Import/Ahk.Web.Import.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/ahk-backend/Ahk.Web.Import/CosmosDocuments.cs b/ahk-backend/Ahk.Web.Import/CosmosDocuments.cs new file mode 100644 index 0000000..4f74ca9 --- /dev/null +++ b/ahk-backend/Ahk.Web.Import/CosmosDocuments.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; + +namespace Ahk.Web.Import; + +/// +/// Shapes of the exported CosmosDB documents. Only the fields we import are declared; Cosmos system fields +/// (_rid, _self, _etag, _ts, _attachments) are simply ignored by the deserializer. +/// +internal sealed class StudentResultDocument +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + public string? Neptun { get; set; } + + public string? GitHubRepoName { get; set; } + + public int? GitHubPrNumber { get; set; } + + public string? GitHubPrUrl { get; set; } + + public DateTimeOffset Date { get; set; } + + public string? Actor { get; set; } + + public string? Origin { get; set; } + + public List? Points { get; set; } + + public bool Confirmed { get; set; } +} + +internal sealed class ExerciseWithPointDocument +{ + public string? Name { get; set; } + + public double Point { get; set; } +} + +/// +/// One document from the events container. The container is polymorphic: $type selects which +/// subtype's fields are populated (written by the legacy StatusEventItemJsonConverter). +/// +internal sealed class StatusEventDocument +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("$type")] + public string? Type { get; set; } + + public string? Repository { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + // BranchCreateEvent + public string? Branch { get; set; } + + // WorkflowRunEvent + public string? Conclusion { get; set; } + + // PullRequestEvent + public string? Action { get; set; } + + public List? Assignees { get; set; } + + public string? Neptun { get; set; } + + public string? HtmlUrl { get; set; } + + public int Number { get; set; } +} + +internal sealed class WebhookTokenDocument +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + public string? Secret { get; set; } + + public string? Description { get; set; } +} + +/// Legacy $type discriminator values from the events container. +internal static class LegacyEventTypes +{ + public const string RepositoryCreate = "RepositoryCreateEvent"; + public const string BranchCreate = "BranchCreateEvent"; + public const string PullRequest = "PullRequestEvent"; + public const string WorkflowRun = "WorkflowRunEvent"; +} diff --git a/ahk-backend/Ahk.Web.Import/ImportOptions.cs b/ahk-backend/Ahk.Web.Import/ImportOptions.cs new file mode 100644 index 0000000..177aa55 --- /dev/null +++ b/ahk-backend/Ahk.Web.Import/ImportOptions.cs @@ -0,0 +1,70 @@ +namespace Ahk.Web.Import; + +/// Command-line options for the one-time import. +internal sealed class ImportOptions +{ + public string CourseSlug { get; private set; } = string.Empty; + + public string ConnectionString { get; private set; } = string.Empty; + + public string? GradesFile { get; private set; } + + public string? EventsFile { get; private set; } + + public string? TokensFile { get; private set; } + + /// Only import rows whose repository name starts with this prefix (when one export covers several courses). + public string? RepoPrefix { get; private set; } + + public bool Force { get; private set; } + + public static string Usage => + """ + Ahk.Web.Import — one-time CosmosDB -> MSSQL import (throwaway tool). + + --course Target course slug (must already exist). [required] + --connection Target MSSQL connection string. [required] + --grades Exported 'grades' container. + --events Exported 'events' container. + --tokens Exported 'webhooktokens' container. + --repo-prefix Only import repositories starting with this prefix. + --force Import even if the course already has domain rows. + + At least one of --grades / --events / --tokens must be supplied. + """; + + public static bool TryParse(string[] args, out ImportOptions options, out string? error) + { + options = new ImportOptions(); + error = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + string? Next() => i + 1 < args.Length ? args[++i] : null; + + switch (arg) + { + case "--course": options.CourseSlug = Next() ?? string.Empty; break; + case "--connection": options.ConnectionString = Next() ?? string.Empty; break; + case "--grades": options.GradesFile = Next(); break; + case "--events": options.EventsFile = Next(); break; + case "--tokens": options.TokensFile = Next(); break; + case "--repo-prefix": options.RepoPrefix = Next(); break; + case "--force": options.Force = true; break; + default: + error = $"Unknown argument: {arg}"; + return false; + } + } + + if (string.IsNullOrWhiteSpace(options.CourseSlug)) + error = "--course is required."; + else if (string.IsNullOrWhiteSpace(options.ConnectionString)) + error = "--connection is required."; + else if (options.GradesFile is null && options.EventsFile is null && options.TokensFile is null) + error = "At least one of --grades / --events / --tokens must be supplied."; + + return error is null; + } +} diff --git a/ahk-backend/Ahk.Web.Import/Importer.cs b/ahk-backend/Ahk.Web.Import/Importer.cs new file mode 100644 index 0000000..8d8b9fd --- /dev/null +++ b/ahk-backend/Ahk.Web.Import/Importer.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Import; + +/// +/// Moves one course's history from the exported CosmosDB JSON into the relational model. +/// +/// Deliberately bypasses Ahk.Web.Services and writes through the DbContext directly: this is bulk movement of +/// records that are already in final shape, not domain operations. It does reuse so the +/// imported rows are identical in shape to runtime-written ones. +/// +/// There is no HTTP scope here, so every read uses IgnoreQueryFilters — the course query filter would otherwise +/// match nothing. +/// +internal sealed class Importer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ApplicationDbContext db; + private readonly ImportOptions options; + + // Caches so each distinct neptun/repo produces exactly one row. + private readonly Dictionary students = new(StringComparer.Ordinal); + private readonly Dictionary submissions = new(StringComparer.Ordinal); + + public Importer(ApplicationDbContext db, ImportOptions options) + { + this.db = db; + this.options = options; + } + + public async Task RunAsync() + { + var course = await db.Courses.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Slug == options.CourseSlug); + if (course is null) + { + Console.Error.WriteLine($"Course '{options.CourseSlug}' does not exist. Create it in the portal first."); + return 1; + } + + if (!options.Force && await HasExistingDataAsync(course.Id)) + { + Console.Error.WriteLine($"Course '{options.CourseSlug}' already has imported data. Re-run with --force to import anyway."); + return 1; + } + + var grades = ReadDocuments(options.GradesFile); + var events = ReadDocuments(options.EventsFile); + var tokens = ReadDocuments(options.TokensFile); + + Console.WriteLine($"Read {grades.Count} grade, {events.Count} event, {tokens.Count} token document(s)."); + + await using var transaction = await db.Database.BeginTransactionAsync(); + + // Order matters for the foreign keys: students and submissions first, then their children. + await PreloadExistingAsync(course.Id); + + var eventCount = await ImportEventsAsync(course.Id, events); + var gradeCount = await ImportGradesAsync(course.Id, grades); + var tokenCount = await ImportTokensAsync(course.Id, tokens); + + await transaction.CommitAsync(); + + Console.WriteLine(); + Console.WriteLine("Import complete:"); + Console.WriteLine($" students : {students.Count}"); + Console.WriteLine($" submissions : {submissions.Count}"); + Console.WriteLine($" events : {eventCount}"); + Console.WriteLine($" grades : {gradeCount}"); + Console.WriteLine($" tokens : {tokenCount}"); + return 0; + } + + private Task HasExistingDataAsync(int courseId) + => db.Submissions.IgnoreQueryFilters().AnyAsync(s => s.CourseId == courseId); + + private async Task PreloadExistingAsync(int courseId) + { + foreach (var s in await db.Students.IgnoreQueryFilters().Where(s => s.CourseId == courseId).ToListAsync()) + students[s.Neptun] = s; + + foreach (var s in await db.Submissions.IgnoreQueryFilters().Where(s => s.CourseId == courseId).ToListAsync()) + submissions[s.GitHubRepoName] = s; + } + + private static List ReadDocuments(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return new List(); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Export file not found: {path}", path); + + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize>(stream, JsonOptions) ?? new List(); + } + + private bool IsIncluded(string? repository) + { + if (string.IsNullOrWhiteSpace(repository)) + return false; + + return options.RepoPrefix is null + || Normalize.RepoName(repository).StartsWith(Normalize.RepoName(options.RepoPrefix), StringComparison.Ordinal); + } + + private async Task GetStudentAsync(int courseId, string neptun) + { + var key = Normalize.Neptun(neptun); + if (students.TryGetValue(key, out var existing)) + return existing; + + var student = new Student { CourseId = courseId, Neptun = key }; + db.Students.Add(student); + await db.SaveChangesAsync(); + + students[key] = student; + return student; + } + + private async Task GetSubmissionAsync(int courseId, string repository, string? neptun) + { + var key = Normalize.RepoName(repository); + if (!submissions.TryGetValue(key, out var submission)) + { + submission = new Submission { CourseId = courseId, GitHubRepoName = key }; + db.Submissions.Add(submission); + await db.SaveChangesAsync(); + submissions[key] = submission; + } + + if (!string.IsNullOrWhiteSpace(neptun) && submission.StudentId is null) + { + var student = await GetStudentAsync(courseId, neptun); + submission.StudentId = student.Id; + await db.SaveChangesAsync(); + } + + return submission; + } + + private async Task ImportEventsAsync(int courseId, List documents) + { + var imported = 0; + + foreach (var doc in documents.Where(d => IsIncluded(d.Repository))) + { + var submission = await GetSubmissionAsync(courseId, doc.Repository!, doc.Neptun); + + SubmissionEvent? entity = doc.Type switch + { + LegacyEventTypes.RepositoryCreate => new RepositoryCreatedEvent(), + LegacyEventTypes.BranchCreate => new BranchCreatedEvent { Branch = doc.Branch ?? string.Empty }, + LegacyEventTypes.WorkflowRun => new WorkflowRunEvent { Conclusion = doc.Conclusion }, + LegacyEventTypes.PullRequest => new PullRequestEvent + { + Number = doc.Number, + Action = doc.Action ?? string.Empty, + HtmlUrl = doc.HtmlUrl, + Neptun = string.IsNullOrWhiteSpace(doc.Neptun) ? null : Normalize.Neptun(doc.Neptun), + Assignees = doc.Assignees ?? new List(), + }, + _ => null, + }; + + if (entity is null) + { + Console.Error.WriteLine($" ! skipping event with unknown $type '{doc.Type}' (id {doc.Id})"); + continue; + } + + entity.CourseId = courseId; + entity.SubmissionId = submission.Id; + entity.Timestamp = doc.Timestamp; + + db.SubmissionEvents.Add(entity); + + if (submission.LastEventAt is null || doc.Timestamp > submission.LastEventAt) + submission.LastEventAt = doc.Timestamp; + + imported++; + + if (imported % 500 == 0) + await db.SaveChangesAsync(); + } + + await db.SaveChangesAsync(); + return imported; + } + + private async Task ImportGradesAsync(int courseId, List documents) + { + var imported = 0; + + foreach (var doc in documents.Where(d => IsIncluded(d.GitHubRepoName))) + { + var neptun = Normalize.Neptun(doc.Neptun ?? string.Empty); + var submission = await GetSubmissionAsync(courseId, doc.GitHubRepoName!, neptun); + + var record = new GradeRecord + { + CourseId = courseId, + SubmissionId = submission.Id, + StudentId = submission.StudentId, + Neptun = neptun, + PrNumber = doc.GitHubPrNumber, + PrUrl = doc.GitHubPrUrl, + Date = doc.Date, + Actor = doc.Actor, + Origin = doc.Origin, + Confirmed = doc.Confirmed, + }; + + var order = 0; + foreach (var p in doc.Points ?? new List()) + record.Points.Add(new GradeExercisePoint { Name = p.Name ?? string.Empty, Point = p.Point, Order = order++ }); + + db.GradeRecords.Add(record); + imported++; + + if (imported % 500 == 0) + await db.SaveChangesAsync(); + } + + await db.SaveChangesAsync(); + return imported; + } + + private async Task ImportTokensAsync(int courseId, List documents) + { + var imported = 0; + + foreach (var doc in documents.Where(d => !string.IsNullOrWhiteSpace(d.Id))) + { + var exists = await db.CourseWebhookTokens.IgnoreQueryFilters().AnyAsync(t => t.Token == doc.Id); + if (exists) + continue; + + db.CourseWebhookTokens.Add(new CourseWebhookToken + { + CourseId = courseId, + Token = doc.Id!, + Secret = doc.Secret ?? string.Empty, + Description = doc.Description, + }); + imported++; + } + + await db.SaveChangesAsync(); + return imported; + } +} diff --git a/ahk-backend/Ahk.Web.Import/Program.cs b/ahk-backend/Ahk.Web.Import/Program.cs new file mode 100644 index 0000000..14c69e8 --- /dev/null +++ b/ahk-backend/Ahk.Web.Import/Program.cs @@ -0,0 +1,46 @@ +using Ahk.Web.Data; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Import; + +/// +/// Entry point for the one-time CosmosDB -> MSSQL import. This project is throwaway: delete it once every +/// course's history has been migrated. +/// +public static class Program +{ + public static async Task Main(string[] args) + { + if (args.Length == 0 || args.Contains("--help") || args.Contains("-h")) + { + Console.WriteLine(ImportOptions.Usage); + return 0; + } + + if (!ImportOptions.TryParse(args, out var options, out var error)) + { + Console.Error.WriteLine(error); + Console.Error.WriteLine(); + Console.Error.WriteLine(ImportOptions.Usage); + return 1; + } + + var dbOptions = new DbContextOptionsBuilder() + .UseSqlServer(options.ConnectionString) + .Options; + + // No HTTP scope: a null current course means the query filter matches nothing, so the importer reads + // with IgnoreQueryFilters throughout. + await using var db = new ApplicationDbContext(dbOptions, new NullCurrentCourseProvider()); + + try + { + return await new Importer(db, options).RunAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Import failed: {ex.Message}"); + return 1; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/Ahk.Web.Server.Tests.csproj b/ahk-backend/Ahk.Web.Server.Tests/Ahk.Web.Server.Tests.csproj new file mode 100644 index 0000000..86dd719 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/Ahk.Web.Server.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ahk-backend/Ahk.Web.Server.Tests/ApiSmokeTests.cs b/ahk-backend/Ahk.Web.Server.Tests/ApiSmokeTests.cs new file mode 100644 index 0000000..7156a33 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/ApiSmokeTests.cs @@ -0,0 +1,151 @@ +using System.Linq; +using System.Net; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Boots the real pipeline (auth + controllers + course middleware) with the SQL Server DbContext swapped for +/// an in-memory one, and checks the fundamental contracts hold without a database or authenticated user. +/// +public class ApiSmokeTests : IClassFixture +{ + private readonly TestAppFactory factory; + + public ApiSmokeTests(TestAppFactory factory) => this.factory = factory; + + [Fact] + public async Task Me_WithoutAuth_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// + /// The auth cookies must keep app-specific names. Browsers scope cookies by host and ignore the port, so + /// on the framework default every ASP.NET Identity app on localhost shares one cookie — and a foreign one + /// carrying a GUID user id crashes this app's int-keyed Identity inside SecurityStampValidator. + /// + [Fact] + public void AuthCookies_AreNamedForThisApp() + { + var options = factory.Services + .GetRequiredService>(); + + Assert.Equal(Program.ApplicationCookieName, options.Get(IdentityConstants.ApplicationScheme).Cookie.Name); + Assert.Equal(Program.ExternalCookieName, options.Get(IdentityConstants.ExternalScheme).Cookie.Name); + Assert.DoesNotContain("AspNetCore.Identity", Program.ApplicationCookieName, StringComparison.Ordinal); + } + + /// A cookie this app cannot read must sign the caller out, not surface as a 500. + [Fact] + public async Task UnreadableAuthCookie_Returns401_NotAnError() + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("Cookie", $"{Program.ApplicationCookieName}=not-a-real-cookie-value"); + + var response = await client.GetAsync("/api/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Every host/admin surface is behind the site-admin role, including the ones added for the admin UI. + [Theory] + [InlineData("/api/admin/courses")] + [InlineData("/api/admin/courses/1")] + [InlineData("/api/admin/courses/1/github")] + [InlineData("/api/admin/courses/1/members")] + [InlineData("/api/admin/courses/1/tokens")] + [InlineData("/api/admin/users")] + [InlineData("/api/admin/users/1")] + [InlineData("/api/admin/health")] + [InlineData("/api/admin/health/1")] + public async Task AdminEndpoints_WithoutAuth_Return401(string url) + { + var client = factory.CreateClient(); + var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task UnknownCourseSlug_Returns404_BeforeAuth() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/no-such-course/statuses"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// The student-facing surfaces need a session, but nothing more. They are reachable by people who are not + /// members of any course — that is the point of an invite link — so they must sit behind plain + /// authentication rather than the CourseMember policy. + /// + [Theory] + [InlineData("/api/my/assignments")] + [InlineData("/api/viaubc01/invite/some-token")] + public async Task StudentEndpoints_WithoutAuth_Return401(string url) + { + var client = factory.CreateClient(); + var response = await client.GetAsync(url); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + /// Assignment administration stays behind course membership, unlike the invite endpoint. + [Fact] + public async Task AssignmentAdministration_WithoutAuth_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/viaubc01/assignments"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + public sealed class TestAppFactory : WebApplicationFactory + { + protected override IHost CreateHost(IHostBuilder builder) + { + // "Testing" (not Development) so the dev data seeder / SQL Server connection are skipped. + builder.UseEnvironment("Testing"); + builder.ConfigureServices(services => + { + // Drop the SqlServer registration (the options, EF's options-configuration, and the context) + // before swapping in the in-memory provider, otherwise two providers end up registered. + var toRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ApplicationDbContext) || + (d.ServiceType.IsGenericType && d.ServiceType.Name.StartsWith("IDbContextOptionsConfiguration", StringComparison.Ordinal))) + .ToList(); + foreach (var descriptor in toRemove) + services.Remove(descriptor); + + services.AddDbContext(options => options.UseInMemoryDatabase("ApiSmokeTests")); + }); + + var host = base.CreateHost(builder); + + // CourseResolutionMiddleware 404s an unknown {course} slug before authorization ever runs, so the + // course-scoped tests need a real course to aim at — otherwise they would assert 404 and prove + // nothing about the policy on the endpoint. + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Courses.Any(c => c.Slug == "viaubc01")) + { + db.Courses.Add(new Course { Slug = "viaubc01", Name = "Sample Course" }); + db.SaveChanges(); + } + } + + return host; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/AssignmentInviteTests.cs b/ahk-backend/Ahk.Web.Server.Tests/AssignmentInviteTests.cs new file mode 100644 index 0000000..20ffff1 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/AssignmentInviteTests.cs @@ -0,0 +1,307 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Assignments; +using Ahk.Web.Services.GitHub; +using Ahk.Web.Services.Submissions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// The invite state machine, against a mocked GitHub. This is the path a student walks unattended, so each +/// branch is pinned: what is still missing, what happens when the repository is already there, and that +/// accepting twice does not produce two repositories. +/// +public class AssignmentInviteTests +{ + private const int CourseId = 1; + private const string InviteToken = "invite-token"; + + private sealed class FixedCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId => CourseId; + } + + private sealed class Fixture : IAsyncDisposable + { + public Fixture(bool archived = false, string? organization = "ahk-org") + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + Db = new ApplicationDbContext(options, new FixedCourseProvider()); + + Db.Courses.Add(new Course { Id = CourseId, Slug = "viaubc01", Name = "Sample Course", GitHubOrganization = organization }); + Db.Assignments.Add(new Assignment + { + Id = 10, + CourseId = CourseId, + Name = "Homework 1", + TemplateRepoName = "ahk-org/viaubc01-hw1", + InviteToken = InviteToken, + ArchivedAt = archived ? DateTimeOffset.UtcNow.AddDays(-1) : null, + }); + Db.SaveChanges(); + + GitHub = new Mock(MockBehavior.Strict); + Tokens = new Mock(); + Tokens + .Setup(t => t.GetForCourseAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitHubInstallationToken("gh-token", 42, new Dictionary { ["administration"] = "write" }, "all")); + + Service = new AssignmentInviteService( + Db, + GitHub.Object, + Tokens.Object, + new SubmissionResolver(Db), + NullLogger.Instance); + } + + public ApplicationDbContext Db { get; } + + public Mock GitHub { get; } + + public Mock Tokens { get; } + + public AssignmentInviteService Service { get; } + + /// A student who has everything the flow needs, unless a field is nulled out by the caller. + public ApplicationUser AddUser(string? neptun = "ABC123", string? gitHubUsername = "octocat") + { + var user = new ApplicationUser { Id = 7, UserName = "student@bme.hu", NeptunCode = neptun, GitHubUsername = gitHubUsername }; + Db.Users.Add(user); + Db.SaveChanges(); + return user; + } + + /// Wires the mock for the happy path: the repository does not exist yet and gets created. + public void ExpectRepositoryCreated(bool invitationCreated = false) + { + GitHub + .Setup(g => g.GetRepositoryAsync("ahk-org", "viaubc01-hw1-abc123", "gh-token", It.IsAny())) + .ReturnsAsync((GitHubRepository?)null); + + GitHub + .Setup(g => g.GenerateFromTemplateAsync("ahk-org", "viaubc01-hw1", "ahk-org", "viaubc01-hw1-abc123", "gh-token", It.IsAny())) + .ReturnsAsync(new GitHubRepository("ahk-org/viaubc01-hw1-abc123", "https://github.com/ahk-org/viaubc01-hw1-abc123", false, "main")); + + GitHub + .Setup(g => g.EnsureActionsEnabledAsync("ahk-org", "viaubc01-hw1-abc123", "gh-token", It.IsAny())) + .Returns(Task.CompletedTask); + + GitHub + .Setup(g => g.AddCollaboratorAsync("ahk-org", "viaubc01-hw1-abc123", "octocat", "gh-token", It.IsAny())) + .ReturnsAsync(new CollaboratorResult(invitationCreated, invitationCreated ? 99 : null)); + } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + [Fact] + public async Task WithoutNeptunCode_TheFlowStopsAndExplainsWhy() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(neptun: null); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.NeedsNeptun, state.Status); + Assert.Contains("eduID", state.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WithoutGitHubUsername_TheStudentIsAskedForOne() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(gitHubUsername: null); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.NeedsGitHubUsername, state.Status); + + // The repository name is already known, so the screen can tell them what they are about to get. + Assert.Equal("viaubc01-hw1-abc123", state.RepositoryName); + } + + [Fact] + public async Task WithEverythingInPlace_TheStudentIsAskedToConfirm() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.ReadyToAccept, state.Status); + Assert.Equal("ahk-org", state.Organization); + Assert.Equal("viaubc01-hw1-abc123", state.RepositoryName); + } + + [Fact] + public async Task AnArchivedAssignment_TurnsNewStudentsAway() + { + await using var fixture = new Fixture(archived: true); + var user = fixture.AddUser(); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Closed, state.Status); + } + + /// Archiving closes the door to newcomers; it does not take a repository away from someone who has one. + [Fact] + public async Task AnArchivedAssignment_StillShowsTheRepositoryToStudentsWhoAccepted() + { + await using var fixture = new Fixture(archived: true); + var user = fixture.AddUser(); + + fixture.Db.AssignmentAcceptances.Add(new AssignmentAcceptance + { + CourseId = CourseId, + AssignmentId = 10, + UserId = user.Id, + GitHubRepoName = "ahk-org/viaubc01-hw1-abc123", + RepoUrl = "https://github.com/ahk-org/viaubc01-hw1-abc123", + GitHubUsername = "octocat", + }); + await fixture.Db.SaveChangesAsync(); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Accepted, state.Status); + Assert.Equal("https://github.com/ahk-org/viaubc01-hw1-abc123", state.RepoUrl); + } + + [Fact] + public async Task AnUnknownToken_IsNotFound() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + + var state = await fixture.Service.GetStateAsync(CourseId, "no-such-token", user); + + Assert.Equal(InviteStatus.NotFound, state.Status); + } + + [Fact] + public async Task Accepting_CreatesTheRepository_GrantsAccess_AndRecordsThePair() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + fixture.ExpectRepositoryCreated(); + + var state = await fixture.Service.AcceptAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Accepted, state.Status); + Assert.Equal("https://github.com/ahk-org/viaubc01-hw1-abc123", state.RepoUrl); + + fixture.GitHub.Verify( + g => g.GenerateFromTemplateAsync("ahk-org", "viaubc01-hw1", "ahk-org", "viaubc01-hw1-abc123", "gh-token", It.IsAny()), + Times.Once); + fixture.GitHub.Verify( + g => g.AddCollaboratorAsync("ahk-org", "viaubc01-hw1-abc123", "octocat", "gh-token", It.IsAny()), + Times.Once); + + var acceptance = Assert.Single(await fixture.Db.AssignmentAcceptances.IgnoreQueryFilters().ToListAsync()); + Assert.Equal("ahk-org/viaubc01-hw1-abc123", acceptance.GitHubRepoName); + Assert.False(acceptance.InvitationPending); + + // The student is enrolled and the submission exists, so grades and events have somewhere to land before + // the first webhook ever arrives. + Assert.Single(await fixture.Db.Students.IgnoreQueryFilters().Where(s => s.Neptun == "ABC123").ToListAsync()); + Assert.Single(await fixture.Db.Submissions.IgnoreQueryFilters().Where(s => s.GitHubRepoName == "ahk-org/viaubc01-hw1-abc123").ToListAsync()); + } + + /// A student outside the organization is only invited, and the invitation has to be tracked. + [Fact] + public async Task Accepting_RecordsAPendingInvitationWhenGitHubOnlyInvites() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + fixture.ExpectRepositoryCreated(invitationCreated: true); + + var state = await fixture.Service.AcceptAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Accepted, state.Status); + Assert.Equal("https://github.com/ahk-org/viaubc01-hw1-abc123/invitations", state.InvitationUrl); + + var acceptance = Assert.Single(await fixture.Db.AssignmentAcceptances.IgnoreQueryFilters().ToListAsync()); + Assert.True(acceptance.InvitationPending); + Assert.Equal(99, acceptance.InvitationId); + Assert.NotNull(acceptance.InvitationSentAt); + } + + /// + /// The repository may already exist — a re-run, a manual creation, a migrated course. Linking it is right; + /// creating a second one is not, and GitHub would reject it anyway. + /// + [Fact] + public async Task Accepting_LinksAnExistingRepositoryInsteadOfCreatingASecondOne() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + + fixture.GitHub + .Setup(g => g.GetRepositoryAsync("ahk-org", "viaubc01-hw1-abc123", "gh-token", It.IsAny())) + .ReturnsAsync(new GitHubRepository("ahk-org/viaubc01-hw1-abc123", "https://github.com/ahk-org/viaubc01-hw1-abc123", false, "main")); + + fixture.GitHub + .Setup(g => g.AddCollaboratorAsync("ahk-org", "viaubc01-hw1-abc123", "octocat", "gh-token", It.IsAny())) + .ReturnsAsync(new CollaboratorResult(false, null)); + + var state = await fixture.Service.AcceptAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Accepted, state.Status); + fixture.GitHub.Verify( + g => g.GenerateFromTemplateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// Reloading the page, or clicking Accept twice, must not hand out a second repository. + [Fact] + public async Task AcceptingTwice_ReturnsTheSameRepositoryAndTouchesGitHubOnce() + { + await using var fixture = new Fixture(); + var user = fixture.AddUser(); + fixture.ExpectRepositoryCreated(); + + await fixture.Service.AcceptAsync(CourseId, InviteToken, user); + var second = await fixture.Service.AcceptAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.Accepted, second.Status); + Assert.Single(await fixture.Db.AssignmentAcceptances.IgnoreQueryFilters().ToListAsync()); + + fixture.GitHub.Verify( + g => g.GenerateFromTemplateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + /// A course with no organization cannot create anything; say so rather than failing at GitHub. + [Fact] + public async Task ACourseWithoutAnOrganization_ReportsItIsNotConfigured() + { + await using var fixture = new Fixture(organization: null); + var user = fixture.AddUser(); + + var state = await fixture.Service.GetStateAsync(CourseId, InviteToken, user); + + Assert.Equal(InviteStatus.NotConfigured, state.Status); + } + + [Theory] + // No prefix set → falls back to the template repository's own name (the original behaviour). + [InlineData("ahk-org/viaubc01-hw1", null, "ABC123", "viaubc01-hw1-abc123")] + [InlineData("org/hw", null, "xyz789", "hw-xyz789")] + [InlineData("no-owner", null, "ABC123", "no-owner-abc123")] + // Prefix set → it is used instead of the template name, lowercased like every repository name. + [InlineData("ahk-org/viaubc01-hw1", "custom-prefix", "ABC123", "custom-prefix-abc123")] + [InlineData("ahk-org/viaubc01-hw1", "Custom", "xyz789", "custom-xyz789")] + public void RepositoryName_UsesPrefixOrFallsBackToTemplateName_Lowercased(string template, string? prefix, string neptun, string expected) + { + var assignment = new Assignment { TemplateRepoName = template, RepoNamePrefix = prefix }; + Assert.Equal(expected, AssignmentInviteService.BuildRepositoryName(assignment, neptun)); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/AssignmentScopingTests.cs b/ahk-backend/Ahk.Web.Server.Tests/AssignmentScopingTests.cs new file mode 100644 index 0000000..73ce06e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/AssignmentScopingTests.cs @@ -0,0 +1,109 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// The course query filter must cover the assignment entities too. Assignments carry an invite link that +/// provisions repositories, so one course seeing another's would be worse than a data leak — it would let a +/// student of course A accept course B's homework. +/// +public class AssignmentScopingTests +{ + private const int CourseA = 1; + private const int CourseB = 2; + + private sealed class MutableCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId { get; set; } + } + + private static ApplicationDbContext CreateContext(ICurrentCourseProvider provider, string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options, provider); + } + + private static async Task SeedTwoCoursesAsync(string dbName) + { + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = null }, dbName); + + foreach (var (courseId, slug) in new[] { (CourseA, "a"), (CourseB, "b") }) + { + var user = new ApplicationUser { Id = courseId + 100, UserName = $"student-{slug}", NeptunCode = "ABC123" }; + db.Users.Add(user); + + var assignment = new Assignment + { + CourseId = courseId, + Name = $"Homework of {slug}", + TemplateRepoName = $"org/{slug}-hw1", + InviteToken = $"token-{slug}", + }; + db.Assignments.Add(assignment); + await db.SaveChangesAsync(); + + db.AssignmentAcceptances.Add(new AssignmentAcceptance + { + CourseId = courseId, + AssignmentId = assignment.Id, + UserId = user.Id, + GitHubRepoName = $"org/{slug}-hw1-abc123", + RepoUrl = $"https://github.com/org/{slug}-hw1-abc123", + GitHubUsername = "octocat", + }); + await db.SaveChangesAsync(); + } + } + + [Theory] + [InlineData(CourseA, "org/a-hw1")] + [InlineData(CourseB, "org/b-hw1")] + public async Task Assignments_AreFilteredToTheCurrentCourse(int courseId, string expectedTemplate) + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = courseId }, dbName); + + var assignments = await db.Assignments.ToListAsync(); + Assert.Single(assignments); + Assert.Equal(expectedTemplate, assignments[0].TemplateRepoName); + } + + [Fact] + public async Task Acceptances_AreFilteredToTheCurrentCourse() + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = CourseA }, dbName); + + var acceptances = await db.AssignmentAcceptances.ToListAsync(); + Assert.Single(acceptances); + Assert.Equal(CourseA, acceptances[0].CourseId); + } + + /// + /// The student home page and the invite service run without a {course} route segment, so they see nothing + /// unless they bypass the filter. This pins that trap in place rather than leaving it to be rediscovered. + /// + [Fact] + public async Task NoCurrentCourse_HidesAssignmentsUnlessFiltersAreIgnored() + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = null }, dbName); + + Assert.Empty(await db.Assignments.ToListAsync()); + Assert.Empty(await db.AssignmentAcceptances.ToListAsync()); + + Assert.Equal(2, await db.Assignments.IgnoreQueryFilters().CountAsync()); + Assert.Equal(2, await db.AssignmentAcceptances.IgnoreQueryFilters().CountAsync()); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/CourseHealthTests.cs b/ahk-backend/Ahk.Web.Server.Tests/CourseHealthTests.cs new file mode 100644 index 0000000..170ff94 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/CourseHealthTests.cs @@ -0,0 +1,134 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Health; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Covers the course health checks and how their results aggregate. The network-bound GitHub check is not +/// exercised here; the local checks and the aggregation rule are what the admin dashboard's traffic lights +/// are actually derived from. +/// +public class CourseHealthTests +{ + private sealed class NoCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId => null; + } + + private static ApplicationDbContext CreateContext(string dbName) => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options, new NoCourseProvider()); + + private static Course CourseWith(CourseGitHubConfig? config, string? org = "ahk-viaubc01") => + new() { Id = 1, Slug = "viaubc01", Name = "Sample", GitHubOrganization = org, GitHubConfig = config }; + + // ---- Webhook settings ---- + + [Fact] + public async Task WebhookCheck_ReportsNotConfigured_WithoutAnOrganization() + { + var result = await new WebhookConfigurationHealthCheck().RunAsync(CourseWith(new CourseGitHubConfig(), org: null)); + Assert.Equal(HealthStatus.NotConfigured, result.Status); + } + + [Fact] + public async Task WebhookCheck_WarnsWhenTheSignatureCannotBeValidated() + { + var result = await new WebhookConfigurationHealthCheck().RunAsync(CourseWith(new CourseGitHubConfig())); + Assert.Equal(HealthStatus.Warning, result.Status); + Assert.NotNull(result.Remediation); + } + + [Fact] + public async Task WebhookCheck_WarnsWhenTheIntegrationIsSwitchedOff() + { + var config = new CourseGitHubConfig { GitHubWebhookSecret = "s3cret", Enabled = false }; + var result = await new WebhookConfigurationHealthCheck().RunAsync(CourseWith(config)); + Assert.Equal(HealthStatus.Warning, result.Status); + } + + [Fact] + public async Task WebhookCheck_PassesWhenOrganizationAndSecretAreSet() + { + var config = new CourseGitHubConfig { GitHubWebhookSecret = "s3cret", Enabled = true }; + var result = await new WebhookConfigurationHealthCheck().RunAsync(CourseWith(config)); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + // ---- CI callback token ---- + + [Fact] + public async Task TokenCheck_ReportsNotConfigured_WhenNoTokenWasEverIssued() + { + await using var db = CreateContext(Guid.NewGuid().ToString()); + var result = await new CiCallbackTokenHealthCheck(db).RunAsync(CourseWith(new CourseGitHubConfig())); + Assert.Equal(HealthStatus.NotConfigured, result.Status); + } + + [Fact] + public async Task TokenCheck_FailsWhenEveryTokenIsRevoked() + { + var dbName = Guid.NewGuid().ToString(); + await using (var seed = CreateContext(dbName)) + { + seed.CourseWebhookTokens.Add(new CourseWebhookToken + { + CourseId = 1, + Token = "t", + Secret = "s", + RevokedAt = DateTimeOffset.UtcNow, + }); + await seed.SaveChangesAsync(); + } + + await using var db = CreateContext(dbName); + var result = await new CiCallbackTokenHealthCheck(db).RunAsync(CourseWith(new CourseGitHubConfig())); + Assert.Equal(HealthStatus.Failed, result.Status); + } + + /// + /// The check runs in the host/admin context, where no current course is set — so it must bypass the course + /// query filter, or it would report every course as having no token. + /// + [Fact] + public async Task TokenCheck_SeesTokens_EvenWithNoCurrentCourseResolved() + { + var dbName = Guid.NewGuid().ToString(); + await using (var seed = CreateContext(dbName)) + { + seed.CourseWebhookTokens.Add(new CourseWebhookToken { CourseId = 1, Token = "t", Secret = "s" }); + await seed.SaveChangesAsync(); + } + + await using var db = CreateContext(dbName); + var result = await new CiCallbackTokenHealthCheck(db).RunAsync(CourseWith(new CourseGitHubConfig())); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + // ---- Aggregation ---- + + [Theory] + [InlineData(HealthStatus.Healthy, HealthStatus.NotConfigured, HealthStatus.Healthy)] + [InlineData(HealthStatus.Healthy, HealthStatus.Warning, HealthStatus.Warning)] + [InlineData(HealthStatus.Warning, HealthStatus.Failed, HealthStatus.Failed)] + [InlineData(HealthStatus.NotConfigured, HealthStatus.NotConfigured, HealthStatus.NotConfigured)] + public void Report_TakesTheWorstCheckStatus(HealthStatus first, HealthStatus second, HealthStatus expected) + { + var report = new CourseHealthReport + { + Checks = new[] + { + new HealthCheckResult { Status = first }, + new HealthCheckResult { Status = second }, + }, + }; + + Assert.Equal(expected, report.Status); + } + + [Fact] + public void Report_WithNoChecks_IsNotConfigured() => + Assert.Equal(HealthStatus.NotConfigured, new CourseHealthReport().Status); +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/CourseScopingTests.cs b/ahk-backend/Ahk.Web.Server.Tests/CourseScopingTests.cs new file mode 100644 index 0000000..554b6bf --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/CourseScopingTests.cs @@ -0,0 +1,118 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Verifies the EF Core course query filter across the domain entities: a request scoped to one course only +/// sees that course's rows. This is the isolation mechanism the whole portal relies on, so it is exercised +/// directly against the DbContext. +/// +public class CourseScopingTests +{ + private sealed class MutableCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId { get; set; } + } + + private static ApplicationDbContext CreateContext(ICurrentCourseProvider provider, string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options, provider); + } + + private const int CourseA = 1; + private const int CourseB = 2; + + /// Seeds one submission with an event and a grade for each of two courses. + private static async Task SeedTwoCoursesAsync(string dbName) + { + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = null }, dbName); + + foreach (var (courseId, repo, neptun) in new[] { (CourseA, "org/a-hw1-abc123", "ABC123"), (CourseB, "org/b-hw1-xyz789", "XYZ789") }) + { + var student = new Student { CourseId = courseId, Neptun = neptun }; + db.Students.Add(student); + await db.SaveChangesAsync(); + + var submission = new Submission { CourseId = courseId, StudentId = student.Id, GitHubRepoName = repo }; + db.Submissions.Add(submission); + await db.SaveChangesAsync(); + + db.SubmissionEvents.Add(new BranchCreatedEvent + { + CourseId = courseId, + SubmissionId = submission.Id, + Timestamp = DateTimeOffset.UtcNow, + Branch = "solution", + }); + + var grade = new GradeRecord + { + CourseId = courseId, + SubmissionId = submission.Id, + StudentId = student.Id, + Neptun = neptun, + Date = DateTimeOffset.UtcNow, + Confirmed = true, + }; + grade.Points.Add(new GradeExercisePoint { Name = "ex0", Point = 1, Order = 0 }); + db.GradeRecords.Add(grade); + + await db.SaveChangesAsync(); + } + } + + [Theory] + [InlineData(CourseA, "org/a-hw1-abc123")] + [InlineData(CourseB, "org/b-hw1-xyz789")] + public async Task Submissions_AreFilteredToTheCurrentCourse(int courseId, string expectedRepo) + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = courseId }, dbName); + + var submissions = await db.Submissions.ToListAsync(); + Assert.Single(submissions); + Assert.Equal(expectedRepo, submissions[0].GitHubRepoName); + } + + [Fact] + public async Task Students_Events_AndGrades_AreFilteredToTheCurrentCourse() + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = CourseA }, dbName); + + Assert.All(await db.Students.ToListAsync(), s => Assert.Equal(CourseA, s.CourseId)); + Assert.All(await db.SubmissionEvents.ToListAsync(), e => Assert.Equal(CourseA, e.CourseId)); + Assert.All(await db.GradeRecords.ToListAsync(), g => Assert.Equal(CourseA, g.CourseId)); + + Assert.Single(await db.GradeRecords.ToListAsync()); + } + + /// + /// Guards the trap called out in the plan: with no course resolved (machine-to-machine paths, the importer) + /// the filter matches nothing, so such callers must set a provider or use IgnoreQueryFilters. + /// + [Fact] + public async Task NoCurrentCourse_HidesAllCourseScopedRows() + { + var dbName = Guid.NewGuid().ToString(); + await SeedTwoCoursesAsync(dbName); + + await using var db = CreateContext(new MutableCourseProvider { CurrentCourseId = null }, dbName); + + Assert.Empty(await db.Submissions.ToListAsync()); + Assert.Empty(await db.GradeRecords.ToListAsync()); + + // ...but the rows are there when the filter is bypassed. + Assert.Equal(2, await db.Submissions.IgnoreQueryFilters().CountAsync()); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/ExternalClaimsMapperTests.cs b/ahk-backend/Ahk.Web.Server.Tests/ExternalClaimsMapperTests.cs new file mode 100644 index 0000000..2a6c6fa --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/ExternalClaimsMapperTests.cs @@ -0,0 +1,118 @@ +using System.Security.Claims; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Auth; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Covers projecting a BME OIDC identity onto the local user: the claim names, the multi-valued affiliation, +/// and the rule that absent claims must never wipe values already stored. +/// +public class ExternalClaimsMapperTests +{ + private static ClaimsPrincipal Principal(params Claim[] claims) + => new(new ClaimsIdentity(claims, authenticationType: "oidc")); + + [Fact] + public void SyncFromClaims_MapsAllBmeClaims() + { + var user = new ApplicationUser(); + var principal = Principal( + new Claim(ClaimTypes.Email, "teacher@bme.hu"), + new Claim(ClaimTypes.Name, "Teszt Tanár"), + new Claim(BmeClaimTypes.NeptunCode, "teach1"), + new Claim(BmeClaimTypes.Affiliation, "staff@bme.hu;employee@bme.hu")); + + var changed = ExternalClaimsMapper.SyncFromClaims(user, principal); + + Assert.True(changed); + Assert.Equal("teacher@bme.hu", user.Email); + Assert.Equal("Teszt Tanár", user.DisplayName); + Assert.Equal("TEACH1", user.NeptunCode); // normalized to upper, like the domain model + Assert.Equal("staff@bme.hu;employee@bme.hu", user.Affiliation); + } + + [Fact] + public void SyncFromClaims_ComposesDisplayNameFromGivenAndFamilyName() + { + var user = new ApplicationUser(); + var principal = Principal( + new Claim(ClaimTypes.GivenName, "Teszt"), + new Claim(ClaimTypes.Surname, "Tanár")); + + ExternalClaimsMapper.SyncFromClaims(user, principal); + + Assert.Equal("Teszt Tanár", user.DisplayName); + } + + [Fact] + public void SyncFromClaims_JoinsRepeatedAffiliationClaims() + { + var principal = Principal( + new Claim(BmeClaimTypes.Affiliation, "staff@bme.hu"), + new Claim(BmeClaimTypes.Affiliation, "employee@bme.hu"), + new Claim(BmeClaimTypes.Affiliation, "staff@bme.hu")); // duplicate collapses + + Assert.Equal("staff@bme.hu;employee@bme.hu", ExternalClaimsMapper.ResolveAffiliation(principal)); + } + + [Fact] + public void SyncFromClaims_SparseIdentity_LeavesOptionalFieldsNull() + { + var user = new ApplicationUser(); + var principal = Principal(new Claim(ClaimTypes.Email, "sparse@bme.hu")); + + ExternalClaimsMapper.SyncFromClaims(user, principal); + + Assert.Equal("sparse@bme.hu", user.Email); + Assert.Null(user.NeptunCode); + Assert.Null(user.Affiliation); + } + + [Fact] + public void SyncFromClaims_MissingClaims_DoNotWipeStoredValues() + { + var user = new ApplicationUser + { + Email = "teacher@bme.hu", + DisplayName = "Teszt Tanár", + NeptunCode = "TEACH1", + Affiliation = "staff@bme.hu", + }; + + // A later login where the directory returned nothing extra. + var changed = ExternalClaimsMapper.SyncFromClaims(user, Principal()); + + Assert.False(changed); + Assert.Equal("TEACH1", user.NeptunCode); + Assert.Equal("staff@bme.hu", user.Affiliation); + } + + [Fact] + public void SyncFromClaims_ReportsNoChange_WhenValuesAlreadyMatch() + { + var user = new ApplicationUser { Email = "teacher@bme.hu", DisplayName = "Teszt Tanár", NeptunCode = "TEACH1" }; + var principal = Principal( + new Claim(ClaimTypes.Email, "teacher@bme.hu"), + new Claim(ClaimTypes.Name, "Teszt Tanár"), + new Claim(BmeClaimTypes.NeptunCode, "TEACH1")); + + Assert.False(ExternalClaimsMapper.SyncFromClaims(user, principal)); + } + + [Fact] + public void SyncFromClaims_UpdatesChangedDirectoryData() + { + var user = new ApplicationUser { Email = "old@bme.hu", DisplayName = "Old Name", NeptunCode = "OLD123" }; + var principal = Principal( + new Claim(ClaimTypes.Email, "new@bme.hu"), + new Claim(ClaimTypes.Name, "New Name"), + new Claim(BmeClaimTypes.NeptunCode, "NEW456")); + + Assert.True(ExternalClaimsMapper.SyncFromClaims(user, principal)); + Assert.Equal("new@bme.hu", user.Email); + Assert.Equal("New Name", user.DisplayName); + Assert.Equal("NEW456", user.NeptunCode); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/EvaluationResultEndpointTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/EvaluationResultEndpointTests.cs new file mode 100644 index 0000000..46ff35e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/EvaluationResultEndpointTests.cs @@ -0,0 +1,297 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Grading; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// The CI callback, end to end. The client on the other side of this contract is a Go binary already running +/// inside student repositories that are updated on their own schedule, so both the accepted request and every +/// rejection message are fixed by compatibility rather than by taste. +/// +public class EvaluationResultEndpointTests : IClassFixture +{ + private const string Url = "/api/integrations/evaluation-result"; + private const string Token = "ci-token-value"; + private const string Secret = "ci-secret-value"; + private const string RevokedToken = "revoked-token-value"; + + /// Exactly what publish-results-pr emits, imageFiles included. + private const string SampleBody = + """{"gitHubRepoName":"bmeaut/viaubc01-abc123","gitHubBranch":"refs/pull/12/merge","gitHubPullRequestNum":12,"gitHubCommitHash":"aa11cc33","neptunCode":"abc123","imageFiles":[],"result":[{"exerciseName":"ex1","taskName":"t1","points":2,"comment":"ok"},{"exerciseName":"ex1","taskName":"t2","points":3},{"exerciseName":"ex2","taskName":"t3","points":1}],"origin":"https://github.com/bmeaut/viaubc01-abc123/commit/aa11cc33"}"""; + + private readonly CallbackAppFactory factory; + + public EvaluationResultEndpointTests(CallbackAppFactory factory) => this.factory = factory; + + [Fact] + public async Task ValidRequest_RecordsAnUnconfirmedGrade() + { + var response = await PostAsync(SampleBody); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var grade = await db.GradeRecords.IgnoreQueryFilters() + .Include(g => g.Points) + .Include(g => g.Submission) + .OrderByDescending(g => g.Id) + .FirstAsync(); + + Assert.False(grade.Confirmed); + Assert.Equal(GradeService.AutomatedActor, grade.Actor); + Assert.Equal("ABC123", grade.Neptun); + Assert.Equal(12, grade.PrNumber); + Assert.Equal("bmeaut/viaubc01-abc123", grade.Submission!.GitHubRepoName); + + // The Date header is the grade's timestamp, not the moment the server happened to handle it. + Assert.Equal(factory.Now.UtcDateTime, grade.Date.UtcDateTime, TimeSpan.FromSeconds(1)); + + // Per-task detail is discarded; points are summed per exercise and ordered by name. + Assert.Equal(new[] { ("ex1", 5d), ("ex2", 1d) }, grade.Points.OrderBy(p => p.Order).Select(p => (p.Name, p.Point))); + } + + /// + /// The Go client sends imageFiles, which has never had a counterpart here. Tolerating unknown + /// members is a compatibility requirement, not laxness — a strict deserializer would fail every student + /// build at once. + /// + [Fact] + public async Task UnknownMembersAreIgnored() + { + var body = """{"gitHubRepoName":"bmeaut/viaubc01-xyz999","neptunCode":"xyz999","imageFiles":["a.png"],"somethingBrandNew":{"nested":true},"result":[]}"""; + + var response = await PostAsync(body); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task MissingDateHeader_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, date: null), "Date header missing"); + + [Fact] + public async Task UnparseableDateHeader_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, date: "notadate"), "Date header value not valid RFC1123 string"); + + [Theory] + [InlineData(-25)] + [InlineData(11)] + public async Task DateOutsideSkewWindow_Returns400(int minutes) + { + var skewed = factory.Now.AddMinutes(minutes).UtcDateTime.ToString("R", CultureInfo.InvariantCulture); + + await AssertBadRequestAsync( + await PostAsync(SampleBody, date: skewed), "Date header value is not close enough to current date"); + } + + /// Ten minutes either way is inside the window; the boundary is where clock drift actually lives. + [Theory] + [InlineData(-9)] + [InlineData(9)] + public async Task DateInsideSkewWindow_IsAccepted(int minutes) + { + var skewed = factory.Now.AddMinutes(minutes).UtcDateTime; + var response = await PostAsync(SampleBody, date: skewed.ToString("R", CultureInfo.InvariantCulture)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task MissingSignatureHeader_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, signature: null), "X-Ahk-Sha256 header missing"); + + [Fact] + public async Task MissingTokenHeader_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, token: null), "X-Ahk-Token header missing"); + + [Fact] + public async Task UnknownToken_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, token: "no-such-token"), "X-Ahk-Token invalid"); + + /// + /// Revoking must take effect immediately. The secret lookup is cached for an hour, so this is really a test + /// that revocation evicts the cache rather than waiting it out. + /// + [Fact] + public async Task RevokedToken_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, token: RevokedToken), "X-Ahk-Token invalid"); + + [Fact] + public async Task WrongSignature_Returns400() => + await AssertBadRequestAsync(await PostAsync(SampleBody, signature: "notavalidsignature="), "X-Ahk-Sha256 signature not valid"); + + /// + /// The signature covers the URL, so a request signed for a different address must fail. This is the + /// failure mode behind a misconfigured AHK_APPURL, and the reason the documented value is byte-exact. + /// + [Fact] + public async Task SignatureOverADifferentUrl_Returns400() + { + var date = factory.Now.UtcDateTime; + var wrongUrlSignature = Sign("POST", "https://ahk.aut.bme.hu/api/evaluation-result", date, SampleBody, Secret); + + await AssertBadRequestAsync(await PostAsync(SampleBody, signature: wrongUrlSignature), "X-Ahk-Sha256 signature not valid"); + } + + [Fact] + public async Task BodyThatIsNotJson_Returns400() => + await AssertBadRequestAsync(await PostAsync("notjson"), "Body cannot be deserialized as JSON"); + + /// The two top-level fields the legacy DTO actually enforced. + [Theory] + [InlineData("""{"neptunCode":"abc123","result":[]}""")] + [InlineData("""{"gitHubRepoName":"bmeaut/viaubc01-abc123","result":[]}""")] + public async Task BodyMissingRequiredFields_Returns400(string body) => + await AssertBadRequestAsync(await PostAsync(body), "Body cannot be deserialized as JSON"); + + /// Authentication here is the signature alone; a 401 would mean a fallback policy had crept in. + [Fact] + public async Task WithoutCredentials_IsNotUnauthorized() + { + var client = factory.CreateClient(); + using var content = new StringContent(SampleBody, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(Url, content); + + Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static async Task AssertBadRequestAsync(HttpResponseMessage response, string expectedError) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains(expectedError, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// + /// Reimplements the Go client's scheme rather than calling the validator, so these tests cannot pass by + /// agreeing with a mistake in the code under test. + /// + private static string Sign(string verb, string url, DateTime date, string body, string secret) + { + var stringToSign = string.Concat( + verb.ToUpperInvariant(), + "\n", + url.ToLowerInvariant(), + "\n", + date.ToString("R", CultureInfo.InvariantCulture), + "\n", + body); + + using var hmac = new HMACSHA256(Encoding.ASCII.GetBytes(secret)); + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign))); + } + + private async Task PostAsync( + string body, string? token = Token, string? signature = "", string? date = "") + { + var client = factory.CreateClient(); + + // TestServer serves over http; UseHttpsRedirection no-ops without a configured https port, which is + // what the existing smoke tests already rely on. + var absoluteUrl = new Uri(client.BaseAddress!, Url).ToString(); + var dateValue = date is null ? null : (date.Length == 0 ? factory.Now.UtcDateTime.ToString("R", CultureInfo.InvariantCulture) : date); + + using var request = new HttpRequestMessage(HttpMethod.Post, Url) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + if (token is not null) + request.Headers.Add("X-Ahk-Token", token); + + // Without validation: HttpClient parses Date itself and refuses to send a malformed one, which would + // make the "not valid RFC1123" branch unreachable from a test. Over the wire nothing stops a caller + // (or a proxy) from sending exactly that. + if (dateValue is not null) + request.Headers.TryAddWithoutValidation("Date", dateValue); + + if (signature is not null) + { + // When the date is missing or malformed the request is rejected before the signature is ever + // checked, so signing over the frozen clock keeps the helper total. + var signedDate = dateValue is not null + && DateTime.TryParseExact(dateValue, "R", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed) + ? parsed + : factory.Now.UtcDateTime; + + request.Headers.Add("X-Ahk-Sha256", signature.Length == 0 ? Sign("POST", absoluteUrl, signedDate, body, Secret) : signature); + } + + request.Headers.Add("X-Ahk-Delivery", "delivery-1"); + + return await client.SendAsync(request); + } + + public sealed class CallbackAppFactory : WebApplicationFactory + { + /// Frozen, so the ten-minute skew window can be tested from both sides. + public DateTimeOffset Now { get; } = new(2026, 8, 9, 12, 0, 0, TimeSpan.Zero); + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureServices(services => + { + var toRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ApplicationDbContext) || + d.ServiceType == typeof(TimeProvider) || + (d.ServiceType.IsGenericType && d.ServiceType.Name.StartsWith("IDbContextOptionsConfiguration", StringComparison.Ordinal))) + .ToList(); + foreach (var descriptor in toRemove) + services.Remove(descriptor); + + services.AddDbContext(options => options.UseInMemoryDatabase("EvaluationResultEndpointTests")); + services.AddSingleton(new FakeTimeProvider(Now)); + }); + + var host = base.CreateHost(builder); + + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Courses.IgnoreQueryFilters().Any()) + { + var course = new Course + { + Slug = "viaubc01", + Name = "Sample Course", + GitHubOrganization = "bmeaut", + RepoNamePrefix = "viaubc01", + }; + db.Courses.Add(course); + db.SaveChanges(); + + db.CourseWebhookTokens.AddRange( + new CourseWebhookToken { CourseId = course.Id, Token = Token, Secret = Secret, Description = "active" }, + new CourseWebhookToken + { + CourseId = course.Id, + Token = RevokedToken, + Secret = Secret, + Description = "revoked", + RevokedAt = DateTimeOffset.UtcNow, + }); + db.SaveChanges(); + } + } + + return host; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/GitHubWebhookEndpointTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/GitHubWebhookEndpointTests.cs new file mode 100644 index 0000000..0d4a79b --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/GitHubWebhookEndpointTests.cs @@ -0,0 +1,299 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; +using Ahk.Web.Services.GitHubWebhooks; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; +using Octokit; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// The webhook endpoint's response contract, end to end through the real pipeline. +/// +/// Every status here is read by a human in the GitHub App's Advanced → Recent Deliveries tab, so each +/// one is part of the diagnostic surface rather than an implementation detail — which is why they are pinned +/// individually, error strings included. +/// +public class GitHubWebhookEndpointTests : IClassFixture +{ + private const string Secret = "dev-webhook-secret"; + private const string Url = "/api/integrations/github"; + + private readonly WebhookAppFactory factory; + + public GitHubWebhookEndpointTests(WebhookAppFactory factory) => this.factory = factory; + + [Fact] + public async Task MissingEventHeader_Returns400() + { + var response = await PostAsync(Body("bmeaut/viaubc01-abc123"), eventName: null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("X-GitHub-Event header missing", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + [Fact] + public async Task MissingSignatureHeader_Returns400() + { + var response = await PostAsync(Body("bmeaut/viaubc01-abc123"), signature: null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("X-Hub-Signature-256 header missing", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// The pre-parse must reject anything it cannot route, without ever throwing on hostile input. + [Theory] + [InlineData("not json at all")] + [InlineData("{}")] + [InlineData("{\"repository\":null}")] + [InlineData("{\"repository\":{}}")] + [InlineData("[]")] + public async Task BodyWithoutRepositoryName_Returns400(string body) + { + var response = await PostAsync(body); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("no repository information in webhook payload", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// + /// 202, not 4xx. An organization contains repositories that are not a course, and a delivery log full of + /// red teaches administrators to ignore it. + /// + [Fact] + public async Task RepositoryInNoCourse_Returns202() + { + var response = await PostAsync(Body("someoneelse/unrelated-repo")); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Contains("not mapped to a course", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + [Fact] + public async Task CourseWithIntegrationTurnedOff_Returns202() + { + var response = await PostAsync(Body("bmeaut/paused-abc123")); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Contains("turned off", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// + /// A course with no secret is a misconfiguration an administrator has to fix, not a delivery to shrug off — + /// so it keeps the legacy 500 rather than joining the 202 cases. + /// + [Fact] + public async Task CourseWithoutWebhookSecret_Returns500() + { + var response = await PostAsync(Body("bmeaut/nosecret-abc123")); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Contains("GitHub secret not configured", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + [Fact] + public async Task WrongSignature_Returns400() + { + var response = await PostAsync(Body("bmeaut/viaubc01-abc123"), signature: "sha256=deadbeef"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Payload signature not valid", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// + /// A body signed with a *different* course's secret must be indistinguishable from any other bad + /// signature. This is what contains the parse-before-verify ordering: guessing a repository name buys an + /// attacker nothing. + /// + [Fact] + public async Task BodySignedWithAnotherCoursesSecret_Returns400() + { + var body = Body("bmeaut/viaubc01-abc123"); + var response = await PostAsync(body, signature: Sign(body, "a-different-courses-secret")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Payload signature not valid", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// A correctly signed delivery for an event nobody handles is a 200 that says so. + [Fact] + public async Task ValidDeliveryForUnhandledEvent_Returns200WithResult() + { + var response = await PostAsync(Body("bmeaut/viaubc01-abc123"), eventName: "ping"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Event ping is not of interest", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + + /// + /// The dispatcher must receive the resolved course and the per-course run threshold — the two things the + /// port had to add, and the two a handler cannot obtain for itself. + /// + [Fact] + public async Task ValidDelivery_PassesCourseAndThresholdToDispatcher() + { + factory.Dispatcher.Reset(); + + var response = await PostAsync(Body("bmeaut/viaubc01-abc123"), eventName: "pull_request", deliveryId: "delivery-42"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var context = Assert.Single(factory.Dispatcher.Seen); + Assert.Equal("pull_request", context.GitHubEventName); + Assert.Equal("delivery-42", context.DeliveryId); + Assert.Equal(7, context.WorkflowRunThreshold); + Assert.NotEqual(0, context.CourseId); + } + + /// + /// The endpoint is anonymous by design — the HMAC is the authentication. A 401 here would mean a fallback + /// authorization policy had crept into Program.cs and silently broken every delivery. + /// + [Fact] + public async Task WithoutCredentials_IsNotUnauthorized() + { + var response = await PostAsync("not json at all"); + + Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static string Body(string repositoryFullName) + => $"{{\"repository\":{{\"full_name\":\"{repositoryFullName}\"}}}}"; + + /// + /// Signs the way GitHub does, implemented here rather than reused from the validator so the endpoint tests + /// do not merely agree with the code they are testing. + /// + private static string Sign(string body, string secret) + { + using var hmac = new HMACSHA256(Encoding.ASCII.GetBytes(secret)); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); + return "sha256=" + Convert.ToHexString(hash).ToLower(CultureInfo.InvariantCulture); + } + + private async Task PostAsync( + string body, string? eventName = "push", string? signature = "", string? deliveryId = "delivery-1") + { + var client = factory.CreateClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, Url) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + + if (eventName is not null) + request.Headers.Add("X-GitHub-Event", eventName); + + if (deliveryId is not null) + request.Headers.Add("X-GitHub-Delivery", deliveryId); + + // The empty-string default means "sign it correctly"; null means "omit the header entirely". + if (signature is not null) + request.Headers.Add("X-Hub-Signature-256", signature.Length == 0 ? Sign(body, Secret) : signature); + + return await client.SendAsync(request); + } + + /// Records what the endpoint handed it, so the handlers themselves stay out of these tests. + public sealed class RecordingDispatcher : IGitHubWebhookDispatcher + { + public List Seen { get; } = new(); + + public void Reset() => Seen.Clear(); + + public Task ProcessAsync(GitHubWebhookContext context, WebhookResult result, CancellationToken cancellationToken = default) + { + Seen.Add(context); + result.LogInfo($"Event {context.GitHubEventName} is not of interest"); + return Task.CompletedTask; + } + } + + public sealed class WebhookAppFactory : WebApplicationFactory + { + public RecordingDispatcher Dispatcher { get; } = new(); + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureServices(services => + { + var toRemove = services.Where(d => + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(ApplicationDbContext) || + d.ServiceType == typeof(IGitHubWebhookDispatcher) || + d.ServiceType == typeof(ICourseGitHubAppTokenProvider) || + d.ServiceType == typeof(ICourseGitHubClientFactory) || + (d.ServiceType.IsGenericType && d.ServiceType.Name.StartsWith("IDbContextOptionsConfiguration", StringComparison.Ordinal))) + .ToList(); + foreach (var descriptor in toRemove) + services.Remove(descriptor); + + services.AddDbContext(options => options.UseInMemoryDatabase("GitHubWebhookEndpointTests")); + services.AddSingleton(Dispatcher); + + // Nothing may reach api.github.com from a test. + var tokenProvider = new Mock(); + tokenProvider + .Setup(p => p.GetForCourseAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitHubInstallationToken("installation-token", 1, new Dictionary(), "all")); + services.AddSingleton(tokenProvider.Object); + + var clientFactory = new Mock(); + clientFactory.Setup(f => f.CreateForToken(It.IsAny())).Returns(Mock.Of()); + services.AddSingleton(clientFactory.Object); + }); + + var host = base.CreateHost(builder); + + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (!db.Courses.IgnoreQueryFilters().Any()) + { + // Three courses in one organization, so repo-prefix resolution is exercised rather than + // assumed, and each of the configuration failure modes has somewhere to happen. + db.Courses.AddRange( + new Course + { + Slug = "viaubc01", + Name = "Healthy course", + GitHubOrganization = "bmeaut", + RepoNamePrefix = "viaubc01", + GitHubConfig = new CourseGitHubConfig { GitHubWebhookSecret = Secret, WorkflowRunThreshold = 7, Enabled = true }, + }, + new Course + { + Slug = "paused", + Name = "Integration turned off", + GitHubOrganization = "bmeaut", + RepoNamePrefix = "paused", + GitHubConfig = new CourseGitHubConfig { GitHubWebhookSecret = Secret, Enabled = false }, + }, + new Course + { + Slug = "nosecret", + Name = "No webhook secret stored", + GitHubOrganization = "bmeaut", + RepoNamePrefix = "nosecret", + GitHubConfig = new CourseGitHubConfig { GitHubWebhookSecret = null, Enabled = true }, + }); + db.SaveChanges(); + } + } + + return host; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/ParserParityTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/ParserParityTests.cs new file mode 100644 index 0000000..79665da --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/ParserParityTests.cs @@ -0,0 +1,108 @@ +using Ahk.Web.Services.GitHubWebhooks; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// The .github/ahk-monitor.yml opt-in gate. Cases ported verbatim from +/// github-monitor/.../UnitTests/ConfigYamlParserTest.cs — existing course templates carry these exact +/// spellings, so the accepted set is a compatibility contract, not a style choice. +/// +public class ConfigYamlParserTests +{ + [Theory] + [InlineData("enabled")] + [InlineData("enabled: true")] + [InlineData("enabled: yes")] + [InlineData("enabled: 1")] + [InlineData("enabled: true\r")] + [InlineData("enabled: true\n")] + [InlineData("enabled: true\r\n")] + [InlineData("enabled: true\r\naaa: 1")] + [InlineData("aaa: 1\r\nenabled: true")] + public void ConfigYamlIsEnabled(string value) => Assert.True(ConfigYamlParser.IsEnabled(value)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("aaa")] + [InlineData("enabl")] + [InlineData("enabled: false")] + [InlineData("enabled: no")] + [InlineData("enabled: 0")] + [InlineData("enabled: maybe")] + [InlineData("enabled hello")] + public void ConfigYamlIsDisabled(string? value) => Assert.False(ConfigYamlParser.IsEnabled(value)); +} + +/// +/// The /ahk ok chatops grammar. Cases ported verbatim from +/// github-monitor/.../UnitTests/GradeCommentParserTest.cs: teachers have this syntax in their fingers +/// and any narrowing of it silently stops grading a pull request. +/// +public class GradeCommentParserTests +{ + [Theory] + [InlineData("/ahk ok")] + [InlineData("/ahk ok hello")] + [InlineData("/ahk ok 1")] + [InlineData("/ahk ok 1,2")] + [InlineData("/ahk ok 1, 2")] + [InlineData("/ahk ok 1, 2.5")] + [InlineData("/ahk ok 1.33, 2.5, 44")] + [InlineData("/ahk ok 1.33;2.5;44")] + [InlineData("/ahk ok 1,33 2,5 44")] + [InlineData("something\r\n\r\n/ahk ok")] + [InlineData("something\r\n\r\n/ahk ok 1.33, 2.5, 44")] + [InlineData("/ahk ok\r\n\r\nsomething")] + [InlineData("/ahk ok 1.33, 2.5, 44\r\n\r\nsomething")] + [InlineData("/AHK OK 5")] + public void IsGradeComment(string value) => Assert.True(new GradeCommentParser(value).IsMatch); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("aaa")] + [InlineData("@ok")] + [InlineData("/ahkok")] + [InlineData("/ahk okk")] + [InlineData("ahk ok")] + [InlineData("something\r\n\r\n/ahkok")] + [InlineData("something\r\n\r\n/ahk okk")] + [InlineData("ahk ok\r\n\r\nsomething")] + public void IsNotGradeComment(string? value) => Assert.False(new GradeCommentParser(value).IsMatch); + + [Theory] + [InlineData("/ahk ok", new double[0])] + [InlineData("/ahk ok hello", new double[0])] + [InlineData("/ahk ok 1", new[] { 1d })] + [InlineData("/ahk ok 1,2", new[] { 1.2 })] + [InlineData("/ahk ok 1, 2", new[] { 1d, 2d })] + [InlineData("/ahk ok 1,2 2", new[] { 1.2, 2d })] + [InlineData("/ahk ok 1, 2.5", new[] { 1d, 2.5 })] + [InlineData("/ahk ok 1.33, 2.5, 44", new[] { 1.33, 2.5, 44d })] + [InlineData("/ahk ok 1.33;2.5;44", new[] { 1.33, 2.5, 44d })] + [InlineData("/ahk ok 1,33 2,5 44", new[] { 1.33, 2.5, 44d })] + [InlineData("something\r\n\r\n/ahk ok 1, 2.5", new[] { 1d, 2.5 })] + [InlineData("/ahk ok 1.33, 2.5, 44\r\n\r\nsomething", new[] { 1.33, 2.5, 44d })] + public void GradesAreParsed(string value, double[] expectedGrades) + { + var parsed = new GradeCommentParser(value); + + Assert.Equal(expectedGrades.Length > 0, parsed.HasGrades); + Assert.Equal(expectedGrades, parsed.Grades); + } + + /// + /// The parse loop deliberately does not stop at the first match, so a comment that corrects itself grades + /// with the correction. Looks like a bug, is behaviour — pinned so nobody "fixes" it. + /// + [Fact] + public void LastMatchingLineWins() + { + var parsed = new GradeCommentParser("/ahk ok 1 2\r\nsorry, I meant:\r\n/ahk ok 3 4"); + + Assert.True(parsed.IsMatch); + Assert.Equal(new[] { 3d, 4d }, parsed.Grades); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/SignatureParityTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/SignatureParityTests.cs new file mode 100644 index 0000000..646556d --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/SignatureParityTests.cs @@ -0,0 +1,100 @@ +using Ahk.Web.Services.GitHubWebhooks; +using Ahk.Web.Services.Integrations; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// GitHub's X-Hub-Signature-256 scheme. Vectors ported verbatim from +/// github-monitor/.../UnitTests/GitHubSignatureValidatorTest.cs: reproducing them is what proves the +/// portal accepts exactly the deliveries the Azure Function did. +/// +public class GitHubSignatureValidatorTests +{ + private const string Secret = "Wcks02cnncc67c33"; + + [Theory] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "sha256=3926a12bd47c5e3fe91cb2e6dd0c605438ac469c4de09e560b97029a3f751a88")] + [InlineData("qqqq\r\nsdfsdfsdfsdf\r\nwwwwwwwwwwwww\r\n", "sha256=0d5a916d47e3a2d6ebaa1ca9fafb425e122f892edb8464496a2c8107169ba828")] + [InlineData("aaaaaaqqqqqqqqqqqqqqq", "sha256=9abd46d0b161c9b171c36c6e2b88fd27d498ee08555cb4f34d39ddb2467273fe")] + public void SignatureIsValid(string payload, string expectedSignature) + => Assert.True(GitHubSignatureValidator.IsSignatureValid(payload, expectedSignature, Secret)); + + [Theory] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "sha1=dummy")] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "sha1=aaaaaaaa")] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "dummy")] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "")] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", null)] + public void SignatureIsNotValid(string payload, string? receivedSignature) + => Assert.False(GitHubSignatureValidator.IsSignatureValid(payload, receivedSignature, Secret)); + + /// A course with no secret stored must never accidentally validate. + [Fact] + public void MissingSecretNeverValidates() + => Assert.False(GitHubSignatureValidator.IsSignatureValid( + "aaaaaa", "sha256=3926a12bd47c5e3fe91cb2e6dd0c605438ac469c4de09e560b97029a3f751a88", secret: null)); +} + +/// +/// The CI callback's HMAC scheme. +/// +/// These three vectors exist identically in both +/// grade-management/Ahk.GradeManagement.Tests/UnitTests/HmacSha256ValidatorTest.cs and the Go client's +/// publish-results-pr/internal/publishtoapi/hmacsignature_test.go. Reproducing them here is the proof +/// that the portal is wire-compatible with the evaluator container already running in student repositories — +/// which cannot be redeployed on our schedule. +/// +public class HmacSha256ValidatorTests +{ + private const string Secret = "Wcks02cnncc67c33"; + private const string HttpVerb = "POST"; + private const string HttpUrl = "https://my.url.com/address"; + + private static readonly DateTime Date = new(2021, 9, 1, 13, 34, 56, DateTimeKind.Utc); + + [Theory] + [InlineData("aaaaaa\r\nbbbbbbb\r\ncccccccccc\r\n", "SGAhL9hfzLqi30G1uqtQyErRC4oKBlxT9NImaJ/V9CQ=")] + [InlineData("qqqq\r\nsdfsdfsdfsdf\r\nwwwwwwwwwwwww\r\n", "K7lZXguubpUONKhHh40lAzxt2vPyZnm6LkjLhrYPwAo=")] + [InlineData("aaaaaaqqqqqqqqqqqqqqq", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=")] + public void SignatureIsValid(string payload, string expectedSignature) + => Assert.True(HmacSha256Validator.IsSignatureValid(HttpVerb, HttpUrl, Date, payload, expectedSignature, Secret)); + + [Fact] + public void SignatureIsNotValidWhenVerbDiffers() + => Assert.False(HmacSha256Validator.IsSignatureValid( + "PUT", HttpUrl, Date, "aaaaaaqqqqqqqqqqqqqqq", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=", Secret)); + + [Fact] + public void SignatureIsNotValidWhenUrlDiffers() + => Assert.False(HmacSha256Validator.IsSignatureValid( + HttpVerb, "https://my.url.com/other", Date, "aaaaaaqqqqqqqqqqqqqqq", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=", Secret)); + + [Fact] + public void SignatureIsNotValidWhenDateDiffers() + => Assert.False(HmacSha256Validator.IsSignatureValid( + HttpVerb, HttpUrl, Date.AddSeconds(1), "aaaaaaqqqqqqqqqqqqqqq", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=", Secret)); + + [Fact] + public void SignatureIsNotValidWhenPayloadDiffers() + => Assert.False(HmacSha256Validator.IsSignatureValid( + HttpVerb, HttpUrl, Date, "aaaaaaqqqqqqqqqqqqqqqa", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=", Secret)); + + /// + /// Both sides lower-case the URL before signing, so a differently cased AHK_APPURL still verifies. + /// The only forgiving part of an otherwise byte-exact contract, and worth knowing when diagnosing one. + /// + [Fact] + public void UrlCasingDoesNotMatter() + => Assert.True(HmacSha256Validator.IsSignatureValid( + HttpVerb, "https://MY.URL.com/Address", Date, "aaaaaaqqqqqqqqqqqqqqq", "cN9KEIb9uO7VskC9mmZ7wWkzqOXirFXcjqB3i4cK0mA=", Secret)); + + /// Four parts, single LF separators, no trailing newline. + [Fact] + public void StringToSignHasTheDocumentedShape() + { + var stringToSign = HmacSha256Validator.GetStringToSign("post", "HTTPS://My.Url.Com/Address", Date, "body"); + + Assert.Equal("POST\nhttps://my.url.com/address\nWed, 01 Sep 2021 13:34:56 GMT\nbody", stringToSign); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookHandlerRegistrationTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookHandlerRegistrationTests.cs new file mode 100644 index 0000000..3eb4ab5 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookHandlerRegistrationTests.cs @@ -0,0 +1,113 @@ +using Ahk.Web.Services; +using Ahk.Web.Services.GitHubWebhooks; +using Ahk.Web.Services.GitHubWebhooks.Handlers; +using Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; +using Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// Guards the shape of the handler set itself, without running a delivery. +/// +public class WebhookHandlerRegistrationTests +{ + /// + /// ⚠️ The invariant behind SubmissionEvent.GitHubDeliveryId. + /// + /// The column is globally unique so a redelivered webhook does not duplicate rows, but one delivery fans + /// out to several handlers. That only works while at most one handler per event name writes a + /// status event. Add a second writer for an existing event and its rows silently vanish (the + /// redelivery guard swallows them) or the unique index rejects them on SQL Server — either way, in + /// production, not here. So the invariant is a failing build instead. + /// + /// If you genuinely need two writers for one event, key the delivery id per handler first. + /// + [Fact] + public void AtMostOneStatusEventWriterPerEvent() + { + var offenders = ResolveHandlers() + .Where(h => h is IStatusEventWriter) + .GroupBy(h => h.GitHubEventName, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => $"{g.Key}: {string.Join(", ", g.Select(h => h.GetType().Name))}") + .ToList(); + + Assert.True( + offenders.Count == 0, + $"More than one handler writes a status event for the same GitHub event, which breaks the delivery-id " + + $"redelivery guard: {string.Join("; ", offenders)}"); + } + + /// + /// The exact handler set ported from github-monitor. Pinned so that dropping a registration — which costs + /// nothing at compile time and shows up only as a rule that quietly stopped being enforced — fails here. + /// + [Fact] + public void AllPortedHandlersAreRegistered() + { + var registered = ResolveHandlers() + .Select(h => (h.GetType(), h.GitHubEventName)) + .ToHashSet(); + + var expected = new HashSet<(Type, string)> + { + (typeof(BranchProtectionRuleHandler), "create"), + (typeof(IssueCommentEditDeleteHandler), "issue_comment"), + (typeof(PullRequestOpenDuplicateHandler), "pull_request"), + (typeof(PullRequestReviewToAssigneeHandler), "pull_request"), + (typeof(GradeCommandIssueCommentHandler), "issue_comment"), + (typeof(GradeCommandReviewCommentHandler), "pull_request_review"), + (typeof(ActionWorkflowRunHandler), "workflow_run"), + (typeof(BranchCreateStatusTrackingHandler), "create"), + (typeof(WorkflowRunStatusTrackingHandler), "workflow_run"), + (typeof(PullRequestStatusTrackingHandler), "pull_request"), + }; + + Assert.Equal(expected, registered); + } + + /// + /// Handlers post comments, and the order they appear in under a pull request is the order they are + /// registered in. DI returns an IEnumerable<T> in registration order, which is the whole + /// reason github-monitor's explicit dispatch-config builder was not needed in the port — so that property + /// is worth a test of its own. + /// + [Fact] + public void RegistrationOrderMatchesTheOriginal() + { + var pullRequestHandlers = ResolveHandlers() + .Where(h => h.GitHubEventName == "pull_request") + .Select(h => h.GetType()) + .ToList(); + + Assert.Equal( + new[] + { + typeof(PullRequestOpenDuplicateHandler), + typeof(PullRequestReviewToAssigneeHandler), + typeof(PullRequestStatusTrackingHandler), + }, + pullRequestHandlers); + } + + /// + /// Resolves the handlers alone, with their collaborators stubbed. Registering the whole service graph would + /// drag in the DbContext and the HTTP clients for a question that is purely about registration. + /// + private static IReadOnlyList ResolveHandlers() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new MemoryCache(new MemoryCacheOptions())); + services.AddSingleton(Mock.Of()); + services.AddSingleton(Mock.Of()); + services.AddAhkGitHubWebhooks(); + + var provider = services.BuildServiceProvider(); + return provider.GetServices().ToList(); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookStatusEventTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookStatusEventTests.cs new file mode 100644 index 0000000..bb8c810 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GitHubWebhooks/WebhookStatusEventTests.cs @@ -0,0 +1,299 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHubWebhooks; +using Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; +using Ahk.Web.Services.StatusTracking; +using Ahk.Web.Services.Submissions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Octokit; +using Octokit.Internal; +using Xunit; + +namespace Ahk.Web.Server.Tests.GitHubWebhooks; + +/// +/// The status-tracking handlers, run against a real on EF InMemory. +/// +/// These handlers are the whole of the dashboard's data: what a teacher sees is a projection over the rows +/// written here, so the mapping from GitHub payload to event subtype is worth pinning field by field. +/// +public class WebhookStatusEventTests +{ + private const int CourseId = 1; + private const string Repository = "bmeaut/viaubc01-abc123"; + + /// Creation of the *default* branch is how a new repository is recognised. + [Fact] + public async Task DefaultBranchCreate_RecordsRepositoryCreated() + { + using var db = NewContext(nameof(DefaultBranchCreate_RecordsRepositoryCreated)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("main"), "delivery-1")); + + Assert.Contains("repository create lifecycle handled", result.Result, StringComparison.Ordinal); + var recorded = Assert.Single(await ReadEventsAsync(db)); + Assert.IsType(recorded); + Assert.Equal("delivery-1", recorded.GitHubDeliveryId); + } + + [Fact] + public async Task NonDefaultBranchCreate_RecordsBranchCreated() + { + using var db = NewContext(nameof(NonDefaultBranchCreate_RecordsBranchCreated)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("feature/homework"), "delivery-1")); + + Assert.Contains("branch create lifecycle handled", result.Result, StringComparison.Ordinal); + var recorded = Assert.Single(await ReadEventsAsync(db)); + Assert.Equal("feature/homework", Assert.IsType(recorded).Branch); + } + + /// A tag is not a branch. + [Fact] + public async Task TagCreate_RecordsNothing() + { + using var db = NewContext(nameof(TagCreate_RecordsNothing)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("v1.0", refType: "tag"), "delivery-1")); + + Assert.Contains("not of interest", result.Result, StringComparison.Ordinal); + Assert.Empty(await ReadEventsAsync(db)); + } + + [Fact] + public async Task PullRequestOpened_RecordsPullRequestEventAndLinksTheStudent() + { + using var db = NewContext(nameof(PullRequestOpened_RecordsPullRequestEventAndLinksTheStudent)); + var handler = new PullRequestStatusTrackingHandler(NewEventService(db), NewCache(), NullLogger.Instance); + + var result = await handler.ExecuteAsync(NewContextFor(PullRequestPayload("opened"), "delivery-1")); + + Assert.Contains("pull request lifecycle handled", result.Result, StringComparison.Ordinal); + + var recorded = Assert.IsType(Assert.Single(await ReadEventsAsync(db))); + Assert.Equal(12, recorded.Number); + Assert.Equal("opened", recorded.Action); + Assert.Equal("https://github.com/bmeaut/viaubc01-abc123/pull/12", recorded.HtmlUrl); + Assert.Equal("ABC123", recorded.Neptun); + Assert.Equal(new[] { "teacher1" }, recorded.Assignees); + + // neptun.txt is what ties a repository to a person; a pull request is usually where it first appears. + var submission = await db.Submissions.IgnoreQueryFilters().Include(s => s.Student).SingleAsync(); + Assert.Equal("ABC123", submission.Student!.Neptun); + } + + [Fact] + public async Task PullRequestSynchronize_IsNotOfInterest() + { + using var db = NewContext(nameof(PullRequestSynchronize_IsNotOfInterest)); + var handler = new PullRequestStatusTrackingHandler(NewEventService(db), NewCache(), NullLogger.Instance); + + var result = await handler.ExecuteAsync(NewContextFor(PullRequestPayload("synchronize"), "delivery-1")); + + Assert.Contains("not of interest", result.Result, StringComparison.Ordinal); + Assert.Empty(await ReadEventsAsync(db)); + } + + [Fact] + public async Task CompletedWorkflowRun_RecordsItsConclusion() + { + using var db = NewContext(nameof(CompletedWorkflowRun_RecordsItsConclusion)); + var handler = new WorkflowRunStatusTrackingHandler(NewEventService(db), NewCache(), NullLogger.Instance); + + var result = await handler.ExecuteAsync(NewContextFor(WorkflowRunPayload("completed", "failure"), "delivery-1")); + + Assert.Contains("workflow_run lifecycle handled", result.Result, StringComparison.Ordinal); + Assert.Equal("failure", Assert.IsType(Assert.Single(await ReadEventsAsync(db))).Conclusion); + } + + /// + /// GitHub redelivers, and an administrator can redeliver by hand from the Advanced tab. The delivery id + /// makes that idempotent. + /// + /// Note this exercises 's own explicit guard rather than the + /// database: EF InMemory does not enforce the filtered unique index that backs it in production. + /// + [Fact] + public async Task RedeliveringTheSameDelivery_RecordsOneRow() + { + using var db = NewContext(nameof(RedeliveringTheSameDelivery_RecordsOneRow)); + var handler = NewBranchHandler(db); + + var first = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("feature/homework"), "delivery-1")); + var second = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("feature/homework"), "delivery-1")); + + Assert.Contains("action performed", first.Result, StringComparison.Ordinal); + Assert.Contains("redelivery, event already recorded", second.Result, StringComparison.Ordinal); + Assert.Single(await ReadEventsAsync(db)); + } + + /// Two genuine deliveries are two events, redelivery guard notwithstanding. + [Fact] + public async Task DistinctDeliveries_RecordBothRows() + { + using var db = NewContext(nameof(DistinctDeliveries_RecordBothRows)); + var handler = NewBranchHandler(db); + + await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("feature/a"), "delivery-1")); + await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("feature/b"), "delivery-2")); + + Assert.Equal(2, (await ReadEventsAsync(db)).Count); + } + + /// + /// The opt-in gate. A repository without .github/ahk-monitor.yml is ignored entirely — no event, no + /// rule enforced — which is the single most common reason a correctly wired webhook appears to do nothing. + /// + [Fact] + public async Task RepositoryWithoutAhkMonitorYml_IsIgnored() + { + using var db = NewContext(nameof(RepositoryWithoutAhkMonitorYml_IsIgnored)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("main"), "delivery-1", monitorYml: null)); + + Assert.Contains("no ahk-monitor.yml or disabled", result.Result, StringComparison.Ordinal); + Assert.Empty(await ReadEventsAsync(db)); + } + + [Fact] + public async Task RepositoryWithDisabledAhkMonitorYml_IsIgnored() + { + using var db = NewContext(nameof(RepositoryWithDisabledAhkMonitorYml_IsIgnored)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor(CreateBranchPayload("main"), "delivery-1", monitorYml: "enabled: false")); + + Assert.Contains("no ahk-monitor.yml or disabled", result.Result, StringComparison.Ordinal); + Assert.Empty(await ReadEventsAsync(db)); + } + + [Fact] + public async Task GarbagePayload_IsReportedNotThrown() + { + using var db = NewContext(nameof(GarbagePayload_IsReportedNotThrown)); + var handler = NewBranchHandler(db); + + var result = await handler.ExecuteAsync(NewContextFor("{\"nonsense\":1}", "delivery-1")); + + Assert.Contains("payload error", result.Result, StringComparison.Ordinal); + Assert.Empty(await ReadEventsAsync(db)); + } + + private static BranchCreateStatusTrackingHandler NewBranchHandler(ApplicationDbContext db) + => new(NewEventService(db), NewCache(), NullLogger.Instance); + + private static ISubmissionEventService NewEventService(ApplicationDbContext db) + => new SubmissionEventService(db, new SubmissionResolver(db)); + + private static IMemoryCache NewCache() => new MemoryCache(new MemoryCacheOptions()); + + private static async Task> ReadEventsAsync(ApplicationDbContext db) + => await db.SubmissionEvents.IgnoreQueryFilters().ToListAsync(); + + private static ApplicationDbContext NewContext(string name) + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options; + var db = new ApplicationDbContext(options, new FixedCourseProvider()); + + db.Courses.Add(new Course { Id = CourseId, Slug = "viaubc01", Name = "Sample Course", GitHubOrganization = "bmeaut" }); + db.SaveChanges(); + + return db; + } + + private static GitHubWebhookContext NewContextFor(string body, string deliveryId, string? monitorYml = "enabled: true") + => new() + { + CourseId = CourseId, + GitHubEventName = "create", + DeliveryId = deliveryId, + RequestBody = body, + GitHubClient = NewGitHubClient(monitorYml), + WorkflowRunThreshold = 5, + }; + + /// + /// Only the two file reads the handlers make are stubbed. Octokit models are built by deserializing the + /// JSON GitHub would send rather than through their constructors, which are long and mostly irrelevant. + /// + private static IGitHubClient NewGitHubClient(string? monitorYml) + { + var contents = new Mock(); + + contents + .Setup(c => c.GetAllContentsByRef(It.IsAny(), ".github/ahk-monitor.yml", It.IsAny())) + .Returns(() => monitorYml is null + ? throw new NotFoundException("not found", System.Net.HttpStatusCode.NotFound) + : Task.FromResult>(new[] { FileContent(monitorYml) })); + + contents + .Setup(c => c.GetAllContentsByRef(It.IsAny(), "neptun.txt", It.IsAny())) + .ReturnsAsync(new[] { FileContent("ABC123\n") }); + + var repositories = new Mock(); + repositories.SetupGet(r => r.Content).Returns(contents.Object); + + var client = new Mock(); + client.SetupGet(c => c.Repository).Returns(repositories.Object); + + return client.Object; + } + + private static RepositoryContent FileContent(string text) + { + var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(text)); + return new SimpleJsonSerializer().Deserialize( + $$"""{"name":"f","path":"f","sha":"s","size":{{text.Length}},"type":"file","encoding":"base64","content":"{{encoded}}"}"""); + } + + private static string CreateBranchPayload(string branch, string refType = "branch") + => $$""" + { + "ref": "{{branch}}", + "ref_type": "{{refType}}", + "repository": { "id": 55, "name": "viaubc01-abc123", "full_name": "{{Repository}}", "default_branch": "main", + "owner": { "login": "bmeaut", "id": 9, "type": "Organization" } }, + "installation": { "id": 123 } + } + """; + + private static string PullRequestPayload(string action) + => $$""" + { + "action": "{{action}}", + "number": 12, + "pull_request": { + "number": 12, + "html_url": "https://github.com/bmeaut/viaubc01-abc123/pull/12", + "head": { "ref": "feature/homework" }, + "assignees": [ { "login": "teacher1", "id": 3 } ] + }, + "repository": { "id": 55, "name": "viaubc01-abc123", "full_name": "{{Repository}}", "default_branch": "main", + "owner": { "login": "bmeaut", "id": 9, "type": "Organization" } }, + "installation": { "id": 123 } + } + """; + + private static string WorkflowRunPayload(string action, string conclusion) + => $$""" + { + "action": "{{action}}", + "workflow_run": { "conclusion": "{{conclusion}}" }, + "repository": { "id": 55, "name": "viaubc01-abc123", "full_name": "{{Repository}}", "default_branch": "main", + "owner": { "login": "bmeaut", "id": 9, "type": "Organization" } }, + "installation": { "id": 123 } + } + """; + + private sealed class FixedCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId => CourseId; + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/GradeLogicParityTests.cs b/ahk-backend/Ahk.Web.Server.Tests/GradeLogicParityTests.cs new file mode 100644 index 0000000..fd2200b --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/GradeLogicParityTests.cs @@ -0,0 +1,118 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Grading; +using Ahk.Web.Services.Grading.Dto; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Parity checks for the grade logic ported from grade-management. The expectations mirror +/// ResultProcessor.GetTotalPoints and SetGradeService.getPoints; if these drift, grades change +/// meaning for existing courses. +/// +public class GradeLogicParityTests +{ + [Fact] + public void AggregatePoints_SumsTasksPerExercise_OrderedByName() + { + var tasks = new[] + { + new EvaluationTaskResult { ExerciseName = "ex2", TaskName = "t1", Points = 1 }, + new EvaluationTaskResult { ExerciseName = "ex1", TaskName = "t2", Points = 2 }, + new EvaluationTaskResult { ExerciseName = "ex1", TaskName = "t3", Points = 3 }, + }; + + var result = GradeService.AggregatePoints(tasks); + + Assert.Equal(2, result.Count); + Assert.Equal("ex1", result[0].Name); + Assert.Equal(5, result[0].Point); // 2 + 3 summed + Assert.Equal("ex2", result[1].Name); + Assert.Equal(1, result[1].Point); + } + + [Fact] + public void AggregatePoints_TreatsMissingExerciseNameAsEmptyGroup() + { + var tasks = new[] + { + new EvaluationTaskResult { ExerciseName = null, TaskName = "t1", Points = 2 }, + new EvaluationTaskResult { ExerciseName = string.Empty, TaskName = "t2", Points = 3 }, + }; + + var result = GradeService.AggregatePoints(tasks); + + Assert.Single(result); + Assert.Equal(string.Empty, result[0].Name); + Assert.Equal(5, result[0].Point); + } + + [Fact] + public void AggregatePoints_HandlesNoTasks() + => Assert.Empty(GradeService.AggregatePoints(Array.Empty())); + + [Fact] + public void BuildPoints_UsesPositionalDefaultNames_WhenNoPreviousResult() + { + var result = GradeService.BuildPoints(new[] { 5d, 3.5d, 0d }, previousPoints: null); + + Assert.Equal(new[] { "ex0", "ex1", "ex2" }, result.Select(p => p.Name)); + Assert.Equal(new[] { 5d, 3.5d, 0d }, result.Select(p => p.Point)); + } + + [Fact] + public void BuildPoints_CarriesForwardPreviousExerciseNames() + { + var previous = new List + { + new() { Name = "Exercise 1", Point = 1, Order = 0 }, + new() { Name = "Exercise 2", Point = 2, Order = 1 }, + }; + + var result = GradeService.BuildPoints(new[] { 5d, 4d }, previous); + + Assert.Equal(new[] { "Exercise 1", "Exercise 2" }, result.Select(p => p.Name)); + Assert.Equal(new[] { 5d, 4d }, result.Select(p => p.Point)); + } + + [Fact] + public void BuildPoints_FallsBackToPositionalNames_BeyondPreviousCount() + { + var previous = new List { new() { Name = "Exercise 1", Point = 1, Order = 0 } }; + + var result = GradeService.BuildPoints(new[] { 5d, 4d }, previous); + + Assert.Equal(new[] { "Exercise 1", "ex1" }, result.Select(p => p.Name)); + } + + [Fact] + public void CsvExporter_PreservesLegacyFormat() + { + var grades = new[] + { + new FinalStudentGrade + { + Neptun = "abc123", + Repo = "org/course-hw1-abc123", + PrUrl = "https://github.com/org/course-hw1-abc123/pull/1", + Points = new Dictionary { ["ex0"] = 2, ["ex1"] = 3.5 }, + }, + new FinalStudentGrade + { + Neptun = "XYZ789", + Repo = "org/course-hw1-xyz789", + PrUrl = null, + Points = new Dictionary { ["ex0"] = 1 }, + }, + }; + + var csv = CsvExporter.GetCsv(grades); + var lines = csv.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal("Neptun;GitHubRepo;GitHubPr;ex0;ex1", lines[0]); + Assert.Equal("ABC123;org/course-hw1-abc123;https://github.com/org/course-hw1-abc123/pull/1;2;3.5", lines[1]); + + // Missing exercise -> empty cell; missing PR url -> empty cell. + Assert.Equal("XYZ789;org/course-hw1-xyz789;;1;", lines[2]); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/StatusProjectionTests.cs b/ahk-backend/Ahk.Web.Server.Tests/StatusProjectionTests.cs new file mode 100644 index 0000000..adabcd1 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/StatusProjectionTests.cs @@ -0,0 +1,101 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.StatusTracking; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// Parity checks for the event-log → status projection ported from +/// grade-management/.../StatusTracking/StatusTrackingService.cs: latest PR action wins, assignees are +/// the distinct union, branches are distinct, and workflow runs report count + last conclusion. +/// +public class StatusProjectionTests +{ + private sealed class FixedCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId { get; set; } + } + + private const int CourseId = 1; + + private static ApplicationDbContext CreateContext(string dbName) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new ApplicationDbContext(options, new FixedCourseProvider { CurrentCourseId = CourseId }); + } + + private static async Task SeedAsync() + { + var db = CreateContext(Guid.NewGuid().ToString()); + var now = DateTimeOffset.UtcNow; + + var student = new Student { CourseId = CourseId, Neptun = "ABC123" }; + db.Students.Add(student); + await db.SaveChangesAsync(); + + var submission = new Submission { CourseId = CourseId, StudentId = student.Id, GitHubRepoName = "org/course-hw1-abc123" }; + db.Submissions.Add(submission); + await db.SaveChangesAsync(); + + db.SubmissionEvents.AddRange( + new RepositoryCreatedEvent { CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-5) }, + new BranchCreatedEvent { CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-4), Branch = "solution" }, + new BranchCreatedEvent { CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-4), Branch = "solution" }, + new PullRequestEvent + { + CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-3), + Number = 1, Action = "opened", HtmlUrl = "https://github.com/org/course-hw1-abc123/pull/1", + Neptun = "ABC123", Assignees = new List { "teacher1" }, + }, + new PullRequestEvent + { + CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-1), + Number = 1, Action = "closed", HtmlUrl = "https://github.com/org/course-hw1-abc123/pull/1", + Neptun = "ABC123", Assignees = new List { "teacher2" }, + }, + new WorkflowRunEvent { CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-3), Conclusion = "failure" }, + new WorkflowRunEvent { CourseId = CourseId, SubmissionId = submission.Id, Timestamp = now.AddDays(-2), Conclusion = "success" }); + + await db.SaveChangesAsync(); + return db; + } + + [Fact] + public async Task Projection_MatchesLegacySemantics() + { + await using var db = await SeedAsync(); + var service = new StatusTrackingService(db); + + var statuses = await service.ListStatusesAsync(CourseId); + + var status = Assert.Single(statuses); + Assert.Equal("org/course-hw1-abc123", status.Repository); + Assert.Equal("ABC123", status.Neptun); + + // Branches are distinct. + Assert.Equal(new[] { "solution" }, status.Branches); + + // One PR, latest action wins, assignees are the distinct union across its events. + var pr = Assert.Single(status.PullRequests); + Assert.Equal(1, pr.Number); + Assert.Equal("closed", pr.Status); + Assert.Equal("teacher1, teacher2", pr.Assignee); + + // Workflow runs: total count and the most recent conclusion. + Assert.Equal(2, status.WorkflowRuns.Count); + Assert.Equal("success", status.WorkflowRuns.LastStatus); + } + + [Fact] + public async Task Projection_ReturnsNothingForAnotherCourse() + { + await using var db = await SeedAsync(); + var service = new StatusTrackingService(db); + + Assert.Empty(await service.ListStatusesAsync(courseId: 999)); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/StudentAssignmentTests.cs b/ahk-backend/Ahk.Web.Server.Tests/StudentAssignmentTests.cs new file mode 100644 index 0000000..d3597f0 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/StudentAssignmentTests.cs @@ -0,0 +1,210 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Assignments; +using Ahk.Web.Services.GitHub; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// The student's home page. Its whole job is to answer "can I actually open this repository yet", which means +/// reconciling what the database remembers with what GitHub currently says — a pending invitation may have been +/// accepted, or may have quietly expired. +/// +public class StudentAssignmentTests +{ + private const int CourseId = 1; + private const int UserId = 7; + + /// + /// Null current course, deliberately: these endpoints carry no {course} segment. If the service ever stops + /// using IgnoreQueryFilters, these tests go from green to empty results. + /// + private sealed class NoCourseProvider : ICurrentCourseProvider + { + public int? CurrentCourseId => null; + } + + private sealed class Fixture : IAsyncDisposable + { + public Fixture(bool invitationPending, long? invitationId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + Db = new ApplicationDbContext(options, new NoCourseProvider()); + + Db.Courses.Add(new Course { Id = CourseId, Slug = "viaubc01", Name = "Sample Course", GitHubOrganization = "ahk-org" }); + Db.Users.Add(new ApplicationUser { Id = UserId, UserName = "student@bme.hu" }); + Db.Assignments.Add(new Assignment { Id = 10, CourseId = CourseId, Name = "Homework 1", TemplateRepoName = "ahk-org/viaubc01-hw1", InviteToken = "t" }); + Db.AssignmentAcceptances.Add(new AssignmentAcceptance + { + Id = 100, + CourseId = CourseId, + AssignmentId = 10, + UserId = UserId, + GitHubRepoName = "ahk-org/viaubc01-hw1-abc123", + RepoUrl = "https://github.com/ahk-org/viaubc01-hw1-abc123", + GitHubUsername = "octocat", + InvitationPending = invitationPending, + InvitationId = invitationId, + InvitationSentAt = invitationPending ? DateTimeOffset.UtcNow.AddDays(-8) : null, + }); + Db.SaveChanges(); + + GitHub = new Mock(); + Tokens = new Mock(); + Tokens + .Setup(t => t.GetForCourseAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitHubInstallationToken("gh-token", 42, new Dictionary(), "all")); + + Service = new StudentAssignmentService(Db, GitHub.Object, Tokens.Object, NullLogger.Instance); + } + + public ApplicationDbContext Db { get; } + + public Mock GitHub { get; } + + public Mock Tokens { get; } + + public StudentAssignmentService Service { get; } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + [Fact] + public async Task ASettledRepository_IsListedAsActiveWithoutAskingGitHub() + { + await using var fixture = new Fixture(invitationPending: false, invitationId: null); + + var repositories = await fixture.Service.ListForUserAsync(UserId); + + var repository = Assert.Single(repositories); + Assert.Equal(RepositoryAccess.Active, repository.Access); + Assert.Equal("viaubc01", repository.CourseSlug); + Assert.Equal("Homework 1", repository.AssignmentName); + Assert.Null(repository.InvitationUrl); + + fixture.GitHub.Verify( + g => g.IsCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// The student accepted the invitation on GitHub; the portal should notice and stop nagging them. + [Fact] + public async Task AnInvitationAcceptedOnGitHub_IsClearedAndReportedAsActive() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + fixture.GitHub + .Setup(g => g.IsCollaboratorAsync("ahk-org", "viaubc01-hw1-abc123", "octocat", "gh-token", It.IsAny())) + .ReturnsAsync(true); + + var repositories = await fixture.Service.ListForUserAsync(UserId); + + Assert.Equal(RepositoryAccess.Active, Assert.Single(repositories).Access); + + var stored = await fixture.Db.AssignmentAcceptances.IgnoreQueryFilters().SingleAsync(); + Assert.False(stored.InvitationPending); + Assert.Null(stored.InvitationId); + } + + /// No access and no invitation left on GitHub means it lapsed — offer the resend, do not leave them waiting. + [Fact] + public async Task AnInvitationGitHubNoLongerHas_IsReportedAsExpired() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + fixture.GitHub + .Setup(g => g.IsCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + fixture.GitHub + .Setup(g => g.FindInvitationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((GitHubInvitation?)null); + + var repositories = await fixture.Service.ListForUserAsync(UserId); + + Assert.Equal(RepositoryAccess.InvitationExpired, Assert.Single(repositories).Access); + } + + [Fact] + public async Task AnInvitationStillWaiting_IsReportedAsPendingWithSomewhereToGo() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + fixture.GitHub + .Setup(g => g.IsCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + fixture.GitHub + .Setup(g => g.FindInvitationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitHubInvitation(99, "octocat", Expired: false, DateTimeOffset.UtcNow.AddDays(-1))); + + var repository = Assert.Single(await fixture.Service.ListForUserAsync(UserId)); + + Assert.Equal(RepositoryAccess.InvitationPending, repository.Access); + Assert.Equal("https://github.com/ahk-org/viaubc01-hw1-abc123/invitations", repository.InvitationUrl); + } + + /// GitHub cannot extend an invitation, so the stale one has to be withdrawn before a new one is sent. + [Fact] + public async Task Resending_WithdrawsTheStaleInvitationAndStoresTheNewOne() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + fixture.GitHub + .Setup(g => g.IsCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + fixture.GitHub + .Setup(g => g.FindInvitationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new GitHubInvitation(99, "octocat", Expired: true, DateTimeOffset.UtcNow.AddDays(-8))); + fixture.GitHub + .Setup(g => g.DeleteInvitationAsync("ahk-org", "viaubc01-hw1-abc123", 99, "gh-token", It.IsAny())) + .Returns(Task.CompletedTask); + fixture.GitHub + .Setup(g => g.AddCollaboratorAsync("ahk-org", "viaubc01-hw1-abc123", "octocat", "gh-token", It.IsAny())) + .ReturnsAsync(new CollaboratorResult(true, 123)); + + var result = await fixture.Service.ResendInvitationAsync(UserId, 100); + + Assert.NotNull(result); + Assert.Equal(RepositoryAccess.InvitationPending, result!.Access); + + fixture.GitHub.Verify(g => g.DeleteInvitationAsync("ahk-org", "viaubc01-hw1-abc123", 99, "gh-token", It.IsAny()), Times.Once); + + var stored = await fixture.Db.AssignmentAcceptances.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(123, stored.InvitationId); + Assert.True(stored.InvitationPending); + } + + /// They accepted between drawing the page and clicking Resend; do not send a pointless invitation. + [Fact] + public async Task Resending_DoesNothingWhenTheStudentAlreadyHasAccess() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + fixture.GitHub + .Setup(g => g.IsCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await fixture.Service.ResendInvitationAsync(UserId, 100); + + Assert.Equal(RepositoryAccess.Active, result!.Access); + fixture.GitHub.Verify( + g => g.AddCollaboratorAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// An acceptance belonging to someone else is not found, not someone else's repository. + [Fact] + public async Task Resending_AnotherStudentsAcceptance_IsRefused() + { + await using var fixture = new Fixture(invitationPending: true, invitationId: 99); + + Assert.Null(await fixture.Service.ResendInvitationAsync(userId: 999, acceptanceId: 100)); + Assert.Empty(await fixture.Service.ListForUserAsync(999)); + } +} diff --git a/ahk-backend/Ahk.Web.Server.Tests/UserNeptunTests.cs b/ahk-backend/Ahk.Web.Server.Tests/UserNeptunTests.cs new file mode 100644 index 0000000..80736fd --- /dev/null +++ b/ahk-backend/Ahk.Web.Server.Tests/UserNeptunTests.cs @@ -0,0 +1,137 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Admin; +using Ahk.Web.Server.Admin.Dto; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Ahk.Web.Server.Tests; + +/// +/// The admin rule that a Neptun code is either empty or unique across users, exercised through +/// over a real on the in-memory store. +/// (EF InMemory does not enforce the filtered unique index, so this proves the controller's own guard — +/// the database index is the backstop in production.) +/// +public sealed class UserNeptunTests : IDisposable +{ + private readonly ApplicationDbContext db; + private readonly UserManager userManager; + private readonly UsersAdminController controller; + + public UserNeptunTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"UserNeptunTests-{Guid.NewGuid()}") + .Options; + this.db = new ApplicationDbContext(options, new NoCourse()); + + var store = new UserStore(this.db); + this.userManager = new UserManager( + store, + Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + services: null!, + NullLogger>.Instance); + + this.controller = new UsersAdminController(this.db, this.userManager); + } + + [Fact] + public async Task Create_StoresNeptunUppercased() + { + var result = await this.controller.Create(NewUser("alice", neptun: "abc123"), default); + + var dto = Assert.IsType(Assert.IsType(result.Result).Value); + Assert.Equal("ABC123", dto.NeptunCode); + Assert.Equal("ABC123", (await this.userManager.FindByNameAsync("alice"))!.NeptunCode); + } + + [Fact] + public async Task Create_DuplicateNeptun_IsRejected() + { + await this.controller.Create(NewUser("alice", neptun: "ABC123"), default); + + // Same code, different casing/whitespace — normalization must make them collide. + var result = await this.controller.Create(NewUser("bob", neptun: " abc123 "), default); + + Assert.IsType(result.Result); + Assert.Null(await this.userManager.FindByNameAsync("bob")); // not created + } + + [Fact] + public async Task Create_BlankNeptun_StoredAsNull_AndMayRepeat() + { + await this.controller.Create(NewUser("alice", neptun: ""), default); + var result = await this.controller.Create(NewUser("bob", neptun: " "), default); + + Assert.IsType(result.Result); + Assert.Null((await this.userManager.FindByNameAsync("alice"))!.NeptunCode); + Assert.Null((await this.userManager.FindByNameAsync("bob"))!.NeptunCode); + } + + [Fact] + public async Task Update_ToNeptunHeldByAnother_IsRejected() + { + await this.controller.Create(NewUser("alice", neptun: "ALICE1"), default); + await this.controller.Create(NewUser("bob", neptun: "BOB1"), default); + var bob = (await this.userManager.FindByNameAsync("bob"))!; + + var result = await this.controller.Update(bob.Id, new UpdateUserRequest { NeptunCode = "alice1" }, default); + + Assert.IsType(result.Result); + Assert.Equal("BOB1", (await this.userManager.FindByNameAsync("bob"))!.NeptunCode); // unchanged + } + + [Fact] + public async Task Update_KeepingOwnNeptun_Succeeds() + { + await this.controller.Create(NewUser("alice", neptun: "ALICE1"), default); + var alice = (await this.userManager.FindByNameAsync("alice"))!; + + var result = await this.controller.Update(alice.Id, new UpdateUserRequest { NeptunCode = "alice1" }, default); + + Assert.IsType(Assert.IsType(result.Result).Value); + Assert.Equal("ALICE1", (await this.userManager.FindByNameAsync("alice"))!.NeptunCode); + } + + [Fact] + public async Task Update_ClearingNeptun_StoresNull() + { + await this.controller.Create(NewUser("alice", neptun: "ALICE1"), default); + var alice = (await this.userManager.FindByNameAsync("alice"))!; + + await this.controller.Update(alice.Id, new UpdateUserRequest { NeptunCode = "" }, default); + + Assert.Null((await this.userManager.FindByNameAsync("alice"))!.NeptunCode); + } + + private static CreateUserRequest NewUser(string userName, string? neptun) => new() + { + UserName = userName, + Password = "Passw0rd!", + NeptunCode = neptun, + }; + + public void Dispose() + { + this.userManager.Dispose(); + this.db.Dispose(); + } + + /// Course provider that resolves no course; is not course-scoped, so + /// the value is irrelevant here. + private sealed class NoCourse : ICurrentCourseProvider + { + public int? CurrentCourseId => null; + } +} diff --git a/ahk-backend/Ahk.Web.Server/Admin/CourseHealthAdminController.cs b/ahk-backend/Ahk.Web.Server/Admin/CourseHealthAdminController.cs new file mode 100644 index 0000000..d70a5fc --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Admin/CourseHealthAdminController.cs @@ -0,0 +1,39 @@ +using Ahk.Web.Data; +using Ahk.Web.Services.Health; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Admin; + +/// +/// Runs the course health checks on demand. Results are computed per request and never cached: an admin opens +/// this page precisely because they want to know the state right now, usually after changing a credential. +/// +/// Checks are discovered through DI (), so extending what "healthy" means +/// requires no change here. +/// +[ApiController] +[Route("api/admin/health")] +[Authorize(Roles = Roles.Admin)] +public sealed class CourseHealthAdminController : ControllerBase +{ + private readonly ICourseHealthService health; + + public CourseHealthAdminController(ICourseHealthService health) => this.health = health; + + /// Health of every course — the admin health dashboard. + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> CheckAll(CancellationToken cancellationToken) => + Ok(await health.CheckAllCoursesAsync(cancellationToken)); + + /// Health of one course — used by the re-check button on the course editor. + [HttpGet("{courseId:int}")] + [ProducesResponseType(typeof(CourseHealthReport), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CheckCourse(int courseId, CancellationToken cancellationToken) + { + var report = await health.CheckCourseAsync(courseId, cancellationToken); + return report is null ? NotFound() : Ok(report); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Admin/CoursesAdminController.cs b/ahk-backend/Ahk.Web.Server/Admin/CoursesAdminController.cs new file mode 100644 index 0000000..47692e7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Admin/CoursesAdminController.cs @@ -0,0 +1,372 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Admin.Dto; +using Ahk.Web.Services.Courses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.Admin; + +/// +/// Host/admin context (no {course} segment): site super-admins manage the set of courses, their connected +/// GitHub environments, their staff and their CI callback tokens. This centralizes what used to be a separate +/// per-course Azure deployment plus its AHK_* application settings. +/// +/// Reads of course-scoped entities (students, submissions, grades, tokens) use IgnoreQueryFilters() and +/// filter on CourseId themselves: there is no {course} segment here, so no current course is set and the +/// course filter would otherwise match nothing. +/// +[ApiController] +[Route("api/admin/courses")] +[Authorize(Roles = Roles.Admin)] +public sealed class CoursesAdminController : ControllerBase +{ + private readonly ApplicationDbContext db; + private readonly IWebhookTokenService webhookTokens; + + public CoursesAdminController(ApplicationDbContext db, IWebhookTokenService webhookTokens) + { + this.db = db; + this.webhookTokens = webhookTokens; + } + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var courses = await db.Courses + .AsNoTracking() + .OrderBy(c => c.Slug) + .Select(c => new CourseDto + { + Id = c.Id, + Slug = c.Slug, + Name = c.Name, + GitHubOrganization = c.GitHubOrganization, + RepoNamePrefix = c.RepoNamePrefix, + CreatedAt = c.CreatedAt, + IntegrationEnabled = c.GitHubConfig != null && c.GitHubConfig.Enabled, + MemberCount = c.Memberships.Count, + StudentCount = db.Students.IgnoreQueryFilters().Count(s => s.CourseId == c.Id), + SubmissionCount = db.Submissions.IgnoreQueryFilters().Count(s => s.CourseId == c.Id), + }) + .ToListAsync(cancellationToken); + + return Ok(courses); + } + + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CourseDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id, CancellationToken cancellationToken) + { + var course = await db.Courses + .AsNoTracking() + .Include(c => c.GitHubConfig) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + if (course is null) + return NotFound(); + + var members = await db.CourseMemberships + .AsNoTracking() + .Where(m => m.CourseId == id) + .OrderBy(m => m.User!.UserName) + .Select(m => new CourseMemberDto + { + UserId = m.UserId, + UserName = m.User!.UserName ?? string.Empty, + DisplayName = m.User.DisplayName, + Email = m.User.Email, + Role = m.Role, + }) + .ToListAsync(cancellationToken); + + var tokens = await webhookTokens.ListForCourseAsync(id, cancellationToken); + + return Ok(new CourseDetailDto + { + Id = course.Id, + Slug = course.Slug, + Name = course.Name, + GitHubOrganization = course.GitHubOrganization, + RepoNamePrefix = course.RepoNamePrefix, + CreatedAt = course.CreatedAt, + StudentCount = await db.Students.IgnoreQueryFilters().CountAsync(s => s.CourseId == id, cancellationToken), + SubmissionCount = await db.Submissions.IgnoreQueryFilters().CountAsync(s => s.CourseId == id, cancellationToken), + GitHubConfig = ToDto(course.GitHubConfig), + Members = members, + WebhookTokens = tokens.Select(ToDto).ToList(), + }); + } + + [HttpPost] + [ProducesResponseType(typeof(CourseDetailDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateCourseRequest request, CancellationToken cancellationToken) + { + if (await db.Courses.AnyAsync(c => c.Slug == request.Slug, cancellationToken)) + return Conflict(new { error = $"A course with slug '{request.Slug}' already exists." }); + + var course = new Course + { + Slug = request.Slug, + Name = request.Name, + GitHubOrganization = Trimmed(request.GitHubOrganization), + RepoNamePrefix = Trimmed(request.RepoNamePrefix), + + // Created up front so the course editor always has an integration section to fill in. + GitHubConfig = new CourseGitHubConfig(), + }; + + db.Courses.Add(course); + await db.SaveChangesAsync(cancellationToken); + + return CreatedAtAction(nameof(Get), new { id = course.Id }, new CourseDetailDto + { + Id = course.Id, + Slug = course.Slug, + Name = course.Name, + GitHubOrganization = course.GitHubOrganization, + RepoNamePrefix = course.RepoNamePrefix, + CreatedAt = course.CreatedAt, + GitHubConfig = ToDto(course.GitHubConfig), + }); + } + + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Update(int id, [FromBody] UpdateCourseRequest request, CancellationToken cancellationToken) + { + var course = await db.Courses.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + if (course is null) + return NotFound(); + + if (!string.Equals(course.Slug, request.Slug, StringComparison.Ordinal) + && await db.Courses.AnyAsync(c => c.Slug == request.Slug && c.Id != id, cancellationToken)) + { + return Conflict(new { error = $"A course with slug '{request.Slug}' already exists." }); + } + + course.Slug = request.Slug; + course.Name = request.Name; + course.GitHubOrganization = Trimmed(request.GitHubOrganization); + course.RepoNamePrefix = Trimmed(request.RepoNamePrefix); + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + /// + /// Deletes a course and, by cascade, everything assigned to it — students, submissions, events, grades, + /// memberships and tokens. The caller must repeat the slug to confirm, so a mis-clicked id cannot erase a + /// live course's grades. + /// + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id, [FromQuery] string? confirmSlug, CancellationToken cancellationToken) + { + var course = await db.Courses.FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + if (course is null) + return NotFound(); + + if (!string.Equals(course.Slug, confirmSlug, StringComparison.Ordinal)) + return BadRequest(new { error = $"Type the course slug '{course.Slug}' to confirm deletion." }); + + // GradeRecord, SubmissionEvent and AssignmentAcceptance point at Course with NoAction (SQL Server + // rejects the extra cascade paths), so they are removed explicitly before the course goes. + await db.GradeExercisePoints.IgnoreQueryFilters() + .Where(p => p.GradeRecord!.CourseId == id).ExecuteDeleteAsync(cancellationToken); + await db.GradeRecords.IgnoreQueryFilters().Where(g => g.CourseId == id).ExecuteDeleteAsync(cancellationToken); + await db.SubmissionEvents.IgnoreQueryFilters().Where(e => e.CourseId == id).ExecuteDeleteAsync(cancellationToken); + await db.Submissions.IgnoreQueryFilters().Where(s => s.CourseId == id).ExecuteDeleteAsync(cancellationToken); + await db.AssignmentAcceptances.IgnoreQueryFilters().Where(a => a.CourseId == id).ExecuteDeleteAsync(cancellationToken); + await db.Assignments.IgnoreQueryFilters().Where(a => a.CourseId == id).ExecuteDeleteAsync(cancellationToken); + + db.Courses.Remove(course); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ---- GitHub integration ---- + + [HttpGet("{id:int}/github")] + [ProducesResponseType(typeof(CourseGitHubConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetGitHubConfig(int id, CancellationToken cancellationToken) + { + var course = await db.Courses.AsNoTracking().Include(c => c.GitHubConfig) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + return course is null ? NotFound() : Ok(ToDto(course.GitHubConfig)); + } + + [HttpPut("{id:int}/github")] + [ProducesResponseType(typeof(CourseGitHubConfigDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateGitHubConfig(int id, [FromBody] UpdateCourseGitHubConfigRequest request, CancellationToken cancellationToken) + { + var course = await db.Courses.Include(c => c.GitHubConfig).FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + if (course is null) + return NotFound(); + + var config = course.GitHubConfig; + if (config is null) + { + config = new CourseGitHubConfig { CourseId = id }; + db.CourseGitHubConfigs.Add(config); + course.GitHubConfig = config; + } + + config.GitHubAppId = Trimmed(request.GitHubAppId); + config.GitHubAppPrivateKey = ApplySecret(config.GitHubAppPrivateKey, request.GitHubAppPrivateKey); + config.GitHubAccessToken = ApplySecret(config.GitHubAccessToken, request.GitHubAccessToken); + config.GitHubWebhookSecret = ApplySecret(config.GitHubWebhookSecret, request.GitHubWebhookSecret); + config.WorkflowRunThreshold = request.WorkflowRunThreshold; + config.Enabled = request.Enabled; + config.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(cancellationToken); + return Ok(ToDto(config)); + } + + // ---- Members ---- + + [HttpGet("{id:int}/members")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> ListMembers(int id, CancellationToken cancellationToken) + { + var members = await db.CourseMemberships + .AsNoTracking() + .Where(m => m.CourseId == id) + .OrderBy(m => m.User!.UserName) + .Select(m => new CourseMemberDto + { + UserId = m.UserId, + UserName = m.User!.UserName ?? string.Empty, + DisplayName = m.User.DisplayName, + Email = m.User.Email, + Role = m.Role, + }) + .ToListAsync(cancellationToken); + + return Ok(members); + } + + /// Adds a user to the course, or changes the role they hold in it. + [HttpPut("{id:int}/members")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpsertMember(int id, [FromBody] UpsertCourseMemberRequest request, CancellationToken cancellationToken) + { + if (!await db.Courses.AnyAsync(c => c.Id == id, cancellationToken)) + return NotFound(); + + if (!await db.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return NotFound(new { error = "No such user." }); + + var membership = await db.CourseMemberships + .FirstOrDefaultAsync(m => m.CourseId == id && m.UserId == request.UserId, cancellationToken); + + if (membership is null) + db.CourseMemberships.Add(new CourseMembership { CourseId = id, UserId = request.UserId, Role = request.Role }); + else + membership.Role = request.Role; + + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpDelete("{id:int}/members/{userId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RemoveMember(int id, int userId, CancellationToken cancellationToken) + { + var membership = await db.CourseMemberships + .FirstOrDefaultAsync(m => m.CourseId == id && m.UserId == userId, cancellationToken); + + if (membership is null) + return NotFound(); + + db.CourseMemberships.Remove(membership); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + // ---- CI callback tokens ---- + + [HttpGet("{id:int}/tokens")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> ListTokens(int id, CancellationToken cancellationToken) + { + var tokens = await webhookTokens.ListForCourseAsync(id, cancellationToken); + return Ok(tokens.Select(ToDto).ToList()); + } + + /// Issues a token, returning its secret. The secret is also readable later via the list endpoint. + [HttpPost("{id:int}/tokens")] + [ProducesResponseType(typeof(WebhookTokenDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> CreateToken(int id, [FromBody] CreateWebhookTokenRequest request, CancellationToken cancellationToken) + { + if (!await db.Courses.AnyAsync(c => c.Id == id, cancellationToken)) + return NotFound(); + + var token = await webhookTokens.CreateAsync(id, Trimmed(request.Description), cancellationToken); + return CreatedAtAction(nameof(ListTokens), new { id }, ToDto(token)); + } + + [HttpDelete("{id:int}/tokens/{tokenId:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task RevokeToken(int id, int tokenId, CancellationToken cancellationToken) => + await webhookTokens.RevokeAsync(id, tokenId, cancellationToken) ? NoContent() : NotFound(); + + /// + /// Applies the credential update rule: null leaves the stored value alone, empty clears it, anything else + /// replaces it. The UI sends null for a field the admin did not type into, so an unchanged form never + /// wipes a secret it was never shown. + /// + private static string? ApplySecret(string? current, string? incoming) => incoming switch + { + null => current, + "" => null, + _ => incoming.Trim(), + }; + + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static CourseGitHubConfigDto ToDto(CourseGitHubConfig? config) => new() + { + GitHubAppId = config?.GitHubAppId, + HasAppPrivateKey = !string.IsNullOrEmpty(config?.GitHubAppPrivateKey), + HasAccessToken = !string.IsNullOrEmpty(config?.GitHubAccessToken), + AccessTokenHint = Hint(config?.GitHubAccessToken), + HasWebhookSecret = !string.IsNullOrEmpty(config?.GitHubWebhookSecret), + WorkflowRunThreshold = config?.WorkflowRunThreshold ?? 5, + Enabled = config?.Enabled ?? false, + UpdatedAt = config?.UpdatedAt, + }; + + /// Last four characters of a secret — enough to recognize which one is stored, not enough to use. + private static string? Hint(string? secret) => + string.IsNullOrEmpty(secret) || secret.Length < 8 ? null : secret[^4..]; + + // The CI callback secret is a plaintext column (the HMAC scheme needs the raw key), and every token endpoint + // here is admin-only. An admin who can mint a token can already obtain a working secret, so returning an + // existing one is no extra exposure — it lets the console copy a secret again rather than force a re-issue. + private static WebhookTokenDto ToDto(CourseWebhookToken token) => new() + { + Id = token.Id, + Token = token.Token, + Secret = token.Secret, + Description = token.Description, + CreatedAt = token.CreatedAt, + RevokedAt = token.RevokedAt, + }; +} diff --git a/ahk-backend/Ahk.Web.Server/Admin/Dto/CourseDto.cs b/ahk-backend/Ahk.Web.Server/Admin/Dto/CourseDto.cs new file mode 100644 index 0000000..c925f25 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Admin/Dto/CourseDto.cs @@ -0,0 +1,187 @@ +using System.ComponentModel.DataAnnotations; +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Server.Admin.Dto; + +/// A course as it appears in the admin list: identity plus enough counts to judge it at a glance. +public sealed class CourseDto +{ + public int Id { get; set; } + + public string Slug { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? GitHubOrganization { get; set; } + + public string? RepoNamePrefix { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + /// False when the course's GitHub integration is switched off (or not created yet). + public bool IntegrationEnabled { get; set; } + + public int MemberCount { get; set; } + + public int StudentCount { get; set; } + + public int SubmissionCount { get; set; } +} + +/// Everything one course holds, for the course editor: settings, integration, members and tokens. +public sealed class CourseDetailDto +{ + public int Id { get; set; } + + public string Slug { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? GitHubOrganization { get; set; } + + public string? RepoNamePrefix { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public int StudentCount { get; set; } + + public int SubmissionCount { get; set; } + + public CourseGitHubConfigDto GitHubConfig { get; set; } = new(); + + public IReadOnlyList Members { get; set; } = Array.Empty(); + + public IReadOnlyList WebhookTokens { get; set; } = Array.Empty(); +} + +public sealed class CreateCourseRequest +{ + [Required] + [RegularExpression("^[a-z0-9-]{2,64}$", ErrorMessage = "Slug must be 2-64 chars: lowercase letters, digits, hyphen.")] + public string Slug { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string Name { get; set; } = string.Empty; + + [MaxLength(256)] + public string? GitHubOrganization { get; set; } + + [MaxLength(256)] + public string? RepoNamePrefix { get; set; } +} + +/// +/// Course settings. The slug is included because it is editable, but changing it changes every URL the course +/// is reachable at — the UI warns before saving. +/// +public sealed class UpdateCourseRequest +{ + [Required] + [RegularExpression("^[a-z0-9-]{2,64}$", ErrorMessage = "Slug must be 2-64 chars: lowercase letters, digits, hyphen.")] + public string Slug { get; set; } = string.Empty; + + [Required] + [MaxLength(256)] + public string Name { get; set; } = string.Empty; + + [MaxLength(256)] + public string? GitHubOrganization { get; set; } + + [MaxLength(256)] + public string? RepoNamePrefix { get; set; } +} + +/// +/// The course's GitHub integration as the admin UI sees it. Stored credentials are never sent back — only +/// whether one is present, and a last-four hint so an admin can tell which token is in place. +/// +public sealed class CourseGitHubConfigDto +{ + public string? GitHubAppId { get; set; } + + public bool HasAppPrivateKey { get; set; } + + public bool HasAccessToken { get; set; } + + /// Last four characters of the stored access token, e.g. "…f3Ab". Null when none is stored. + public string? AccessTokenHint { get; set; } + + public bool HasWebhookSecret { get; set; } + + public int WorkflowRunThreshold { get; set; } + + public bool Enabled { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } +} + +/// +/// Update for the GitHub integration. The three credential fields follow one rule: null leaves the +/// stored value alone (the UI sends null when the admin did not touch the field), an empty string clears it, +/// and any other value replaces it. +/// +public sealed class UpdateCourseGitHubConfigRequest +{ + [MaxLength(64)] + public string? GitHubAppId { get; set; } + + public string? GitHubAppPrivateKey { get; set; } + + [MaxLength(512)] + public string? GitHubAccessToken { get; set; } + + [MaxLength(512)] + public string? GitHubWebhookSecret { get; set; } + + [Range(1, 1000)] + public int WorkflowRunThreshold { get; set; } = 5; + + public bool Enabled { get; set; } = true; +} + +public sealed class CourseMemberDto +{ + public int UserId { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string? DisplayName { get; set; } + + public string? Email { get; set; } + + public CourseRole Role { get; set; } +} + +public sealed class UpsertCourseMemberRequest +{ + [Required] + public int UserId { get; set; } + + public CourseRole Role { get; set; } = CourseRole.Instructor; +} + +/// +/// A CI callback token. is populated only in the response that creates the token — it is +/// never readable afterwards, so the UI shows it once and tells the admin to copy it. +/// +public sealed class WebhookTokenDto +{ + public int Id { get; set; } + + public string Token { get; set; } = string.Empty; + + public string? Secret { get; set; } + + public string? Description { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } +} + +public sealed class CreateWebhookTokenRequest +{ + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Admin/Dto/UserDto.cs b/ahk-backend/Ahk.Web.Server/Admin/Dto/UserDto.cs new file mode 100644 index 0000000..44750f7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Admin/Dto/UserDto.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Server.Admin.Dto; + +/// A registered user with the two things an admin edits: site roles and course assignments. +public sealed class UserDto +{ + public int Id { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string? Email { get; set; } + + public string? DisplayName { get; set; } + + /// From the IdP's neptun_code claim; empty for local accounts. + public string? NeptunCode { get; set; } + + /// From the IdP's eduperson_scoped_affiliation claim, values joined with ';'. + public string? Affiliation { get; set; } + + public IReadOnlyList Roles { get; set; } = Array.Empty(); + + public IReadOnlyList Courses { get; set; } = Array.Empty(); + + /// True when the account signs in through the identity provider rather than a local password. + public bool IsExternal { get; set; } + + /// True while the account is locked out, so the list can show why a sign-in is failing. + public bool IsLockedOut { get; set; } +} + +public sealed class UserCourseDto +{ + public int CourseId { get; set; } + + public string Slug { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public CourseRole Role { get; set; } +} + +/// One page of users, plus the total so the UI can say "showing 25 of 340". +public sealed class UserListResponse +{ + public IReadOnlyList Items { get; set; } = Array.Empty(); + + public int Total { get; set; } +} + +public sealed class CreateUserRequest +{ + [Required] + [MaxLength(256)] + public string UserName { get; set; } = string.Empty; + + [EmailAddress] + [MaxLength(256)] + public string? Email { get; set; } + + [MaxLength(256)] + public string? DisplayName { get; set; } + + /// Optional Neptun code. Must be empty, or unique across users. + [MaxLength(32)] + public string? NeptunCode { get; set; } + + [Required] + [MinLength(6)] + public string Password { get; set; } = string.Empty; +} + +public sealed class UpdateUserRequest +{ + [EmailAddress] + [MaxLength(256)] + public string? Email { get; set; } + + [MaxLength(256)] + public string? DisplayName { get; set; } + + [MaxLength(32)] + public string? NeptunCode { get; set; } +} + +/// The complete set of site roles the user should hold; anything not listed is removed. +public sealed class UpdateUserRolesRequest +{ + public IReadOnlyList Roles { get; set; } = Array.Empty(); +} + +public sealed class UpsertUserCourseRequest +{ + [Required] + public int CourseId { get; set; } + + public CourseRole Role { get; set; } = CourseRole.Instructor; +} + +public sealed class SetPasswordRequest +{ + [Required] + [MinLength(6)] + public string NewPassword { get; set; } = string.Empty; +} diff --git a/ahk-backend/Ahk.Web.Server/Admin/UsersAdminController.cs b/ahk-backend/Ahk.Web.Server/Admin/UsersAdminController.cs new file mode 100644 index 0000000..935c540 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Admin/UsersAdminController.cs @@ -0,0 +1,328 @@ +using System.Globalization; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Admin.Dto; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.Admin; + +/// +/// Host/admin context: who has an account, what site role they hold, and which courses they are assigned to. +/// Site roles come from ASP.NET Identity (); course assignments are +/// rows, which is what the CourseMember policy reads. +/// +/// Two self-inflicted lockouts are refused: an admin cannot drop their own Admin role, and cannot delete +/// their own account. +/// +[ApiController] +[Route("api/admin/users")] +[Authorize(Roles = Roles.Admin)] +public sealed class UsersAdminController : ControllerBase +{ + private const int MaxPageSize = 200; + + private readonly ApplicationDbContext db; + private readonly UserManager userManager; + + public UsersAdminController(ApplicationDbContext db, UserManager userManager) + { + this.db = db; + this.userManager = userManager; + } + + /// + /// Lists users, newest account last. matches user name, display name, e-mail or + /// Neptun code; narrows to the members of one course. + /// + [HttpGet] + [ProducesResponseType(typeof(UserListResponse), StatusCodes.Status200OK)] + public async Task> List( + [FromQuery] string? search, + [FromQuery] int? courseId, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken cancellationToken = default) + { + var query = db.Users.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + query = query.Where(u => + (u.UserName != null && u.UserName.Contains(term)) || + (u.DisplayName != null && u.DisplayName.Contains(term)) || + (u.Email != null && u.Email.Contains(term)) || + (u.NeptunCode != null && u.NeptunCode.Contains(term))); + } + + if (courseId is int id) + query = query.Where(u => u.CourseMemberships.Any(m => m.CourseId == id)); + + var total = await query.CountAsync(cancellationToken); + + var users = await query + .OrderBy(u => u.UserName) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, MaxPageSize)) + .Select(u => new + { + User = u, + Courses = u.CourseMemberships + .Select(m => new UserCourseDto { CourseId = m.CourseId, Slug = m.Course!.Slug, Name = m.Course.Name, Role = m.Role }) + .ToList(), + }) + .ToListAsync(cancellationToken); + + // Roles come from Identity's join tables; one query for the page beats one per user. + var userIds = users.Select(u => u.User.Id).ToList(); + var rolesByUser = await db.UserRoles + .AsNoTracking() + .Where(ur => userIds.Contains(ur.UserId)) + .Join(db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, RoleName = r.Name! }) + .ToListAsync(cancellationToken); + + var items = users.Select(u => ToDto( + u.User, + rolesByUser.Where(r => r.UserId == u.User.Id).Select(r => r.RoleName).OrderBy(r => r, StringComparer.Ordinal).ToList(), + u.Courses.OrderBy(c => c.Slug, StringComparer.Ordinal).ToList())) + .ToList(); + + return Ok(new UserListResponse { Items = items, Total = total }); + } + + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id, CancellationToken cancellationToken) + { + var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + return user is null ? NotFound() : Ok(await LoadDtoAsync(user, cancellationToken)); + } + + /// Creates a local (password) account. Directory users are created on their first OIDC sign-in. + [HttpPost] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken) + { + var neptun = NormalizeNeptun(request.NeptunCode); + if (neptun is not null && await IsNeptunTakenAsync(neptun, excludeUserId: null, cancellationToken)) + return BadRequest(new { error = $"The Neptun code {neptun} is already assigned to another user." }); + + var user = new ApplicationUser + { + UserName = request.UserName.Trim(), + Email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim(), + DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) ? null : request.DisplayName.Trim(), + NeptunCode = neptun, + EmailConfirmed = true, + }; + + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + return BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + + return CreatedAtAction(nameof(Get), new { id = user.Id }, await LoadDtoAsync(user, cancellationToken)); + } + + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update(int id, [FromBody] UpdateUserRequest request, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(id.ToString(CultureInfo.InvariantCulture)); + if (user is null) + return NotFound(); + + var neptun = NormalizeNeptun(request.NeptunCode); + if (neptun is not null && await IsNeptunTakenAsync(neptun, excludeUserId: user.Id, cancellationToken)) + return BadRequest(new { error = $"The Neptun code {neptun} is already assigned to another user." }); + + user.DisplayName = Trimmed(request.DisplayName); + user.NeptunCode = neptun; + + var email = Trimmed(request.Email); + if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + { + var emailResult = await userManager.SetEmailAsync(user, email); + if (!emailResult.Succeeded) + return BadRequest(new { errors = emailResult.Errors.Select(e => e.Description) }); + } + + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + return BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + + return Ok(await LoadDtoAsync(user, cancellationToken)); + } + + /// Replaces the user's site roles with exactly the set given. + [HttpPut("{id:int}/roles")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateRoles(int id, [FromBody] UpdateUserRolesRequest request, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(id.ToString(CultureInfo.InvariantCulture)); + if (user is null) + return NotFound(); + + var requested = request.Roles.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.Ordinal).ToList(); + + var unknown = requested.Where(r => !Roles.All.Contains(r, StringComparer.Ordinal)).ToList(); + if (unknown.Count > 0) + return BadRequest(new { error = $"Unknown role: {string.Join(", ", unknown)}." }); + + if (IsSelf(user) && !requested.Contains(Roles.Admin, StringComparer.Ordinal)) + return BadRequest(new { error = "You cannot remove your own Admin role. Ask another administrator to do it." }); + + var current = await userManager.GetRolesAsync(user); + + var toRemove = current.Except(requested, StringComparer.Ordinal).ToList(); + if (toRemove.Count > 0) + { + var removeResult = await userManager.RemoveFromRolesAsync(user, toRemove); + if (!removeResult.Succeeded) + return BadRequest(new { errors = removeResult.Errors.Select(e => e.Description) }); + } + + var toAdd = requested.Except(current, StringComparer.Ordinal).ToList(); + if (toAdd.Count > 0) + { + var addResult = await userManager.AddToRolesAsync(user, toAdd); + if (!addResult.Succeeded) + return BadRequest(new { errors = addResult.Errors.Select(e => e.Description) }); + } + + return Ok(await LoadDtoAsync(user, cancellationToken)); + } + + /// Assigns the user to a course, or changes the role they hold in it. + [HttpPut("{id:int}/courses")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpsertCourse(int id, [FromBody] UpsertUserCourseRequest request, CancellationToken cancellationToken) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (user is null) + return NotFound(); + + if (!await db.Courses.AnyAsync(c => c.Id == request.CourseId, cancellationToken)) + return NotFound(new { error = "No such course." }); + + var membership = await db.CourseMemberships + .FirstOrDefaultAsync(m => m.UserId == id && m.CourseId == request.CourseId, cancellationToken); + + if (membership is null) + db.CourseMemberships.Add(new CourseMembership { UserId = id, CourseId = request.CourseId, Role = request.Role }); + else + membership.Role = request.Role; + + await db.SaveChangesAsync(cancellationToken); + return Ok(await LoadDtoAsync(user, cancellationToken)); + } + + [HttpDelete("{id:int}/courses/{courseId:int}")] + [ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> RemoveCourse(int id, int courseId, CancellationToken cancellationToken) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (user is null) + return NotFound(); + + var membership = await db.CourseMemberships + .FirstOrDefaultAsync(m => m.UserId == id && m.CourseId == courseId, cancellationToken); + + if (membership is not null) + { + db.CourseMemberships.Remove(membership); + await db.SaveChangesAsync(cancellationToken); + } + + return Ok(await LoadDtoAsync(user, cancellationToken)); + } + + /// Sets a new password for a local account, e.g. after a support request. + [HttpPost("{id:int}/password")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetPassword(int id, [FromBody] SetPasswordRequest request) + { + var user = await userManager.FindByIdAsync(id.ToString(CultureInfo.InvariantCulture)); + if (user is null) + return NotFound(); + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + var result = await userManager.ResetPasswordAsync(user, token, request.NewPassword); + + return result.Succeeded + ? NoContent() + : BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + } + + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var user = await userManager.FindByIdAsync(id.ToString(CultureInfo.InvariantCulture)); + if (user is null) + return NotFound(); + + if (IsSelf(user)) + return BadRequest(new { error = "You cannot delete your own account." }); + + var result = await userManager.DeleteAsync(user); + return result.Succeeded + ? NoContent() + : BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + } + + private bool IsSelf(ApplicationUser user) => + string.Equals(userManager.GetUserId(User), user.Id.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal); + + private async Task LoadDtoAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var roles = await userManager.GetRolesAsync(user); + var courses = await db.CourseMemberships + .AsNoTracking() + .Where(m => m.UserId == user.Id) + .OrderBy(m => m.Course!.Slug) + .Select(m => new UserCourseDto { CourseId = m.CourseId, Slug = m.Course!.Slug, Name = m.Course.Name, Role = m.Role }) + .ToListAsync(cancellationToken); + + return ToDto(user, roles.OrderBy(r => r, StringComparer.Ordinal).ToList(), courses); + } + + private static UserDto ToDto(ApplicationUser user, IReadOnlyList roles, IReadOnlyList courses) => new() + { + Id = user.Id, + UserName = user.UserName ?? string.Empty, + Email = user.Email, + DisplayName = user.DisplayName, + NeptunCode = user.NeptunCode, + Affiliation = user.Affiliation, + Roles = roles, + Courses = courses, + IsExternal = user.PasswordHash is null, + IsLockedOut = user.LockoutEnd is not null && user.LockoutEnd > DateTimeOffset.UtcNow, + }; + + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// Canonical Neptun code, or null for a blank one — never "", so the filtered unique index treats + /// "no code" as absent rather than a shared value. + private static string? NormalizeNeptun(string? value) => + string.IsNullOrWhiteSpace(value) ? null : Normalize.Neptun(value); + + /// True when another user already holds this (already-normalized) Neptun code. + private Task IsNeptunTakenAsync(string neptun, int? excludeUserId, CancellationToken cancellationToken) => + db.Users.AnyAsync(u => u.NeptunCode == neptun && (excludeUserId == null || u.Id != excludeUserId), cancellationToken); +} diff --git a/ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj b/ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj new file mode 100644 index 0000000..ed9b33e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/ahk-backend/Ahk.Web.Server/Auth/AuthController.cs b/ahk-backend/Ahk.Web.Server/Auth/AuthController.cs new file mode 100644 index 0000000..8e12ed6 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/AuthController.cs @@ -0,0 +1,176 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Auth.Dto; +using Ahk.Web.Server.Configuration; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Ahk.Web.Server.Auth; + +/// Local username/password authentication and the current-user endpoint for the SPA. +[ApiController] +[Route("api/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly SignInManager signInManager; + private readonly UserManager userManager; + private readonly ApplicationDbContext db; + private readonly OidcOptions oidcOptions; + + public AuthController( + SignInManager signInManager, + UserManager userManager, + ApplicationDbContext db, + IOptions oidcOptions) + { + this.signInManager = signInManager; + this.userManager = userManager; + this.db = db; + this.oidcOptions = oidcOptions.Value; + } + + [HttpPost("login")] + [ProducesResponseType(typeof(CurrentUserResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(LoginFailureResponse), StatusCodes.Status401Unauthorized)] + public async Task> Login([FromBody] LoginRequest request) + { + var result = await signInManager.PasswordSignInAsync(request.UserName, request.Password, request.RememberMe, lockoutOnFailure: true); + + // Lockout and a wrong password share the 401, but they need different words: one is "try again", + // the other is "wait, or ask an administrator". + if (result.IsLockedOut) + { + return Unauthorized(new LoginFailureResponse + { + Reason = "LockedOut", + Error = "This account is temporarily locked after too many failed attempts. Try again in a few minutes.", + }); + } + + if (result.IsNotAllowed) + { + return Unauthorized(new LoginFailureResponse + { + Reason = "NotAllowed", + Error = "This account is not allowed to sign in. Ask an administrator to check it.", + }); + } + + if (!result.Succeeded) + { + return Unauthorized(new LoginFailureResponse + { + Reason = "InvalidCredentials", + Error = "That username and password do not match an account.", + }); + } + + var user = await userManager.FindByNameAsync(request.UserName); + return Ok(await BuildCurrentUserAsync(user!)); + } + + /// + /// Clears the portal session. Returns the provider's end-session URL when one is configured, which the SPA + /// then navigates to; the BME IdP does not advertise end_session_endpoint, so today this is null and + /// sign-out is local-only (the SSO session at the IdP survives). + /// + [HttpPost("logout")] + [Authorize] + [ProducesResponseType(typeof(LogoutResponse), StatusCodes.Status200OK)] + public async Task> Logout() + { + await signInManager.SignOutAsync(); + + var endSession = oidcOptions.EndSessionEndpoint; + if (string.IsNullOrWhiteSpace(endSession)) + return Ok(new LogoutResponse { EndSessionUrl = null }); + + var postLogout = oidcOptions.PostLogoutRedirectUri; + var url = string.IsNullOrWhiteSpace(postLogout) + ? endSession + : $"{endSession}{(endSession.Contains('?', StringComparison.Ordinal) ? "&" : "?")}post_logout_redirect_uri={Uri.EscapeDataString(postLogout)}"; + + return Ok(new LogoutResponse { EndSessionUrl = url }); + } + + [HttpPost("register")] + [ProducesResponseType(typeof(CurrentUserResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Register([FromBody] RegisterRequest request) + { + var user = new ApplicationUser { UserName = request.UserName, Email = request.Email, DisplayName = request.DisplayName }; + var result = await userManager.CreateAsync(user, request.Password); + if (!result.Succeeded) + return BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + + await signInManager.SignInAsync(user, isPersistent: false); + return Ok(await BuildCurrentUserAsync(user)); + } + + [HttpGet("me")] + [Authorize] + [ProducesResponseType(typeof(CurrentUserResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Me() + { + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + return Ok(await BuildCurrentUserAsync(user)); + } + + private async Task BuildCurrentUserAsync(ApplicationUser user) + { + var roles = await userManager.GetRolesAsync(user); + + var memberships = await db.CourseMemberships + .AsNoTracking() + .Where(m => m.UserId == user.Id) + .Select(m => new CourseMembershipDto + { + Slug = m.Course!.Slug, + Name = m.Course.Name, + Role = m.Role.ToString(), + }) + .ToListAsync(); + + // A site admin may open any course (CourseMembershipAuthorizationHandler says so), so the switcher has + // to list them all — otherwise the instructor screens are unreachable for courses they do not staff. + // Explicit memberships win, keeping the role the admin actually holds in their own courses. + var courses = memberships; + if (roles.Contains(Roles.Admin, StringComparer.Ordinal)) + { + var assigned = memberships.Select(m => m.Slug).ToHashSet(StringComparer.Ordinal); + var rest = await db.Courses + .AsNoTracking() + .Where(c => !assigned.Contains(c.Slug)) + .OrderBy(c => c.Slug) + .Select(c => new CourseMembershipDto + { + Slug = c.Slug, + Name = c.Name, + Role = CourseRole.Admin.ToString(), + ViaSiteAdmin = true, + }) + .ToListAsync(); + + courses = memberships.Concat(rest).OrderBy(c => c.Slug, StringComparer.Ordinal).ToList(); + } + + return new CurrentUserResponse + { + UserId = user.Id, + UserName = user.UserName ?? string.Empty, + Email = user.Email, + DisplayName = user.DisplayName, + NeptunCode = user.NeptunCode, + GitHubUsername = user.GitHubUsername, + Roles = roles.ToList(), + Courses = courses, + }; + } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/BmeClaimTypes.cs b/ahk-backend/Ahk.Web.Server/Auth/BmeClaimTypes.cs new file mode 100644 index 0000000..f6ebe3b --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/BmeClaimTypes.cs @@ -0,0 +1,14 @@ +namespace Ahk.Web.Server.Auth; + +/// +/// Claim names published by the BME IdP (see its claims_supported). Used both for the OIDC claim +/// mappings and when syncing an external identity onto the local user. +/// +public static class BmeClaimTypes +{ + /// Student/staff Neptun code — the key of the domain model. + public const string NeptunCode = "neptun_code"; + + /// Multi-valued, e.g. ["staff@bme.hu", "employee@bme.hu"]. + public const string Affiliation = "eduperson_scoped_affiliation"; +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/Dto/CurrentUserResponse.cs b/ahk-backend/Ahk.Web.Server/Auth/Dto/CurrentUserResponse.cs new file mode 100644 index 0000000..ee73e73 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/Dto/CurrentUserResponse.cs @@ -0,0 +1,45 @@ +namespace Ahk.Web.Server.Auth.Dto; + +/// Shape returned by GET /api/auth/me — hydrates the SPA session with identity + course access. +public sealed class CurrentUserResponse +{ + public int UserId { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string? Email { get; set; } + + public string? DisplayName { get; set; } + + /// + /// From the IdP's neptun_code claim. The invite flow needs it to name a student's repository, and + /// the SPA needs to know whether it is there before offering to accept an assignment. + /// + public string? NeptunCode { get; set; } + + /// Verified GitHub login, or null while the user has not supplied one. + public string? GitHubUsername { get; set; } + + public IReadOnlyList Roles { get; set; } = Array.Empty(); + + /// + /// Every course this user can open, which is what the course switcher lists. For a site admin that is all + /// courses, matching CourseMembershipAuthorizationHandler, which lets admins into any course. + /// + public IReadOnlyList Courses { get; set; } = Array.Empty(); +} + +public sealed class CourseMembershipDto +{ + public string Slug { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Role { get; set; } = string.Empty; + + /// + /// True when access comes from the site-admin role rather than a membership record. The UI marks these so + /// an admin can see they are working inside a course they were not explicitly assigned to. + /// + public bool ViaSiteAdmin { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/Dto/LoginRequest.cs b/ahk-backend/Ahk.Web.Server/Auth/Dto/LoginRequest.cs new file mode 100644 index 0000000..ba5e20c --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/Dto/LoginRequest.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ahk.Web.Server.Auth.Dto; + +public sealed class LoginRequest +{ + [Required] + public string UserName { get; set; } = string.Empty; + + [Required] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } +} + +/// +/// Why a sign-in was refused. Sent with the 401 so the SPA can say what actually happened — a locked-out +/// account and a mistyped password are the same status code but very different things to be told. +/// +public sealed class LoginFailureResponse +{ + /// Machine-readable: InvalidCredentials, LockedOut or NotAllowed. + public string Reason { get; set; } = string.Empty; + + /// Sentence shown to the person signing in. + public string Error { get; set; } = string.Empty; +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/Dto/LogoutResponse.cs b/ahk-backend/Ahk.Web.Server/Auth/Dto/LogoutResponse.cs new file mode 100644 index 0000000..886a0bc --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/Dto/LogoutResponse.cs @@ -0,0 +1,11 @@ +namespace Ahk.Web.Server.Auth.Dto; + +/// Result of signing out of the portal. +public sealed class LogoutResponse +{ + /// + /// Provider end-session URL the SPA should navigate to for a full single sign-out, or null when the + /// provider has none configured (the current case for the BME IdP) and sign-out is local-only. + /// + public string? EndSessionUrl { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/Dto/RegisterRequest.cs b/ahk-backend/Ahk.Web.Server/Auth/Dto/RegisterRequest.cs new file mode 100644 index 0000000..be809cc --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/Dto/RegisterRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Ahk.Web.Server.Auth.Dto; + +public sealed class RegisterRequest +{ + [Required] + public string UserName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + public string Password { get; set; } = string.Empty; + + public string? DisplayName { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/ExternalAuthController.cs b/ahk-backend/Ahk.Web.Server/Auth/ExternalAuthController.cs new file mode 100644 index 0000000..95d2c59 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/ExternalAuthController.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Ahk.Web.Server.Auth; + +/// +/// Generic OIDC external login. The SPA navigates the browser to challenge, which redirects to the OIDC +/// provider; the provider posts back to the OIDC handler (SignInScheme = external cookie) which returns to +/// callback. There the external identity is linked/created and the Identity application cookie is issued, +/// then the browser is redirected back into the SPA. +/// +[ApiController] +[Route("api/auth/external")] +public sealed class ExternalAuthController : ControllerBase +{ + public const string Scheme = "oidc"; + + private readonly SignInManager signInManager; + private readonly UserManager userManager; + private readonly OidcOptions oidcOptions; + private readonly ILogger logger; + + public ExternalAuthController( + SignInManager signInManager, + UserManager userManager, + IOptions oidcOptions, + ILogger logger) + { + this.signInManager = signInManager; + this.userManager = userManager; + this.oidcOptions = oidcOptions.Value; + this.logger = logger; + } + + [HttpGet("challenge")] + public IActionResult Challenge([FromQuery] string? returnUrl) + { + if (!oidcOptions.IsEnabled) + return BadRequest(new { error = "OIDC is not configured." }); + + var redirectUrl = Url.Action(nameof(Callback), values: new { returnUrl }); + var properties = signInManager.ConfigureExternalAuthenticationProperties(Scheme, redirectUrl); + return Challenge(properties, Scheme); + } + + [HttpGet("callback")] + public async Task Callback([FromQuery] string? returnUrl) + { + var info = await signInManager.GetExternalLoginInfoAsync(); + if (info is null) + return Redirect(SafeReturnUrl(returnUrl, error: "external_login_failed")); + + var signInResult = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: true, bypassTwoFactor: true); + if (signInResult.Succeeded) + { + // Known identity: refresh the directory data, which may have changed since the last login. + var existing = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); + if (existing is not null && ExternalClaimsMapper.SyncFromClaims(existing, info.Principal)) + await userManager.UpdateAsync(existing); + + return Redirect(SafeReturnUrl(returnUrl)); + } + + // First time this external identity is seen. Identify the person by Neptun code — the domain key — + // not by email/username, so an account an admin pre-provisioned with just a Neptun code is linked + // rather than duplicated. + var neptunClaim = info.Principal.FindFirstValue(BmeClaimTypes.NeptunCode); + var neptun = string.IsNullOrWhiteSpace(neptunClaim) ? null : Normalize.Neptun(neptunClaim); + + var user = neptun is null + ? null + : await userManager.Users.FirstOrDefaultAsync(u => u.NeptunCode == neptun); + + if (user is null) + { + // No account carries this Neptun code (or the IdP sent none): provision a fresh local user. + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + user = new ApplicationUser { UserName = email ?? BuildUserName(info), EmailConfirmed = true }; + ExternalClaimsMapper.SyncFromClaims(user, info.Principal); + + var created = await userManager.CreateAsync(user); + if (!created.Succeeded) + { + logger.LogError( + "Provisioning a user for external login {Provider}/{ProviderKey} failed: {Errors}", + info.LoginProvider, + info.ProviderKey, + string.Join("; ", created.Errors.Select(e => $"{e.Code}: {e.Description}"))); + + return Redirect(SafeReturnUrl(returnUrl, error: "user_creation_failed")); + } + } + else + { + // Existing account matched by Neptun code (e.g. admin-created): attach this login to it. The + // username is never written by the claim sync, so it is preserved; email is refreshed. + if (ExternalClaimsMapper.SyncFromClaims(user, info.Principal)) + await userManager.UpdateAsync(user); + } + + await userManager.AddLoginAsync(user, info); + await signInManager.SignInAsync(user, isPersistent: true); + + return Redirect(SafeReturnUrl(returnUrl)); + } + + /// + /// Landing point after a provider-initiated sign-out, matching the registered + /// post_logout_redirect_uris (https://ahk.aut.bme.hu/signout-callback-oidc). Unused while the BME IdP + /// advertises no end-session endpoint, but registered and wired so enabling it needs no code change. + /// + [HttpGet("/signout-callback-oidc")] + [AllowAnonymous] + public IActionResult SignedOut([FromQuery] string? returnUrl) + => Redirect(SafeReturnUrl(returnUrl)); + + /// + /// Fallback username when the provider returns no email. Identity's AllowedUserNameCharacters excludes ':', + /// so the provider/subject pair is sanitized rather than concatenated raw. + /// + private static string BuildUserName(ExternalLoginInfo info) + { + var raw = $"{info.LoginProvider}-{info.ProviderKey}"; + var safe = new string(raw.Select(c => char.IsLetterOrDigit(c) || c is '-' or '.' or '_' or '@' or '+' ? c : '-').ToArray()); + return safe; + } + + // Only allow relative return paths to avoid open-redirects; default to the SPA root. + private string SafeReturnUrl(string? returnUrl, string? error = null) + { + var target = Url.IsLocalUrl(returnUrl) ? returnUrl! : "/"; + if (error is null) + return target; + + return target + (target.Contains('?', StringComparison.Ordinal) ? "&" : "?") + "error=" + Uri.EscapeDataString(error); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/ExternalClaimsMapper.cs b/ahk-backend/Ahk.Web.Server/Auth/ExternalClaimsMapper.cs new file mode 100644 index 0000000..a739aa7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/ExternalClaimsMapper.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Server.Auth; + +/// +/// Projects an external (BME OIDC) identity onto the local user record. Applied on every login, not just at +/// creation, so a user's directory data stays current. +/// +public static class ExternalClaimsMapper +{ + /// Copies the mapped claims onto . Returns true when anything changed. + public static bool SyncFromClaims(ApplicationUser user, ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(principal); + + var changed = false; + + var email = principal.FindFirstValue(ClaimTypes.Email); + if (!string.IsNullOrWhiteSpace(email) && !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + { + user.Email = email; + changed = true; + } + + var displayName = ResolveDisplayName(principal); + if (!string.IsNullOrWhiteSpace(displayName) && !string.Equals(user.DisplayName, displayName, StringComparison.Ordinal)) + { + user.DisplayName = displayName; + changed = true; + } + + // Absent claims must not wipe existing values — a sparse directory account should not clear a known code. + var neptun = principal.FindFirstValue(BmeClaimTypes.NeptunCode); + if (!string.IsNullOrWhiteSpace(neptun) && !string.Equals(user.NeptunCode, neptun, StringComparison.OrdinalIgnoreCase)) + { + user.NeptunCode = neptun.ToUpperInvariant(); + changed = true; + } + + var affiliation = ResolveAffiliation(principal); + if (!string.IsNullOrWhiteSpace(affiliation) && !string.Equals(user.Affiliation, affiliation, StringComparison.Ordinal)) + { + user.Affiliation = affiliation; + changed = true; + } + + return changed; + } + + /// Prefers the provider's name, otherwise composes it from given/family name. + public static string? ResolveDisplayName(ClaimsPrincipal principal) + { + var name = principal.FindFirstValue(ClaimTypes.Name) ?? principal.FindFirstValue("name"); + if (!string.IsNullOrWhiteSpace(name)) + return name; + + var given = principal.FindFirstValue(ClaimTypes.GivenName) ?? principal.FindFirstValue("given_name"); + var family = principal.FindFirstValue(ClaimTypes.Surname) ?? principal.FindFirstValue("family_name"); + var composed = string.Join(' ', new[] { given, family }.Where(s => !string.IsNullOrWhiteSpace(s))); + + return string.IsNullOrWhiteSpace(composed) ? null : composed; + } + + /// + /// eduperson_scoped_affiliation is multi-valued. The claim action joins array values, but a provider may + /// also emit repeated claims — handle both and normalize to a single ';'-separated string. + /// + public static string? ResolveAffiliation(ClaimsPrincipal principal) + { + var values = principal.FindAll(BmeClaimTypes.Affiliation) + .SelectMany(c => c.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return values.Length == 0 ? null : string.Join(';', values); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Auth/ProfileController.cs b/ahk-backend/Ahk.Web.Server/Auth/ProfileController.cs new file mode 100644 index 0000000..e8e189a --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Auth/ProfileController.cs @@ -0,0 +1,117 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.Auth; + +public sealed class SetGitHubUsernameRequest +{ + public string GitHubUsername { get; set; } = string.Empty; + + /// + /// Optional course whose GitHub App token pays for the lookup. Purely a rate-limit matter — the call works + /// unauthenticated too, at 60 requests an hour instead of 5000. The invite page always has a course to name. + /// + public string? CourseSlug { get; set; } +} + +public sealed class GitHubProfileResponse +{ + public string GitHubUsername { get; set; } = string.Empty; + + public long? GitHubUserId { get; set; } +} + +/// +/// The parts of a user's own profile they maintain themselves. Today that is one thing: their GitHub account, +/// which the portal needs before it can hand them a repository. +/// +[ApiController] +[Route("api/profile")] +[Authorize] +public sealed class ProfileController : ControllerBase +{ + private readonly UserManager userManager; + private readonly IGitHubRepositoryService gitHub; + private readonly ICourseGitHubAppTokenProvider tokens; + private readonly ApplicationDbContext db; + + public ProfileController( + UserManager userManager, + IGitHubRepositoryService gitHub, + ICourseGitHubAppTokenProvider tokens, + ApplicationDbContext db) + { + this.userManager = userManager; + this.gitHub = gitHub; + this.tokens = tokens; + this.db = db; + } + + /// + /// Records the caller's GitHub login, after checking it exists. The check is GET /users/{login} + /// rather than a fetch of the profile page: a 404 there is unambiguous, and the response carries the + /// numeric account id, which survives the user renaming themselves later. + /// + [HttpPut("github")] + [ProducesResponseType(typeof(GitHubProfileResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> SetGitHubUsername([FromBody] SetGitHubUsernameRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + // A pasted profile URL is the obvious mistake to be forgiving about; everything else must be a login. + var login = request.GitHubUsername?.Trim().TrimEnd('/') ?? string.Empty; + var lastSlash = login.LastIndexOf('/'); + if (lastSlash >= 0) + login = login[(lastSlash + 1)..]; + + if (string.IsNullOrWhiteSpace(login)) + return BadRequest(new { error = "Enter your GitHub username." }); + + string? token = null; + if (!string.IsNullOrWhiteSpace(request.CourseSlug)) + { + var courseId = await db.Courses.AsNoTracking() + .Where(c => c.Slug == request.CourseSlug) + .Select(c => c.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (courseId != 0) + token = (await tokens.GetForCourseAsync(courseId, bypassCache: false, cancellationToken))?.Token; + } + + GitHubUser? account; + try + { + account = await gitHub.GetUserAsync(login, token, cancellationToken); + } + catch (Exception ex) when (ex is GitHubOperationException or HttpRequestException or TaskCanceledException) + { + return StatusCode(StatusCodes.Status502BadGateway, new { error = "GitHub could not be reached to check that username. Try again in a few minutes." }); + } + + if (account is null) + return BadRequest(new { error = $"There is no GitHub user called \"{login}\". Check the spelling — it is the name in your profile URL, github.com/." }); + + // Store GitHub's own casing, so the value shown back matches the account exactly. + user.GitHubUsername = account.Login; + user.GitHubUserId = account.Id; + + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + return BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + + return Ok(new GitHubProfileResponse { GitHubUsername = account.Login, GitHubUserId = account.Id }); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Configuration/OidcOptions.cs b/ahk-backend/Ahk.Web.Server/Configuration/OidcOptions.cs new file mode 100644 index 0000000..fad7627 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Configuration/OidcOptions.cs @@ -0,0 +1,62 @@ +namespace Ahk.Web.Server.Configuration; + +/// +/// OpenID Connect provider settings (configuration section Authentication:Oidc). Written against BME's +/// Shibboleth IdP (https://idp.bme.hu) but kept generic. +/// +/// When / are empty the external login handler is not registered +/// and OIDC is disabled, so the app runs with local username/password only. +/// +public sealed class OidcOptions +{ + public const string SectionName = "Authentication:Oidc"; + + public string? Authority { get; set; } + + public string? ClientId { get; set; } + + /// Never commit this — use `dotnet user-secrets` locally and Authentication__Oidc__ClientSecret in production. + public string? ClientSecret { get; set; } + + /// + /// Exact scopes to request. Must stay within what the client is registered for — requesting an + /// unregistered scope (e.g. profile, which BME did not register for us) is a common rejection cause. + /// offline_access is registered but omitted by default: we mint our own Identity cookie and never + /// use refresh tokens. + /// + public string[] Scopes { get; set; } = new[] { "openid", "email", "userinfo" }; + + /// + /// Off by default: the BME IdP does not advertise code_challenge_methods_supported, and we are a + /// confidential client (client_secret_post), so PKCE is defence-in-depth rather than required. + /// + public bool UsePkce { get; set; } + + /// + /// query by default. The ASP.NET default of form_post makes the callback a cross-site POST, + /// which drops the correlation cookie under SameSite=Lax and fails with "Correlation failed". + /// + public string ResponseMode { get; set; } = "query"; + + /// We exchange the external identity for our own cookie, so the provider's tokens are not kept. + public bool SaveTokens { get; set; } + + /// + /// Absolute redirect_uri override. Needed in development because the Angular proxy rewrites the Host, so the + /// computed value would point at the backend port instead of the browser's origin. + /// + public string? RedirectUri { get; set; } + + public string? PostLogoutRedirectUri { get; set; } + + /// + /// RP-initiated logout endpoint. The BME IdP does not advertise end_session_endpoint, so this is empty + /// and logout is local-only; setting it later enables full sign-out with no code change. + /// + public string? EndSessionEndpoint { get; set; } + + /// Development only: serve an in-app mock OpenID provider at /mock-oidc (see MockOidc/). + public bool UseMockProvider { get; set; } + + public bool IsEnabled => !string.IsNullOrWhiteSpace(Authority) && !string.IsNullOrWhiteSpace(ClientId); +} diff --git a/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipAuthorizationHandler.cs b/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipAuthorizationHandler.cs new file mode 100644 index 0000000..ff3b320 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipAuthorizationHandler.cs @@ -0,0 +1,49 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.CourseContext; + +/// +/// Grants the CourseMember policy when the authenticated user has a in the +/// resolved current course. Site admins () are allowed into any course. +/// +public sealed class CourseMembershipAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentCourseProvider currentCourse; + private readonly ApplicationDbContext db; + private readonly UserManager userManager; + + public CourseMembershipAuthorizationHandler(ICurrentCourseProvider currentCourse, ApplicationDbContext db, UserManager userManager) + { + this.currentCourse = currentCourse; + this.db = db; + this.userManager = userManager; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CourseMembershipRequirement requirement) + { + if (currentCourse.CurrentCourseId is not int courseId) + return; + + if (context.User.Identity?.IsAuthenticated != true) + return; + + if (context.User.IsInRole(Roles.Admin)) + { + context.Succeed(requirement); + return; + } + + if (!int.TryParse(userManager.GetUserId(context.User), out var userId)) + return; + + var isMember = await db.CourseMemberships.AsNoTracking() + .AnyAsync(m => m.CourseId == courseId && m.UserId == userId); + + if (isMember) + context.Succeed(requirement); + } +} diff --git a/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipRequirement.cs b/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipRequirement.cs new file mode 100644 index 0000000..36235de --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/CourseContext/CourseMembershipRequirement.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Ahk.Web.Server.CourseContext; + +/// Requires the current user to be a member of the resolved current course (site admins bypass). +public sealed class CourseMembershipRequirement : IAuthorizationRequirement +{ + public const string PolicyName = "CourseMember"; +} diff --git a/ahk-backend/Ahk.Web.Server/CourseContext/CourseResolutionMiddleware.cs b/ahk-backend/Ahk.Web.Server/CourseContext/CourseResolutionMiddleware.cs new file mode 100644 index 0000000..692a41e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/CourseContext/CourseResolutionMiddleware.cs @@ -0,0 +1,42 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.CourseContext; + +/// +/// Resolves the {course} route segment (present on /api/{course}/... routes) to a , +/// stores it in HttpContext.Items["Course"], and sets the request's +/// so the DbContext course filter applies. Returns 404 when the slug does not match a course. Routes without a +/// {course} segment (auth, admin, integrations) pass through untouched. +/// +/// Runs after routing (needs route values) and after authentication. Course *membership* is enforced separately +/// by via the CourseMember policy. +/// +public sealed class CourseResolutionMiddleware +{ + public const string CourseRouteKey = "course"; + public const string CourseItemKey = "Course"; + + private readonly RequestDelegate next; + + public CourseResolutionMiddleware(RequestDelegate next) => this.next = next; + + public async Task InvokeAsync(HttpContext context, ApplicationDbContext db, CurrentCourseProvider currentCourse) + { + if (context.GetRouteValue(CourseRouteKey) is string slug && !string.IsNullOrEmpty(slug)) + { + var course = await db.Courses.AsNoTracking().FirstOrDefaultAsync(c => c.Slug == slug); + if (course is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + currentCourse.Set(course.Id); + context.Items[CourseItemKey] = course; + } + + await this.next(context); + } +} diff --git a/ahk-backend/Ahk.Web.Server/CourseContext/CurrentCourseProvider.cs b/ahk-backend/Ahk.Web.Server/CourseContext/CurrentCourseProvider.cs new file mode 100644 index 0000000..89ea2db --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/CourseContext/CurrentCourseProvider.cs @@ -0,0 +1,14 @@ +using Ahk.Web.Data; + +namespace Ahk.Web.Server.CourseContext; + +/// +/// Scoped per request. sets the active course from the {course} route +/// segment; reads it to apply the course query filter. +/// +public sealed class CurrentCourseProvider : ICurrentCourseProvider +{ + public int? CurrentCourseId { get; private set; } + + public void Set(int courseId) => CurrentCourseId = courseId; +} diff --git a/ahk-backend/Ahk.Web.Server/Courses/AssignmentInviteController.cs b/ahk-backend/Ahk.Web.Server/Courses/AssignmentInviteController.cs new file mode 100644 index 0000000..ad8e9f8 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Courses/AssignmentInviteController.cs @@ -0,0 +1,97 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Services.Assignments; +using Ahk.Web.Services.GitHub; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Courses; + +/// +/// The student side of an assignment: follow the invite link, confirm, get a repository. Replaces GitHub +/// Classroom's accept flow. +/// +/// ⚠️ [Authorize] without the CourseMember policy, deliberately. Students are not members of the +/// course — accepting is how they first appear in it at all — so requiring membership here would lock every +/// student out of the one endpoint meant for them. still resolves the +/// {course} segment, so the course query filter behaves normally; the invite token is the capability that +/// authorizes the request. +/// +[ApiController] +[Route("api/{course}/invite/{token}")] +[Authorize] +public sealed class AssignmentInviteController : ControllerBase +{ + private readonly IAssignmentInviteService invites; + private readonly UserManager userManager; + private readonly ILogger logger; + + public AssignmentInviteController( + IAssignmentInviteService invites, + UserManager userManager, + ILogger logger) + { + this.invites = invites; + this.userManager = userManager; + this.logger = logger; + } + + /// Where this student stands: what is still missing, or the repository they already have. + [HttpGet] + [ProducesResponseType(typeof(InviteState), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> Get(string token, CancellationToken cancellationToken) + { + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + var course = CurrentCourse(); + return Ok(await invites.GetStateAsync(course.Id, token, user, cancellationToken)); + } + + /// + /// Creates the student's repository and grants them access. Idempotent: accepting twice returns the same + /// repository rather than creating a second one. + /// + [HttpPost("accept")] + [ProducesResponseType(typeof(InviteState), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> Accept(string token, CancellationToken cancellationToken) + { + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + var course = CurrentCourse(); + + try + { + return Ok(await invites.AcceptAsync(course.Id, token, user, cancellationToken)); + } + catch (GitHubOperationException ex) + { + // GitHub refused something we asked for. Its own words are far more useful to the instructor who + // will be asked about it than "an error occurred". + logger.LogError(ex, "Accepting invite {Token} in course {Course} failed at GitHub.", token, course.Slug); + + return StatusCode(StatusCodes.Status502BadGateway, new + { + error = $"GitHub refused to set up the repository: {ex.GitHubMessage ?? ex.Message} Tell your instructor — this is a configuration problem, not something you did.", + }); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "Accepting invite {Token} in course {Course} could not reach GitHub.", token, course.Slug); + + return StatusCode(StatusCodes.Status502BadGateway, new + { + error = "GitHub could not be reached. Try again in a few minutes.", + }); + } + } + + private Course CurrentCourse() => (Course)HttpContext.Items[CourseResolutionMiddleware.CourseItemKey]!; +} diff --git a/ahk-backend/Ahk.Web.Server/Courses/AssignmentsController.cs b/ahk-backend/Ahk.Web.Server/Courses/AssignmentsController.cs new file mode 100644 index 0000000..79931c8 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Courses/AssignmentsController.cs @@ -0,0 +1,227 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.Courses.Dto; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Services.Assignments; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Courses; + +/// +/// Course-scoped assignment administration: what used to be set up in GitHub Classroom. Staff define an +/// assignment against a template repository and hand out its invite link; students provision themselves through +/// . +/// +/// Assignments are additive. Repositories created outside the portal keep working exactly as before, so nothing +/// here is a precondition for grading or status tracking. +/// +[ApiController] +[Route("api/{course}/assignments")] +[Authorize(Policy = CourseMembershipRequirement.PolicyName)] +public sealed class AssignmentsController : ControllerBase +{ + private readonly IAssignmentService assignments; + + public AssignmentsController(IAssignmentService assignments) => this.assignments = assignments; + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] bool includeArchived, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var items = await assignments.ListAsync(course.Id, includeArchived, cancellationToken); + var counts = await assignments.CountAcceptancesAsync(course.Id, cancellationToken); + + return Ok(items.Select(a => ToDto(a, course, counts.GetValueOrDefault(a.Id))).ToList()); + } + + /// + /// One assignment. additionally asks GitHub whether the template + /// repository exists and is marked as a template — a network call, so the editor opts into it rather than + /// every listing paying for it. + /// + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(AssignmentDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id, [FromQuery] bool checkTemplate, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var assignment = await assignments.GetAsync(course.Id, id, cancellationToken); + if (assignment is null) + return NotFound(); + + var acceptances = await assignments.ListAcceptancesAsync(course.Id, id, cancellationToken); + + return Ok(new AssignmentDetailDto + { + Assignment = ToDto(assignment, course, acceptances.Count), + Template = checkTemplate + ? TemplateCheckDto.From(await assignments.CheckTemplateAsync(course.Id, assignment.TemplateRepoName, cancellationToken)) + : null, + }); + } + + /// + /// Advisory template check for a repository name the editor is still typing, before the assignment is saved. + /// Same GitHub lookup as 's checkTemplate, but keyed on a name rather than a stored + /// assignment — so the check is available while creating, not only while editing. Never blocks anything. + /// + [HttpPost("check-template")] + [ProducesResponseType(typeof(TemplateCheckDto), StatusCodes.Status200OK)] + public async Task> CheckTemplate([FromBody] CheckTemplateRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var course = CurrentCourse(); + var check = await assignments.CheckTemplateAsync(course.Id, request.TemplateRepoName ?? string.Empty, cancellationToken); + + return Ok(TemplateCheckDto.From(check)); + } + + [HttpPost] + [ProducesResponseType(typeof(AssignmentDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] SaveAssignmentRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (Validate(request) is { } error) + return BadRequest(new { error }); + + var course = CurrentCourse(); + var assignment = await assignments.CreateAsync(course.Id, ToInput(request), cancellationToken); + + return CreatedAtAction(nameof(Get), new { course = course.Slug, id = assignment.Id }, ToDto(assignment, course, 0)); + } + + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(AssignmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Update(int id, [FromBody] SaveAssignmentRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (Validate(request) is { } error) + return BadRequest(new { error }); + + var course = CurrentCourse(); + var assignment = await assignments.UpdateAsync(course.Id, id, ToInput(request), cancellationToken); + + return assignment is null ? NotFound() : Ok(ToDto(assignment, course, 0)); + } + + [HttpPost("{id:int}/archive")] + [ProducesResponseType(typeof(AssignmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task> Archive(int id, CancellationToken cancellationToken) => + SetArchivedAsync(id, archived: true, cancellationToken); + + [HttpPost("{id:int}/unarchive")] + [ProducesResponseType(typeof(AssignmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task> Unarchive(int id, CancellationToken cancellationToken) => + SetArchivedAsync(id, archived: false, cancellationToken); + + /// Issues a new invite link. Every copy of the previous one stops working immediately. + [HttpPost("{id:int}/regenerate-invite")] + [ProducesResponseType(typeof(AssignmentDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> RegenerateInvite(int id, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var assignment = await assignments.RegenerateInviteTokenAsync(course.Id, id, cancellationToken); + + return assignment is null ? NotFound() : Ok(ToDto(assignment, course, 0)); + } + + /// + /// Deletes an assignment nobody has accepted. Once students hold repositories the record is the only trace + /// of who got what, so the API refuses and points at archiving instead. + /// + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int id, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + + var assignment = await assignments.GetAsync(course.Id, id, cancellationToken); + if (assignment is null) + return NotFound(); + + if (!await assignments.DeleteAsync(course.Id, id, cancellationToken)) + { + return Conflict(new + { + error = "Students have already accepted this assignment, so it cannot be deleted. Archive it instead — that closes the invite link and keeps their repositories linked.", + }); + } + + return NoContent(); + } + + [HttpGet("{id:int}/acceptances")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> ListAcceptances(int id, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var acceptances = await assignments.ListAcceptancesAsync(course.Id, id, cancellationToken); + + return Ok(acceptances.Select(a => new AssignmentAcceptanceDto + { + Id = a.Id, + UserName = a.User?.UserName ?? string.Empty, + DisplayName = a.User?.DisplayName, + NeptunCode = a.User?.NeptunCode, + GitHubUsername = a.GitHubUsername, + GitHubRepoName = a.GitHubRepoName, + RepoUrl = a.RepoUrl, + AcceptedAt = a.AcceptedAt, + InvitationPending = a.InvitationPending, + }).ToList()); + } + + private async Task> SetArchivedAsync(int id, bool archived, CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var assignment = await assignments.SetArchivedAsync(course.Id, id, archived, cancellationToken); + + return assignment is null ? NotFound() : Ok(ToDto(assignment, course, 0)); + } + + private static string? Validate(SaveAssignmentRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return "An assignment needs a name."; + + return string.IsNullOrWhiteSpace(request.TemplateRepoName) + ? "An assignment needs a template repository." + : null; + } + + private static AssignmentInput ToInput(SaveAssignmentRequest request) => new() + { + Name = request.Name, + Description = request.Description, + TemplateRepoName = request.TemplateRepoName, + RepoNamePrefix = request.RepoNamePrefix, + }; + + private AssignmentDto ToDto(Assignment assignment, Course course, int acceptanceCount) => new() + { + Id = assignment.Id, + Name = assignment.Name, + Description = assignment.Description, + TemplateRepoName = assignment.TemplateRepoName, + RepoNamePrefix = assignment.RepoNamePrefix, + InvitePath = $"/{course.Slug}/invite/{assignment.InviteToken}", + IsArchived = assignment.ArchivedAt is not null, + ArchivedAt = assignment.ArchivedAt, + CreatedAt = assignment.CreatedAt, + AcceptanceCount = acceptanceCount, + }; + + private Course CurrentCourse() => (Course)HttpContext.Items[CourseResolutionMiddleware.CourseItemKey]!; +} diff --git a/ahk-backend/Ahk.Web.Server/Courses/Dto/AssignmentDto.cs b/ahk-backend/Ahk.Web.Server/Courses/Dto/AssignmentDto.cs new file mode 100644 index 0000000..dedaf10 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Courses/Dto/AssignmentDto.cs @@ -0,0 +1,120 @@ +using Ahk.Web.Services.Assignments; + +namespace Ahk.Web.Server.Courses.Dto; + +/// An assignment as the instructor screens see it. +public sealed class AssignmentDto +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// Full "owner/name" of the template repository students are given a copy of. + public string TemplateRepoName { get; set; } = string.Empty; + + /// Prefix for generated student repositories ({prefix}-{neptun}); null falls back to the template name. + public string? RepoNamePrefix { get; set; } + + /// + /// The invite link as a site-relative path (/{course}/invite/{token}). Deliberately not an absolute + /// URL: the API would have to build one from the Host header, and the Angular dev proxy rewrites that to + /// the backend's own port — producing a link that works for nobody. The browser knows the origin the + /// student will actually use, so it composes the final URL. + /// + public string InvitePath { get; set; } = string.Empty; + + public bool IsArchived { get; set; } + + public DateTimeOffset? ArchivedAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + /// How many students have taken this assignment up. + public int AcceptanceCount { get; set; } +} + +/// An assignment plus the state of its template repository on GitHub. +public sealed class AssignmentDetailDto +{ + public AssignmentDto Assignment { get; set; } = new(); + + /// Null when the template was not checked (the check costs a GitHub call, so it is opt-in). + public TemplateCheckDto? Template { get; set; } +} + +/// Advisory result of looking the template repository up on GitHub. +public sealed class TemplateCheckDto +{ + public bool Reachable { get; set; } + + public bool IsTemplate { get; set; } + + public string? HtmlUrl { get; set; } + + /// What is wrong, in one sentence; null when the template is fine. + public string? Problem { get; set; } + + public static TemplateCheckDto From(TemplateCheck check) + { + ArgumentNullException.ThrowIfNull(check); + + return new TemplateCheckDto + { + Reachable = check.Reachable, + IsTemplate = check.IsTemplate, + HtmlUrl = check.HtmlUrl, + Problem = check.Problem, + }; + } +} + +public sealed class SaveAssignmentRequest +{ + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// "owner/name", or a bare repository name taken to be in the course's organization. + public string TemplateRepoName { get; set; } = string.Empty; + + /// + /// Prefix for generated student repositories ({prefix}-{neptun}). Blank/omitted falls back to the + /// template repository's name. + /// + public string? RepoNamePrefix { get; set; } +} + +/// +/// A template repository to look up on GitHub without saving an assignment first — lets the editor validate +/// a name while creating, before an assignment id exists. A body (not a query param) because the name may +/// contain a '/'. +/// +public sealed class CheckTemplateRequest +{ + public string? TemplateRepoName { get; set; } +} + +/// One student's acceptance, as the instructor's roster of an assignment shows it. +public sealed class AssignmentAcceptanceDto +{ + public int Id { get; set; } + + public string UserName { get; set; } = string.Empty; + + public string? DisplayName { get; set; } + + public string? NeptunCode { get; set; } + + public string GitHubUsername { get; set; } = string.Empty; + + public string GitHubRepoName { get; set; } = string.Empty; + + public string RepoUrl { get; set; } = string.Empty; + + public DateTimeOffset AcceptedAt { get; set; } + + /// True while the student has been invited to their repository but has not accepted yet. + public bool InvitationPending { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Courses/GradesController.cs b/ahk-backend/Ahk.Web.Server/Courses/GradesController.cs new file mode 100644 index 0000000..afa22ef --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Courses/GradesController.cs @@ -0,0 +1,43 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Services.Grading; +using Ahk.Web.Services.Grading.Dto; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Courses; + +/// +/// Course-scoped final grades — the port of the legacy list-grades/{*repoprefix} function, with the +/// repository prefix replaced by the {course} route segment. The CSV endpoint preserves the original export +/// format so downstream administration keeps working. +/// +[ApiController] +[Route("api/{course}/grades")] +[Authorize(Policy = CourseMembershipRequirement.PolicyName)] +public sealed class GradesController : ControllerBase +{ + private readonly IGradeListingService gradeListing; + + public GradesController(IGradeListingService gradeListing) => this.gradeListing = gradeListing; + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var course = CurrentCourse(); + return Ok(await gradeListing.ListAsync(course.Id, cancellationToken)); + } + + [HttpGet("csv")] + [Produces("text/csv")] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + public async Task ExportCsv(CancellationToken cancellationToken) + { + var course = CurrentCourse(); + var csv = await gradeListing.ExportCsvAsync(course.Id, cancellationToken); + return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"{course.Slug}-grades.csv"); + } + + private Course CurrentCourse() => (Course)HttpContext.Items[CourseResolutionMiddleware.CourseItemKey]!; +} diff --git a/ahk-backend/Ahk.Web.Server/Courses/SubmissionStatusesController.cs b/ahk-backend/Ahk.Web.Server/Courses/SubmissionStatusesController.cs new file mode 100644 index 0000000..4cbdb8d --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Courses/SubmissionStatusesController.cs @@ -0,0 +1,30 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Services.StatusTracking; +using Ahk.Web.Services.StatusTracking.Dto; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Courses; + +/// +/// Course-scoped submission status list — the port of the legacy list-statuses/{*repoprefix} function, +/// with the repository prefix replaced by the {course} route segment. +/// +[ApiController] +[Route("api/{course}/statuses")] +[Authorize(Policy = CourseMembershipRequirement.PolicyName)] +public sealed class SubmissionStatusesController : ControllerBase +{ + private readonly IStatusTrackingService statusTracking; + + public SubmissionStatusesController(IStatusTrackingService statusTracking) => this.statusTracking = statusTracking; + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var course = (Course)HttpContext.Items[CourseResolutionMiddleware.CourseItemKey]!; + return Ok(await statusTracking.ListStatusesAsync(course.Id, cancellationToken)); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Integrations/Dto/EvaluationResultRequest.cs b/ahk-backend/Ahk.Web.Server/Integrations/Dto/EvaluationResultRequest.cs new file mode 100644 index 0000000..4301f78 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Integrations/Dto/EvaluationResultRequest.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Ahk.Web.Server.Integrations.Dto; + +/// +/// The evaluation result posted by publish-results-pr. A verbatim port of grade-management's +/// AhkProcessResult, property names and validation attributes included: this is a wire contract with a +/// Go client running inside student repositories, and those repositories are updated on their own schedule. +/// +/// ⚠️ Unknown members are tolerated on purpose. The Go client also sends imageFiles, which has no +/// counterpart here and never had one. Do not turn on JsonUnmappedMemberHandling.Disallow. +/// +public sealed class EvaluationResultRequest +{ + [JsonPropertyName("gitHubRepoName")] + [Required] + [StringLength(200, MinimumLength = 1)] + public string GitHubRepoName { get; set; } = string.Empty; + + [JsonPropertyName("gitHubBranch")] + public string? GitHubBranch { get; set; } + + [JsonPropertyName("gitHubCommitHash")] + public string? GitHubCommitHash { get; set; } + + /// Absent rather than zero when there is no pull request — the Go client marks it omitempty. + [JsonPropertyName("gitHubPullRequestNum")] + public int? GitHubPullRequestNum { get; set; } + + [JsonPropertyName("neptunCode")] + [Required] + [StringLength(100, MinimumLength = 1)] + public string NeptunCode { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public EvaluationTaskResultRequest[]? Result { get; set; } + + [JsonPropertyName("origin")] + public string? Origin { get; set; } +} + +/// +/// One task line from result.txt. +/// +/// ⚠️ The [Required] on is not actually enforced, because +/// Validator.TryValidateObject does not recurse into collection elements. That was true in +/// grade-management too, and it is left alone deliberately: a stricter server here would start failing student +/// builds that pass today. +/// +public sealed class EvaluationTaskResultRequest +{ + [JsonPropertyName("exerciseName")] + public string? ExerciseName { get; set; } + + [JsonPropertyName("taskName")] + [Required] + [StringLength(100, MinimumLength = 1)] + public string TaskName { get; set; } = string.Empty; + + [JsonPropertyName("points")] + [Required] + public double Points { get; set; } + + [JsonPropertyName("comment")] + public string? Comment { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Server/Integrations/EvaluationResultController.cs b/ahk-backend/Ahk.Web.Server/Integrations/EvaluationResultController.cs new file mode 100644 index 0000000..37a7d0c --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Integrations/EvaluationResultController.cs @@ -0,0 +1,220 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.Json; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Server.Integrations.Dto; +using Ahk.Web.Services.Courses; +using Ahk.Web.Services.Grading; +using Ahk.Web.Services.Grading.Dto; +using Ahk.Web.Services.Integrations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; + +namespace Ahk.Web.Server.Integrations; + +/// +/// Receives an automated evaluation result from the GitHub Action that runs in a student's repository — +/// the port of grade-management's evaluation-result function. +/// +/// ⚠️ The caller signs the request URL, so this route's public address is part of the contract: +/// https://ahk.aut.bme.hu/api/integrations/evaluation-result, https, no trailing slash. It must match the +/// action's AHK_APPURL exactly (case aside, which both sides lower). UseForwardedHeaders running +/// first in the pipeline is what makes yield the public URL behind IIS. +/// See docs/ci-callback.md. +/// +/// The Go client treats any non-2xx as fatal and fails the student's build, so the failure modes here are +/// deliberately limited to genuine misconfiguration. +/// +[ApiController] +[AllowAnonymous] +[Route("api/integrations/evaluation-result")] +[ApiExplorerSettings(IgnoreApi = true)] +public sealed class EvaluationResultController : ControllerBase +{ + /// How far the caller's clock may drift. Carried over unchanged; GitHub-hosted runners are UTC. + private static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(10); + + private static readonly JsonSerializerOptions PayloadOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly IWebhookTokenService tokens; + private readonly ICourseResolutionService courses; + private readonly IGradeService grades; + private readonly CurrentCourseProvider currentCourse; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public EvaluationResultController( + IWebhookTokenService tokens, + ICourseResolutionService courses, + IGradeService grades, + CurrentCourseProvider currentCourse, + TimeProvider timeProvider, + ILogger logger) + { + this.tokens = tokens; + this.courses = courses; + this.grades = grades; + this.currentCourse = currentCourse; + this.timeProvider = timeProvider; + this.logger = logger; + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CancellationToken cancellationToken) + { + var token = Request.Headers["X-Ahk-Token"].FirstOrDefault(); + var receivedSignature = Request.Headers["X-Ahk-Sha256"].FirstOrDefault(); + var deliveryId = Request.Headers["X-Ahk-Delivery"].FirstOrDefault(); + var dateStr = Request.Headers[HeaderNames.Date].FirstOrDefault(); + + logger.LogInformation( + "evaluation-result request with X-Ahk-Delivery='{DeliveryId}', X-Ahk-Token = '{Token}'", deliveryId, MaskToken(token)); + + // Order and wording of these checks are the ported contract; the evaluator's logs are full of them. + if (string.IsNullOrEmpty(dateStr)) + return BadRequest(new { error = "Date header missing" }); + + if (!DateTime.TryParseExact( + dateStr, + "R", + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var date)) + { + return BadRequest(new { error = "Date header value not valid RFC1123 string" }); + } + + var now = timeProvider.GetUtcNow().UtcDateTime; + if (date < now.Add(-MaxClockSkew) || date > now.Add(MaxClockSkew)) + return BadRequest(new { error = "Date header value is not close enough to current date" }); + + if (string.IsNullOrEmpty(receivedSignature)) + return BadRequest(new { error = "X-Ahk-Sha256 header missing" }); + + if (string.IsNullOrEmpty(token)) + return BadRequest(new { error = "X-Ahk-Token header missing" }); + + var secret = await tokens.GetSecretForTokenAsync(token, cancellationToken); + if (string.IsNullOrEmpty(secret)) + return BadRequest(new { error = "X-Ahk-Token invalid" }); + + var requestBody = await RawBody.ReadAsync(Request, cancellationToken); + var signedUrl = Request.GetDisplayUrl(); + + if (!HmacSha256Validator.IsSignatureValid(Request.Method, signedUrl, date, requestBody, receivedSignature, secret)) + { + // The URL is the usual culprit and the only part of the signed string that is safe to log — never + // the body (student code) and never the secret. + logger.LogDebug( + "evaluation-result signature mismatch for X-Ahk-Delivery='{DeliveryId}'; signed URL was '{Url}'", deliveryId, signedUrl); + return BadRequest(new { error = "X-Ahk-Sha256 signature not valid" }); + } + + if (!TryReadPayload(requestBody, out var payload, out var deserializationError)) + return BadRequest(new { error = deserializationError }); + + // New in the portal: one deployment serves every course, so the course comes from the token — the + // authenticated credential — rather than from the caller-supplied repository name. + var course = await courses.ResolveByWebhookTokenAsync(token, cancellationToken); + if (course is null) + { + // Same message as an unknown token: a caller must not be able to tell a revoked token from one + // whose course was deleted. + logger.LogWarning("evaluation-result token '{Token}' resolved to no course", MaskToken(token)); + return BadRequest(new { error = "X-Ahk-Token invalid" }); + } + + var repositoryCourse = await courses.ResolveByRepositoryAsync(payload.GitHubRepoName, cancellationToken); + if (repositoryCourse is not null && repositoryCourse.Id != course.Id) + { + // Not fatal — the token is authoritative — but it means a course is using another course's token, + // and the grade is about to land in the wrong place. + logger.LogWarning( + "evaluation-result for '{Repository}' resolves to course '{RepositoryCourse}' but its token belongs to '{TokenCourse}'", + payload.GitHubRepoName, repositoryCourse.Slug, course.Slug); + } + + currentCourse.Set(course.Id); + + logger.LogInformation( + "evaluation-result request with X-Ahk-Delivery='{DeliveryId}' accepted, starting processing", deliveryId); + + try + { + await grades.RecordEvaluationResultAsync(course.Id, ToInput(payload), date, cancellationToken); + logger.LogInformation("evaluation-result request handled with success for X-Ahk-Delivery='{DeliveryId}'", deliveryId); + return Ok(); + } +#pragma warning disable CA1031 // Ported shape: the caller gets the failure text, because it is a build log. + catch (Exception ex) +#pragma warning restore CA1031 + { + logger.LogError(ex, "evaluation-result webhook failed for X-Ahk-Delivery='{DeliveryId}'", deliveryId); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.ToString() }); + } + } + + /// + /// A token is a live credential: it selects a course and is half of the pair that authenticates a grade + /// write. grade-management logged it whole, which put working credentials into any log the application + /// ships to. Only enough is kept to tell two tokens apart while diagnosing — the same last-four convention + /// the admin API uses when it reports a stored secret. + /// + private static string MaskToken(string? token) + => string.IsNullOrEmpty(token) ? "(none)" : $"…{token[^Math.Min(4, token.Length)..]}"; + + private static EvaluationResultInput ToInput(EvaluationResultRequest payload) + => new() + { + NeptunCode = payload.NeptunCode, + GitHubRepoName = payload.GitHubRepoName, + GitHubBranch = payload.GitHubBranch, + GitHubCommitHash = payload.GitHubCommitHash, + GitHubPullRequestNum = payload.GitHubPullRequestNum, + Origin = payload.Origin, + Result = payload.Result?.Select(r => new EvaluationTaskResult + { + ExerciseName = r.ExerciseName, + TaskName = r.TaskName, + Points = r.Points, + Comment = r.Comment, + }).ToList() ?? new List(), + }; + + /// Port of grade-management's PayloadReader, error string included. + private static bool TryReadPayload(string requestBody, out EvaluationResultRequest payload, out string error) + { + payload = null!; + error = null!; + + try + { + var result = JsonSerializer.Deserialize(requestBody, PayloadOptions); + if (result is null) + { + error = "Body cannot be deserialized as JSON: the body was null"; + return false; + } + + var validationResults = new List(); + if (!Validator.TryValidateObject(result, new ValidationContext(result, null, null), validationResults, validateAllProperties: true)) + { + error = $"Body cannot be deserialized as JSON: {string.Join(", ", validationResults.Select(s => s.ErrorMessage).ToArray())}"; + return false; + } + + payload = result; + return true; + } + catch (JsonException ex) + { + error = $"Body cannot be deserialized as JSON: {ex.Message}"; + return false; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/Integrations/GitHubWebhookController.cs b/ahk-backend/Ahk.Web.Server/Integrations/GitHubWebhookController.cs new file mode 100644 index 0000000..efbbc81 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Integrations/GitHubWebhookController.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using Ahk.Web.Data; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Services.Courses; +using Ahk.Web.Services.GitHub; +using Ahk.Web.Services.GitHubWebhooks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Server.Integrations; + +/// +/// Receives GitHub App webhook deliveries — the entry point ported from github-monitor's single Azure +/// Function. Anonymous by design: the X-Hub-Signature-256 HMAC is the authentication, and there is no +/// fallback authorization policy in Program.cs for it to fight. +/// +/// Kept out of the OpenAPI document: the SPA never calls this, and NSwag would emit a TypeScript client +/// for a byte-exact signed payload that no browser can produce. +/// +[ApiController] +[AllowAnonymous] +[Route("api/integrations/github")] +[ApiExplorerSettings(IgnoreApi = true)] +public sealed class GitHubWebhookController : ControllerBase +{ + /// GitHub caps webhook payloads at 25 MB. Bounds the work done before the signature is checked. + private const int MaxPayloadBytes = 25 * 1024 * 1024; + + private readonly ICourseResolutionService courses; + private readonly ICourseGitHubAppTokenProvider tokenProvider; + private readonly ICourseGitHubClientFactory clientFactory; + private readonly IGitHubWebhookDispatcher dispatcher; + private readonly CurrentCourseProvider currentCourse; + private readonly ApplicationDbContext db; + private readonly ILogger logger; + + public GitHubWebhookController( + ICourseResolutionService courses, + ICourseGitHubAppTokenProvider tokenProvider, + ICourseGitHubClientFactory clientFactory, + IGitHubWebhookDispatcher dispatcher, + CurrentCourseProvider currentCourse, + ApplicationDbContext db, + ILogger logger) + { + this.courses = courses; + this.tokenProvider = tokenProvider; + this.clientFactory = clientFactory; + this.dispatcher = dispatcher; + this.currentCourse = currentCourse; + this.db = db; + this.logger = logger; + } + + /// + /// Deliberately no [Consumes]: a webhook mistakenly configured as x-www-form-urlencoded + /// should get our explanatory 400 in the delivery log, not a bare framework 415. + /// + [HttpPost] + [RequestSizeLimit(MaxPayloadBytes)] + [ProducesResponseType(typeof(WebhookResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Receive(CancellationToken cancellationToken) + { + var eventName = Request.Headers["X-GitHub-Event"].FirstOrDefault(); + var deliveryId = Request.Headers["X-GitHub-Delivery"].FirstOrDefault(); + var receivedSignature = Request.Headers["X-Hub-Signature-256"].FirstOrDefault(); + + logger.LogInformation( + "Webhook delivery: Delivery id = '{DeliveryId}', Event name = '{EventName}'", deliveryId, eventName); + + if (string.IsNullOrEmpty(eventName)) + return BadRequest(new { error = "X-GitHub-Event header missing" }); + + if (string.IsNullOrEmpty(receivedSignature)) + return BadRequest(new { error = "X-Hub-Signature-256 header missing" }); + + var requestBody = await RawBody.ReadAsync(Request, cancellationToken); + if (string.IsNullOrEmpty(requestBody)) + return BadRequest(new { error = "request body was empty" }); + + // The secret is per course, and the only thing in a delivery that identifies the course is the + // repository name inside the body — so an untrusted body must be parsed before it can be verified. + // Everything up to the signature check is therefore kept inert: one property is read and the document + // is dropped, and nothing is written, logged or called until the HMAC passes. + var repositoryFullName = TryReadRepositoryFullName(requestBody); + if (string.IsNullOrEmpty(repositoryFullName)) + return BadRequest(new { error = "no repository information in webhook payload" }); + + var course = await courses.ResolveByRepositoryAsync(repositoryFullName, cancellationToken); + if (course is null) + { + // 202 rather than 4xx: during cutover an organization legitimately contains repositories that are + // not a course, and a delivery log full of red trains administrators to stop reading it. + logger.LogInformation("Webhook delivery for '{Repository}' matches no course", repositoryFullName); + return Accepted(new { message = $"repository '{repositoryFullName}' is not mapped to a course" }); + } + + var config = await db.CourseGitHubConfigs.AsNoTracking() + .FirstOrDefaultAsync(g => g.CourseId == course.Id, cancellationToken); + + if (config is null) + return Accepted(new { message = $"GitHub integration is not configured for course '{course.Slug}'" }); + + if (!config.Enabled) + return Accepted(new { message = $"GitHub integration is turned off for course '{course.Slug}'" }); + + if (string.IsNullOrEmpty(config.GitHubWebhookSecret)) + return StatusCode(StatusCodes.Status500InternalServerError, new { error = "GitHub secret not configured" }); + + if (!GitHubSignatureValidator.IsSignatureValid(requestBody, receivedSignature, config.GitHubWebhookSecret)) + return BadRequest(new { error = "Payload signature not valid" }); + + // Past this point the body is trusted. + currentCourse.Set(course.Id); + + // The org-based lookup is used rather than the payload's installation.id: the course is resolved *by* + // organization, so the two are the same installation by construction, and deriving a credential from a + // payload field is a weaker path to the same answer. + var token = await tokenProvider.GetForCourseAsync(course.Id, bypassCache: false, cancellationToken); + if (token is null) + return StatusCode(StatusCodes.Status500InternalServerError, new { error = "GitHub App ID/Token not configured" }); + + var context = new GitHubWebhookContext + { + CourseId = course.Id, + GitHubEventName = eventName, + DeliveryId = deliveryId ?? string.Empty, + RequestBody = requestBody, + GitHubClient = clientFactory.CreateForToken(token.Token), + WorkflowRunThreshold = config.WorkflowRunThreshold, + }; + + logger.LogInformation("Webhook delivery accepted with Delivery id = '{DeliveryId}'", deliveryId); + + var webhookResult = new WebhookResult(); + try + { + await dispatcher.ProcessAsync(context, webhookResult, cancellationToken); + logger.LogInformation("Webhook delivery processed successfully with Delivery id = '{DeliveryId}'", deliveryId); + } +#pragma warning disable CA1031 // A handled delivery still answers 200; the failure is reported in the body. + catch (Exception ex) +#pragma warning restore CA1031 + { + webhookResult.LogError(ex, "Failed to handle webhook"); + logger.LogError(ex, "github-webhook failed with Delivery id = '{DeliveryId}'", deliveryId); + } + + return Ok(webhookResult); + } + + /// + /// Reads repository.full_name and nothing else out of an as-yet-unverified body. Returns null on any + /// malformed input — this runs before authentication, so it must not throw. + /// + private static string? TryReadRepositoryFullName(string requestBody) + { + try + { + using var document = JsonDocument.Parse(requestBody); + + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.TryGetProperty("repository", out var repository) + || repository.ValueKind != JsonValueKind.Object + || !repository.TryGetProperty("full_name", out var fullName)) + { + return null; + } + + return fullName.GetString(); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/Integrations/RawBody.cs b/ahk-backend/Ahk.Web.Server/Integrations/RawBody.cs new file mode 100644 index 0000000..401dc26 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Integrations/RawBody.cs @@ -0,0 +1,20 @@ +using System.Text; + +namespace Ahk.Web.Server.Integrations; + +/// +/// Reads a request body as the exact string that was signed. +/// +/// Both machine-to-machine schemes here (GitHub's X-Hub-Signature-256 and the CI callback's +/// X-Ahk-Sha256) compute their HMAC over the raw bytes, so the body must not be round-tripped through +/// model binding first. The controllers therefore declare no [FromBody] parameter, MVC never touches the +/// stream, and this reads it once — no EnableBuffering anywhere in the pipeline. +/// +internal static class RawBody +{ + public static async Task ReadAsync(HttpRequest request, CancellationToken cancellationToken = default) + { + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + return await reader.ReadToEndAsync(cancellationToken); + } +} diff --git a/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcEndpoints.cs b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcEndpoints.cs new file mode 100644 index 0000000..59a9f5e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcEndpoints.cs @@ -0,0 +1,183 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Ahk.Web.Server.Configuration; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Ahk.Web.Server.MockOidc; + +/// +/// Minimal in-app OpenID Provider for development only. Exists because just the production redirect URI +/// (https://ahk.aut.bme.hu/signin-oidc) is registered with the BME IdP, so the real provider cannot be used +/// from localhost. +/// +/// It mirrors the shape of BME's discovery document (query response mode, client_secret_post, the same claim +/// names including neptun_code) and issues genuinely signed RS256 id_tokens, so the ASP.NET OIDC handler runs +/// its real validation path against it. +/// +public static class MockOidcEndpoints +{ + public const string BasePath = "/mock-oidc"; + + /// Holds the developer's chosen persona between /persona and the next /authorize. + private const string PersonaCookie = "ahk_mock_oidc_persona"; + + public static IEndpointRouteBuilder MapMockOidcProvider(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(BasePath).AllowAnonymous(); + + group.MapGet("/.well-known/openid-configuration", (IOptions options) => + { + var issuer = Issuer(options.Value); + return Results.Json(new + { + issuer, + authorization_endpoint = $"{issuer}/authorize", + token_endpoint = $"{issuer}/token", + userinfo_endpoint = $"{issuer}/userinfo", + jwks_uri = $"{issuer}/keyset", + response_types_supported = new[] { "code" }, + subject_types_supported = new[] { "public" }, + grant_types_supported = new[] { "authorization_code" }, + id_token_signing_alg_values_supported = new[] { "RS256" }, + token_endpoint_auth_methods_supported = new[] { "client_secret_post", "client_secret_basic" }, + scopes_supported = new[] { "openid", "profile", "email", "userinfo", "offline_access" }, + response_modes_supported = new[] { "query", "fragment", "form_post" }, + claims_supported = new[] + { + "aud", "iss", "sub", "iat", "exp", "auth_time", "email", "name", + "family_name", "given_name", "eduperson_scoped_affiliation", "neptun_code", + }, + }); + }); + + // Persona selection. The OIDC handler builds the authorize URL itself, so a custom parameter cannot be + // threaded through the challenge — the choice is parked in a cookie instead. + // Usage: navigate to /mock-oidc/persona?user=student, then sign in as usual. + group.MapGet("/persona", (HttpContext context, string? user) => + { + var selected = MockOidcUsers.Resolve(user); + context.Response.Cookies.Append(PersonaCookie, selected.Key, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + }); + + return Results.Json(new { persona = selected.Key, available = MockOidcUsers.All.Keys }); + }); + + // No login UI: pick the persona (query, else cookie, else default) and redirect straight back with a code. + group.MapGet("/authorize", (HttpContext context, MockOidcCodeStore codes, IOptions options) => + { + var query = context.Request.Query; + var redirectUri = query["redirect_uri"].ToString(); + var clientId = query["client_id"].ToString(); + + if (string.IsNullOrEmpty(redirectUri)) + return Results.BadRequest(new { error = "invalid_request", error_description = "redirect_uri is required" }); + + if (!string.Equals(clientId, options.Value.ClientId, StringComparison.Ordinal)) + return Results.BadRequest(new { error = "unauthorized_client", error_description = $"unexpected client_id '{clientId}'" }); + + var personaKey = query["mock_user"].ToString() is { Length: > 0 } q + ? q + : context.Request.Cookies[PersonaCookie]; + var user = MockOidcUsers.Resolve(personaKey); + var code = codes.Issue(user, query["nonce"].ToString() is { Length: > 0 } n ? n : null, clientId); + + var separator = redirectUri.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + var location = $"{redirectUri}{separator}code={Uri.EscapeDataString(code)}"; + + var state = query["state"].ToString(); + if (!string.IsNullOrEmpty(state)) + location += $"&state={Uri.EscapeDataString(state)}"; + + return Results.Redirect(location); + }); + + group.MapPost("/token", async (HttpContext context, MockOidcCodeStore codes, MockOidcSigningKey signingKey, IOptions options) => + { + var form = await context.Request.ReadFormAsync(); + var redeemed = codes.Redeem(form["code"].ToString()); + if (redeemed is null) + return Results.BadRequest(new { error = "invalid_grant", error_description = "unknown or already-used code" }); + + // client_secret_post: the handler sends credentials in the body. + if (!string.Equals(form["client_secret"].ToString(), options.Value.ClientSecret, StringComparison.Ordinal)) + return Results.BadRequest(new { error = "invalid_client", error_description = "client_secret mismatch" }); + + var issuer = Issuer(options.Value); + var now = DateTimeOffset.UtcNow; + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, redeemed.User.Subject), + new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture), ClaimValueTypes.Integer64), + new("auth_time", now.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture), ClaimValueTypes.Integer64), + }; + + if (redeemed.Nonce is not null) + claims.Add(new Claim(JwtRegisteredClaimNames.Nonce, redeemed.Nonce)); + + var token = new JwtSecurityToken( + issuer: issuer, + audience: redeemed.ClientId, + claims: claims, + notBefore: now.UtcDateTime, + expires: now.AddMinutes(10).UtcDateTime, + signingCredentials: signingKey.SigningCredentials); + + var idToken = new JwtSecurityTokenHandler().WriteToken(token); + + // The access token is opaque here; the userinfo endpoint resolves it through the code store's persona. + return Results.Json(new + { + access_token = $"mock-access-{redeemed.User.Key}", + token_type = "Bearer", + expires_in = 600, + id_token = idToken, + }); + }); + + group.MapGet("/userinfo", (HttpContext context) => + { + var authorization = context.Request.Headers.Authorization.ToString(); + const string prefix = "Bearer mock-access-"; + if (!authorization.StartsWith(prefix, StringComparison.Ordinal)) + return Results.Unauthorized(); + + var user = MockOidcUsers.Resolve(authorization[prefix.Length..]); + + // Claim names deliberately match BME's claims_supported. + var payload = new Dictionary(StringComparer.Ordinal) + { + ["sub"] = user.Subject, + ["email"] = user.Email, + ["name"] = user.Name, + ["given_name"] = user.GivenName, + ["family_name"] = user.FamilyName, + }; + + if (user.NeptunCode is not null) + payload["neptun_code"] = user.NeptunCode; + + if (user.Affiliations.Length > 0) + payload["eduperson_scoped_affiliation"] = user.Affiliations; // multi-valued, as at BME + + return Results.Json(payload); + }); + + group.MapGet("/keyset", (MockOidcSigningKey signingKey) + => Results.Json(new { keys = new[] { signingKey.ToJsonWebKey() } })); + + return endpoints; + } + + /// + /// The mock's issuer is the configured Authority, so the handler's issuer validation matches without any + /// special-casing (dev config points Authority at this app's own /mock-oidc). + /// + private static string Issuer(OidcOptions options) => options.Authority!.TrimEnd('/'); +} diff --git a/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcSigningKey.cs b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcSigningKey.cs new file mode 100644 index 0000000..2aa7624 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcSigningKey.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.Tokens; + +namespace Ahk.Web.Server.MockOidc; + +/// +/// In-memory RSA key used by the development mock provider to sign id_tokens, and published through its JWKS. +/// Generated once per process — restarting the app rotates it, which is fine for a dev-only provider. +/// +/// The mock signs tokens for real (rather than stubbing validation out) so the ASP.NET OIDC handler runs its +/// genuine signature/issuer/audience/nonce checks; otherwise the mock would prove nothing about the real flow. +/// +public sealed class MockOidcSigningKey : IDisposable +{ + public const string KeyId = "ahk-mock-key"; + + private readonly RSA rsa; + + public MockOidcSigningKey() + { + rsa = RSA.Create(2048); + SecurityKey = new RsaSecurityKey(rsa) { KeyId = KeyId }; + SigningCredentials = new SigningCredentials(SecurityKey, SecurityAlgorithms.RsaSha256); + } + + public RsaSecurityKey SecurityKey { get; } + + public SigningCredentials SigningCredentials { get; } + + /// Public key as a JWKS entry (RFC 7517), which is what the handler fetches from the keyset endpoint. + public object ToJsonWebKey() + { + var parameters = rsa.ExportParameters(includePrivateParameters: false); + return new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid = KeyId, + n = Base64UrlEncoder.Encode(parameters.Modulus), + e = Base64UrlEncoder.Encode(parameters.Exponent), + }; + } + + public void Dispose() => rsa.Dispose(); +} diff --git a/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcUsers.cs b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcUsers.cs new file mode 100644 index 0000000..b209258 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/MockOidc/MockOidcUsers.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; + +namespace Ahk.Web.Server.MockOidc; + +/// A fixed persona the mock provider can authenticate as, mirroring BME's claim set. +public sealed record MockOidcUser( + string Key, + string Subject, + string Email, + string Name, + string GivenName, + string FamilyName, + string? NeptunCode, + string[] Affiliations); + +/// +/// Fixed development personas. The default is an instructor; ?mock_user=student (or =noclaims) +/// selects another so claim handling — multi-valued affiliation, missing neptun code — can be exercised. +/// +public static class MockOidcUsers +{ + public const string DefaultKey = "instructor"; + + public static readonly IReadOnlyDictionary All = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [DefaultKey] = new( + Key: DefaultKey, + Subject: "mock-sub-instructor", + Email: "teacher@bme.hu", + Name: "Teszt Tanár", + GivenName: "Teszt", + FamilyName: "Tanár", + NeptunCode: "TEACH1", + Affiliations: new[] { "staff@bme.hu", "employee@bme.hu" }), + + ["student"] = new( + Key: "student", + Subject: "mock-sub-student", + Email: "student@bme.hu", + Name: "Teszt Hallgató", + GivenName: "Teszt", + FamilyName: "Hallgató", + NeptunCode: "ABC123", + Affiliations: new[] { "student@bme.hu" }), + + // Directory account with no neptun code and no affiliation — the sparse-claims case. + ["noclaims"] = new( + Key: "noclaims", + Subject: "mock-sub-noclaims", + Email: "sparse@bme.hu", + Name: "Sparse User", + GivenName: "Sparse", + FamilyName: "User", + NeptunCode: null, + Affiliations: Array.Empty()), + }; + + public static MockOidcUser Resolve(string? key) + => key is not null && All.TryGetValue(key, out var user) ? user : All[DefaultKey]; +} + +/// Authorization codes issued by the mock, held in memory until redeemed at the token endpoint. +public sealed class MockOidcCodeStore +{ + private readonly ConcurrentDictionary codes = new(StringComparer.Ordinal); + + public string Issue(MockOidcUser user, string? nonce, string clientId) + { + var code = Guid.NewGuid().ToString("N"); + codes[code] = new MockOidcAuthorizationCode(user, nonce, clientId); + return code; + } + + /// Single-use, like a real authorization code. + public MockOidcAuthorizationCode? Redeem(string? code) + => code is not null && codes.TryRemove(code, out var value) ? value : null; +} + +public sealed record MockOidcAuthorizationCode(MockOidcUser User, string? Nonce, string ClientId); diff --git a/ahk-backend/Ahk.Web.Server/Program.cs b/ahk-backend/Ahk.Web.Server/Program.cs new file mode 100644 index 0000000..1b1a773 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Program.cs @@ -0,0 +1,273 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Data.Seed; +using Ahk.Web.Server.Auth; +using Ahk.Web.Server.Configuration; +using Ahk.Web.Server.CourseContext; +using Ahk.Web.Server.MockOidc; +using Ahk.Web.Services; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Ahk.Web.Server; + +public class Program +{ + /// + /// Session cookie name. Deliberately app-specific: cookies are scoped by host and ignore the port, so the + /// framework default would be shared with every other ASP.NET Identity app running on localhost. + /// + public const string ApplicationCookieName = "ahk.auth"; + + /// Cookie holding the external identity between the OIDC callback and sign-in. + public const string ExternalCookieName = "ahk.auth.external"; + + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + ConfigureServices(builder); + + var app = builder.Build(); + await ConfigurePipelineAsync(app); + + await app.RunAsync(); + } + + private static void ConfigureServices(WebApplicationBuilder builder) + { + // ---- Options ---- + builder.Services.Configure(builder.Configuration.GetSection(OidcOptions.SectionName)); + var oidc = builder.Configuration.GetSection(OidcOptions.SectionName).Get() ?? new OidcOptions(); + + // ---- EF Core + course scoping ---- + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); + + // ---- Identity (cookie-based; no MapIdentityApi — see plan) ---- + builder.Services + .AddIdentity(options => + { + options.User.RequireUniqueEmail = false; + options.SignIn.RequireConfirmedAccount = false; + options.Password.RequireNonAlphanumeric = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + // The SPA calls the API; make the cookie handler return 401/403 instead of redirecting to login/denied pages. + builder.Services.ConfigureApplicationCookie(options => + { + // Named, not left at ASP.NET's default ".AspNetCore.Identity.Application". Browsers scope cookies + // by host and ignore the port, so on localhost every ASP.NET Identity app shares that default name + // — another project's cookie lands on this one, and because this app uses int keys, its GUID user + // id blows up SecurityStampValidator with "not a valid value for Int32". + options.Cookie.Name = ApplicationCookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Events.OnRedirectToLogin = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; + + // Belt and braces: a cookie this app cannot make sense of must sign the caller out, not throw. + // AddIdentity points OnValidatePrincipal at the security-stamp validator, so it is called here + // rather than replaced. + options.Events.OnValidatePrincipal = async context => + { + try + { + await SecurityStampValidator.ValidatePrincipalAsync(context); + } + catch (Exception ex) when (ex is ArgumentException or FormatException) + { + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme); + } + }; + }); + + // Same collision, shorter-lived cookie: this one carries the external identity between the OIDC + // callback and sign-in. + builder.Services.ConfigureExternalCookie(options => + { + options.Cookie.Name = ExternalCookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); + + // ---- Generic OIDC external login (registered only when configured) ---- + if (oidc.IsEnabled) + { + builder.Services.AddAuthentication().AddOpenIdConnect(ExternalAuthController.Scheme, options => + { + options.Authority = oidc.Authority; + options.ClientId = oidc.ClientId; + options.ClientSecret = oidc.ClientSecret; + + options.ResponseType = "code"; + + // query, not the ASP.NET default of form_post: form_post makes the callback a cross-site POST, + // so the correlation cookie is dropped under SameSite=Lax and login fails with "Correlation failed". + options.ResponseMode = oidc.ResponseMode; + + // Off for BME: the IdP does not advertise code_challenge_methods_supported, and we authenticate + // with client_secret_post, so PKCE is defence-in-depth rather than required. + options.UsePkce = oidc.UsePkce; + + options.SaveTokens = oidc.SaveTokens; + options.CallbackPath = "/signin-oidc"; + options.SignedOutCallbackPath = "/signout-callback-oidc"; + options.SignInScheme = IdentityConstants.ExternalScheme; + options.GetClaimsFromUserInfoEndpoint = true; + + // Exactly the registered scopes — requesting an unregistered one (e.g. profile) gets rejected. + options.Scope.Clear(); + foreach (var scope in oidc.Scopes) + options.Scope.Add(scope); + + // OpenIdConnectOptions ships only DeleteClaim actions — it maps NO standard claims. Anything + // arriving from the userinfo endpoint (rather than inside the id_token) is therefore dropped + // unless mapped explicitly. BME returns these via userinfo, so they must be listed here. + options.ClaimActions.MapUniqueJsonKey(ClaimTypes.Email, "email"); + options.ClaimActions.MapUniqueJsonKey(ClaimTypes.Name, "name"); + options.ClaimActions.MapUniqueJsonKey(ClaimTypes.GivenName, "given_name"); + options.ClaimActions.MapUniqueJsonKey(ClaimTypes.Surname, "family_name"); + + // BME-specific claims. neptun_code is single-valued; eduperson_scoped_affiliation is an array, + // so it needs a custom action that joins the values. Note the resolver receives the whole + // userinfo payload, not just the claim, so the property is looked up explicitly. + options.ClaimActions.MapUniqueJsonKey(BmeClaimTypes.NeptunCode, BmeClaimTypes.NeptunCode); + options.ClaimActions.MapCustomJson(BmeClaimTypes.Affiliation, root => + { + if (!root.TryGetProperty(BmeClaimTypes.Affiliation, out var value)) + return null; + + return value.ValueKind == JsonValueKind.Array + ? string.Join(';', value.EnumerateArray().Select(v => v.ToString())) + : value.ToString(); + }); + + options.Events.OnRedirectToIdentityProvider = context => + { + // The dev proxy rewrites Host, so the computed redirect_uri would point at the backend port + // instead of the browser's origin. In production this is left unset and computed normally. + if (!string.IsNullOrWhiteSpace(oidc.RedirectUri)) + context.ProtocolMessage.RedirectUri = oidc.RedirectUri; + + return Task.CompletedTask; + }; + + if (oidc.UseMockProvider) + { + // The app fetches discovery/token/jwks from its own HTTPS endpoint, whose dev certificate is + // self-signed. Development only. + options.BackchannelHttpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }; + } + }); + } + + // ---- Authorization ---- + builder.Services.AddScoped(); + builder.Services.AddAuthorization(options => + { + options.AddPolicy(CourseMembershipRequirement.PolicyName, policy => + policy.Requirements.Add(new CourseMembershipRequirement())); + }); + + // ---- Development-only mock OpenID provider ---- + if (oidc.UseMockProvider) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + } + + // Behind the production reverse proxy (TLS terminated upstream) the original scheme/host must be + // honoured, otherwise the generated redirect_uri would be http://internal instead of https://ahk.aut.bme.hu. + builder.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedFor; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + }); + + // ---- Domain services (Ahk.Web.Services) ---- + builder.Services.AddMemoryCache(); + + // The CI callback rejects a request whose Date header has drifted more than ten minutes, so it needs a + // clock a test can move. TimeProvider is the framework's answer to grade-management's IDateTimeProvider. + builder.Services.TryAddSingleton(TimeProvider.System); + + builder.Services.AddAhkServices(); + + // ---- MVC + OpenAPI (NSwag document consumed by the Angular code generator) ---- + // Enums travel as names, not ordinals: the generated TypeScript client then models them as string + // literal unions ('Instructor' | 'Admin'), which survives reordering an enum member. + builder.Services.AddControllers().AddJsonOptions(options => + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddOpenApiDocument(settings => + { + settings.DocumentName = "v1"; + settings.Title = "AHK API"; + settings.Version = "v1"; + }); + } + + private static async Task ConfigurePipelineAsync(WebApplication app) + { + // Must run before anything that builds absolute URLs (the OIDC redirect_uri in particular). + app.UseForwardedHeaders(); + + var oidc = app.Services.GetRequiredService>().Value; + + if (app.Environment.IsDevelopment()) + { + app.UseOpenApi(); // /swagger/v1/swagger.json + app.UseSwaggerUi(); // /swagger + await DevDataSeeder.SeedAsync(app.Services); + } + + app.UseHttpsRedirection(); + + // Serve the Angular SPA (published into wwwroot). Same-origin with the API, so the generated + // clients' empty API_BASE_URL keeps issuing relative /api/... requests. Must run before routing. + app.UseDefaultFiles(); + app.UseStaticFiles(); + + app.UseRouting(); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseAuthorization(); + + // Development-only stand-in for the BME IdP; never mapped outside Development. + if (app.Environment.IsDevelopment() && oidc.UseMockProvider) + app.MapMockOidcProvider(); + + app.MapControllers(); + + // Client-side routes (e.g. /admin/courses) have no server endpoint; hand them index.html and let + // the Angular router take over. Matched controller/static routes win first, so /api/... is untouched. + app.MapFallbackToFile("index.html"); + } +} diff --git a/ahk-backend/Ahk.Web.Server/Properties/PublishProfiles/Mezga.pubxml b/ahk-backend/Ahk.Web.Server/Properties/PublishProfiles/Mezga.pubxml new file mode 100644 index 0000000..4f338d3 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Properties/PublishProfiles/Mezga.pubxml @@ -0,0 +1,22 @@ + + + + + FileSystem + FileSystem + Release + Any CPU + Release + Any CPU + true + net10.0 + win-x64 + true + <_IsPortable>true + bin\Release\net10.0\publish\ + + diff --git a/ahk-backend/Ahk.Web.Server/Properties/launchSettings.json b/ahk-backend/Ahk.Web.Server/Properties/launchSettings.json new file mode 100644 index 0000000..e7d3eac --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7443;http://localhost:5234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5234", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/Students/MyAssignmentsController.cs b/ahk-backend/Ahk.Web.Server/Students/MyAssignmentsController.cs new file mode 100644 index 0000000..80759ef --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/Students/MyAssignmentsController.cs @@ -0,0 +1,83 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Assignments; +using Ahk.Web.Services.GitHub; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Ahk.Web.Server.Students; + +/// +/// A student's own view: every repository they hold, across every course, and a way to re-send a GitHub +/// invitation that lapsed before they clicked it. +/// +/// No {course} segment — a student's repositories span courses, and they are members of none of them. The +/// service behind this filters on the user id with IgnoreQueryFilters() for exactly that reason. +/// +[ApiController] +[Route("api/my/assignments")] +[Authorize] +public sealed class MyAssignmentsController : ControllerBase +{ + private readonly IStudentAssignmentService studentAssignments; + private readonly UserManager userManager; + private readonly ILogger logger; + + public MyAssignmentsController( + IStudentAssignmentService studentAssignments, + UserManager userManager, + ILogger logger) + { + this.studentAssignments = studentAssignments; + this.userManager = userManager; + this.logger = logger; + } + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> List(CancellationToken cancellationToken) + { + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + return Ok(await studentAssignments.ListForUserAsync(user.Id, cancellationToken)); + } + + /// + /// Withdraws the stale invitation and issues a fresh one. GitHub has no way to extend an invitation, so + /// replacing it is the only route back in for a student who missed the window. + /// + [HttpPost("{id:int}/resend-invitation")] + [ProducesResponseType(typeof(StudentRepository), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task> ResendInvitation(int id, CancellationToken cancellationToken) + { + var user = await userManager.GetUserAsync(User); + if (user is null) + return Unauthorized(); + + try + { + // Scoped to the caller's own acceptances, so another student's id is a 404, not someone else's repo. + var result = await studentAssignments.ResendInvitationAsync(user.Id, id, cancellationToken); + return result is null ? NotFound() : Ok(result); + } + catch (GitHubOperationException ex) + { + logger.LogError(ex, "Re-sending the invitation for acceptance {AcceptanceId} failed at GitHub.", id); + + return StatusCode(StatusCodes.Status502BadGateway, new + { + error = $"GitHub refused to send the invitation: {ex.GitHubMessage ?? ex.Message}", + }); + } + catch (HttpRequestException) + { + return StatusCode(StatusCodes.Status502BadGateway, new { error = "GitHub could not be reached. Try again in a few minutes." }); + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/app_offline.htm b/ahk-backend/Ahk.Web.Server/app_offline.htm new file mode 100644 index 0000000..4ee628e --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/app_offline.htm @@ -0,0 +1,41 @@ + + + + + + + AHK — deployment in progress + + + +
+

Deployment in progress

+

The AHK portal is being updated and will be back in a few moments.

+

Please refresh this page shortly.

+
+ + diff --git a/ahk-backend/Ahk.Web.Server/appsettings.Development.json b/ahk-backend/Ahk.Web.Server/appsettings.Development.json new file mode 100644 index 0000000..9051f3d --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/appsettings.Development.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "Default": "Server=(localdb)\\MSSQLLocalDB;Database=AhkWeb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" + }, + "Authentication": { + "Oidc": { + "_comment": "Development uses the in-app mock provider (MockOidc/) because only the production redirect URI is registered with the BME IdP.", + "UseMockProvider": true, + "Authority": "https://localhost:7443/mock-oidc", + "ClientId": "AUTAhkClientDev", + "ClientSecret": "dev-mock-secret", + "Scopes": [ "openid", "email", "userinfo" ], + "RedirectUri": "https://localhost:4200/signin-oidc", + "PostLogoutRedirectUri": "https://localhost:4200/signout-callback-oidc" + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/appsettings.json b/ahk-backend/Ahk.Web.Server/appsettings.json new file mode 100644 index 0000000..6871f29 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "" + }, + "Authentication": { + "Oidc": { + "_comment": "BME IdP (Shibboleth). ClientSecret is NEVER stored here — supply it via the Authentication__Oidc__ClientSecret environment variable (or user-secrets locally).", + "Authority": "https://idp.bme.hu", + "ClientId": "AUTAhkClient", + "ClientSecret": "", + "Scopes": [ "openid", "email", "userinfo" ], + "UsePkce": false, + "ResponseMode": "query", + "EndSessionEndpoint": "" + } + } +} diff --git a/ahk-backend/Ahk.Web.Server/web.config b/ahk-backend/Ahk.Web.Server/web.config new file mode 100644 index 0000000..10fdb00 --- /dev/null +++ b/ahk-backend/Ahk.Web.Server/web.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ahk-backend/Ahk.Web.Services/Ahk.Web.Services.csproj b/ahk-backend/Ahk.Web.Services/Ahk.Web.Services.csproj new file mode 100644 index 0000000..65a4906 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Ahk.Web.Services.csproj @@ -0,0 +1,26 @@ + + + + + + + + + <_Parameter1>Ahk.Web.Server.Tests + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/ahk-backend/Ahk.Web.Services/Assignments/AssignmentInviteService.cs b/ahk-backend/Ahk.Web.Services/Assignments/AssignmentInviteService.cs new file mode 100644 index 0000000..fa9e687 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Assignments/AssignmentInviteService.cs @@ -0,0 +1,325 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; +using Ahk.Web.Services.Submissions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Ahk.Web.Services.Assignments; + +/// Where a student stands on one assignment's invite link. +public enum InviteStatus +{ + /// No assignment answers to that invite token in this course — a stale or mistyped link. + NotFound, + + /// Archived, and this student never accepted it. No new repositories are handed out. + Closed, + + /// The account carries no Neptun code, so no repository can be named for it. Terminal. + NeedsNeptun, + + /// The account has no verified GitHub login yet. + NeedsGitHubUsername, + + /// Everything is in place; the student only has to confirm. + ReadyToAccept, + + /// The repository exists and is theirs. + Accepted, + + /// The course is not wired up to GitHub, so nothing can be created. Terminal, and the instructor's problem. + NotConfigured, +} + +/// Everything the invite screen renders, in one shape for both the read and the accept call. +public sealed class InviteState +{ + public InviteStatus Status { get; set; } + + public string CourseName { get; set; } = string.Empty; + + public string CourseSlug { get; set; } = string.Empty; + + public string AssignmentName { get; set; } = string.Empty; + + public string? AssignmentDescription { get; set; } + + /// The GitHub organization the repository lives in, named in the confirmation text. + public string? Organization { get; set; } + + /// The repository the student is about to get, or already has, without the owner prefix. + public string? RepositoryName { get; set; } + + public string? RepoUrl { get; set; } + + public string? GitHubUsername { get; set; } + + /// + /// Set when GitHub only *invited* the student rather than adding them. Until they accept it there, the + /// repository 404s for them, so the UI must send them here instead of to the repository. + /// + public string? InvitationUrl { get; set; } + + /// Human-readable detail for the terminal states; null when there is nothing extra to say. + public string? Message { get; set; } +} + +public interface IAssignmentInviteService +{ + Task GetStateAsync(int courseId, string inviteToken, ApplicationUser user, CancellationToken cancellationToken = default); + + Task AcceptAsync(int courseId, string inviteToken, ApplicationUser user, CancellationToken cancellationToken = default); +} + +/// +/// Drives a student from an invite link to a repository of their own: verify who they are, create the +/// repository from the assignment's template, give them push access, and record the pair. This is the part of +/// GitHub Classroom the portal takes over. +/// +/// Everything it does is idempotent. A student who reloads, double-clicks, or opens the link in two tabs ends +/// up with exactly one repository — guarded by the unique index on (AssignmentId, UserId) and by checking +/// GitHub for the repository before creating it. +/// +public sealed class AssignmentInviteService : IAssignmentInviteService +{ + private readonly ApplicationDbContext db; + private readonly IGitHubRepositoryService gitHub; + private readonly ICourseGitHubAppTokenProvider tokens; + private readonly ISubmissionResolver submissions; + private readonly ILogger logger; + + public AssignmentInviteService( + ApplicationDbContext db, + IGitHubRepositoryService gitHub, + ICourseGitHubAppTokenProvider tokens, + ISubmissionResolver submissions, + ILogger logger) + { + this.db = db; + this.gitHub = gitHub; + this.tokens = tokens; + this.submissions = submissions; + this.logger = logger; + } + + public async Task GetStateAsync(int courseId, string inviteToken, ApplicationUser user, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + + var context = await LoadAsync(courseId, inviteToken, cancellationToken); + if (context is null) + return new InviteState { Status = InviteStatus.NotFound, Message = "This invite link does not match an assignment. Ask your instructor for a current one." }; + + var (course, assignment) = context.Value; + var acceptance = await FindAcceptanceAsync(assignment.Id, user.Id, cancellationToken); + + var state = Describe(course, assignment, user, acceptance); + + // An accepted student keeps their link forever; only newcomers are turned away by archiving. + if (acceptance is not null) + return state; + + if (assignment.ArchivedAt is not null) + { + state.Status = InviteStatus.Closed; + state.Message = "This assignment is no longer accepting new repositories."; + return state; + } + + return state; + } + + public async Task AcceptAsync(int courseId, string inviteToken, ApplicationUser user, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + + // The client is not trusted: everything GetStateAsync decided is decided again here. + var state = await GetStateAsync(courseId, inviteToken, user, cancellationToken); + if (state.Status != InviteStatus.ReadyToAccept) + return state; + + var context = await LoadAsync(courseId, inviteToken, cancellationToken); + if (context is null) + return new InviteState { Status = InviteStatus.NotFound }; + + var (course, assignment) = context.Value; + + var token = await tokens.GetForCourseAsync(course, bypassCache: false, cancellationToken); + if (token is null) + return NotConfigured(state); + + // Enrolls the student in the course on first contact — the model has no roster import, here or anywhere. + var student = await submissions.GetOrCreateStudentAsync(courseId, user.NeptunCode!, cancellationToken); + if (!string.Equals(student.GitHubUsername, user.GitHubUsername, StringComparison.OrdinalIgnoreCase)) + { + student.GitHubUsername = user.GitHubUsername; + await db.SaveChangesAsync(cancellationToken); + } + + var organization = course.GitHubOrganization!; + var repositoryName = BuildRepositoryName(assignment, student.Neptun); + var fullName = Normalize.RepoName($"{organization}/{repositoryName}"); + + var existing = await gitHub.GetRepositoryAsync(organization, repositoryName, token.Token, cancellationToken); + var repository = existing; + + if (repository is null) + { + var (templateOwner, templateName) = IAssignmentService.SplitRepoName(assignment.TemplateRepoName); + + repository = await gitHub.GenerateFromTemplateAsync( + templateOwner, templateName, organization, repositoryName, token.Token, cancellationToken); + + // Belt and braces. A generated repository normally has Actions on, but the evaluator is the whole + // point of the repository, so it is worth one call not to find out otherwise weeks later. + try + { + await gitHub.EnsureActionsEnabledAsync(organization, repositoryName, token.Token, cancellationToken); + } + catch (GitHubOperationException ex) + { + logger.LogWarning(ex, "Could not enable Actions on {Repository}; the repository was still created.", fullName); + } + } + + var collaborator = await gitHub.AddCollaboratorAsync(organization, repositoryName, user.GitHubUsername!, token.Token, cancellationToken); + + // Created eagerly so grades and status events land on the right row even before the first webhook. + // No SubmissionEvent is written here: the webhook receiver owns the event log, and a second + // "repository created" would double-count in the status projection. + await submissions.GetOrCreateAsync(courseId, fullName, student.Neptun, cancellationToken); + + var acceptance = new AssignmentAcceptance + { + CourseId = courseId, + AssignmentId = assignment.Id, + UserId = user.Id, + GitHubRepoName = fullName, + RepoUrl = repository.HtmlUrl, + GitHubUsername = user.GitHubUsername!, + InvitationPending = collaborator.InvitationCreated, + InvitationId = collaborator.InvitationId, + InvitationSentAt = collaborator.InvitationCreated ? DateTimeOffset.UtcNow : null, + }; + + db.AssignmentAcceptances.Add(acceptance); + + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Lost the race against another tab. The unique index did its job; report the row that won. + db.Entry(acceptance).State = EntityState.Detached; + + var winner = await FindAcceptanceAsync(assignment.Id, user.Id, cancellationToken); + if (winner is null) + throw; + + acceptance = winner; + } + + var result = Describe(course, assignment, user, acceptance); + if (existing is not null) + result.Message = "This repository already existed, so it was linked to you rather than created again."; + + return result; + } + + /// + /// The student's repository name: the assignment's with their Neptun + /// code appended. When no prefix is set the template repository's own name is used instead — the original + /// convention, kept for assignments predating the prefix. Lowercased like every repository name in the model. + /// + internal static string BuildRepositoryName(Assignment assignment, string neptun) + { + ArgumentNullException.ThrowIfNull(assignment); + + var prefix = assignment.RepoNamePrefix; + if (string.IsNullOrWhiteSpace(prefix)) + (_, prefix) = IAssignmentService.SplitRepoName(assignment.TemplateRepoName); + + return Normalize.RepoName($"{prefix}-{neptun}"); + } + + private InviteState Describe(Course course, Assignment assignment, ApplicationUser user, AssignmentAcceptance? acceptance) + { + var state = new InviteState + { + CourseName = course.Name, + CourseSlug = course.Slug, + AssignmentName = assignment.Name, + AssignmentDescription = assignment.Description, + Organization = course.GitHubOrganization, + GitHubUsername = user.GitHubUsername, + }; + + if (acceptance is not null) + { + var (owner, name) = IAssignmentService.SplitRepoName(acceptance.GitHubRepoName); + + state.Status = InviteStatus.Accepted; + state.RepositoryName = name; + state.RepoUrl = acceptance.RepoUrl; + state.GitHubUsername = acceptance.GitHubUsername; + state.InvitationUrl = acceptance.InvitationPending ? $"https://github.com/{owner}/{name}/invitations" : null; + return state; + } + + if (string.IsNullOrWhiteSpace(course.GitHubOrganization)) + { + state.Status = InviteStatus.NotConfigured; + state.Message = "This course is not connected to a GitHub organization yet. Ask your instructor to finish setting it up."; + return state; + } + + if (string.IsNullOrWhiteSpace(user.NeptunCode)) + { + state.Status = InviteStatus.NeedsNeptun; + state.Message = "This account has no Neptun code, so no repository can be created for it. Sign in with your BME eduID account instead."; + return state; + } + + state.RepositoryName = BuildRepositoryName(assignment, Normalize.Neptun(user.NeptunCode)); + + if (string.IsNullOrWhiteSpace(user.GitHubUsername)) + { + state.Status = InviteStatus.NeedsGitHubUsername; + return state; + } + + state.Status = InviteStatus.ReadyToAccept; + return state; + } + + private static InviteState NotConfigured(InviteState state) + { + state.Status = InviteStatus.NotConfigured; + state.Message = "This course has no working GitHub App configured, so repositories cannot be created. Ask your instructor to check it."; + return state; + } + + private async Task<(Course Course, Assignment Assignment)?> LoadAsync(int courseId, string inviteToken, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(inviteToken)) + return null; + + var assignment = await db.Assignments.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(a => a.CourseId == courseId && a.InviteToken == inviteToken, cancellationToken); + + if (assignment is null) + return null; + + var course = await db.Courses.AsNoTracking() + .Include(c => c.GitHubConfig) + .FirstOrDefaultAsync(c => c.Id == courseId, cancellationToken); + + return course is null ? null : (course, assignment); + } + + private async Task FindAcceptanceAsync(int assignmentId, int userId, CancellationToken cancellationToken) => + await db.AssignmentAcceptances.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(a => a.AssignmentId == assignmentId && a.UserId == userId, cancellationToken); +} diff --git a/ahk-backend/Ahk.Web.Services/Assignments/AssignmentService.cs b/ahk-backend/Ahk.Web.Services/Assignments/AssignmentService.cs new file mode 100644 index 0000000..6c1f81e --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Assignments/AssignmentService.cs @@ -0,0 +1,264 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Assignments; + +/// What an instructor supplies when creating or editing an assignment. +public sealed class AssignmentInput +{ + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// Either "owner/name" or a bare repository name, which is taken to be in the course's organization. + public string TemplateRepoName { get; set; } = string.Empty; + + /// Prefix for generated student repositories ({prefix}-{neptun}); blank falls back to the template name. + public string? RepoNamePrefix { get; set; } +} + +/// +/// The result of checking an assignment's template repository on GitHub. Advisory: an assignment may be drafted +/// before its template exists, so a problem here is reported, never enforced. +/// +public sealed record TemplateCheck(bool Reachable, bool IsTemplate, string? HtmlUrl, string? Problem); + +public interface IAssignmentService +{ + Task> ListAsync(int courseId, bool includeArchived, CancellationToken cancellationToken = default); + + Task GetAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default); + + Task CreateAsync(int courseId, AssignmentInput input, CancellationToken cancellationToken = default); + + Task UpdateAsync(int courseId, int assignmentId, AssignmentInput input, CancellationToken cancellationToken = default); + + /// Archives or restores. Archiving also closes the invite link to students who have not accepted yet. + Task SetArchivedAsync(int courseId, int assignmentId, bool archived, CancellationToken cancellationToken = default); + + /// Issues a fresh invite token, which invalidates every copy of the old link. + Task RegenerateInviteTokenAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default); + + /// Deletes an assignment. Refuses once students have accepted it — archive those instead. + Task DeleteAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default); + + Task> ListAcceptancesAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default); + + /// Acceptances per assignment for a whole course, so the listing needs one query rather than one per row. + Task> CountAcceptancesAsync(int courseId, CancellationToken cancellationToken = default); + + /// Verifies the template repository exists and is marked as a template. Never throws. + Task CheckTemplateAsync(int courseId, string templateRepoName, CancellationToken cancellationToken = default); + + /// Splits a stored "owner/name" into its parts. + static (string Owner, string Name) SplitRepoName(string fullName) + { + ArgumentNullException.ThrowIfNull(fullName); + + var separator = fullName.IndexOf('/', StringComparison.Ordinal); + return separator < 0 + ? (string.Empty, fullName) + : (fullName[..separator], fullName[(separator + 1)..]); + } +} + +/// +/// Assignment administration for instructors. Like every other service here it takes an explicit +/// courseId and reads with IgnoreQueryFilters(): the ambient course filter follows +/// , which is only set on requests that carry a {course} route segment. +/// +public sealed class AssignmentService : IAssignmentService +{ + private readonly ApplicationDbContext db; + private readonly IGitHubRepositoryService gitHub; + private readonly ICourseGitHubAppTokenProvider tokens; + + public AssignmentService(ApplicationDbContext db, IGitHubRepositoryService gitHub, ICourseGitHubAppTokenProvider tokens) + { + this.db = db; + this.gitHub = gitHub; + this.tokens = tokens; + } + + public async Task> ListAsync(int courseId, bool includeArchived, CancellationToken cancellationToken = default) + { + var query = db.Assignments.IgnoreQueryFilters().AsNoTracking().Where(a => a.CourseId == courseId); + + if (!includeArchived) + query = query.Where(a => a.ArchivedAt == null); + + return await query + .OrderBy(a => a.ArchivedAt == null ? 0 : 1) + .ThenByDescending(a => a.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task GetAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default) => + await db.Assignments.IgnoreQueryFilters().AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == assignmentId && a.CourseId == courseId, cancellationToken); + + public async Task CreateAsync(int courseId, AssignmentInput input, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(input); + + var assignment = new Assignment + { + CourseId = courseId, + Name = input.Name.Trim(), + Description = Trimmed(input.Description), + TemplateRepoName = await QualifyRepoNameAsync(courseId, input.TemplateRepoName, cancellationToken), + RepoNamePrefix = NormalizedPrefix(input.RepoNamePrefix), + InviteToken = TokenGenerator.UrlSafe(18), + }; + + db.Assignments.Add(assignment); + await db.SaveChangesAsync(cancellationToken); + return assignment; + } + + public async Task UpdateAsync(int courseId, int assignmentId, AssignmentInput input, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(input); + + var assignment = await TrackedAsync(courseId, assignmentId, cancellationToken); + if (assignment is null) + return null; + + assignment.Name = input.Name.Trim(); + assignment.Description = Trimmed(input.Description); + assignment.TemplateRepoName = await QualifyRepoNameAsync(courseId, input.TemplateRepoName, cancellationToken); + assignment.RepoNamePrefix = NormalizedPrefix(input.RepoNamePrefix); + + await db.SaveChangesAsync(cancellationToken); + return assignment; + } + + public async Task SetArchivedAsync(int courseId, int assignmentId, bool archived, CancellationToken cancellationToken = default) + { + var assignment = await TrackedAsync(courseId, assignmentId, cancellationToken); + if (assignment is null) + return null; + + assignment.ArchivedAt = archived ? assignment.ArchivedAt ?? DateTimeOffset.UtcNow : null; + await db.SaveChangesAsync(cancellationToken); + return assignment; + } + + public async Task RegenerateInviteTokenAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default) + { + var assignment = await TrackedAsync(courseId, assignmentId, cancellationToken); + if (assignment is null) + return null; + + assignment.InviteToken = TokenGenerator.UrlSafe(18); + await db.SaveChangesAsync(cancellationToken); + return assignment; + } + + public async Task DeleteAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default) + { + var assignment = await TrackedAsync(courseId, assignmentId, cancellationToken); + if (assignment is null) + return false; + + // Repositories exist on GitHub for every acceptance; deleting the record would orphan them and lose the + // audit trail of who got what. Archiving is the operation the instructor actually wants. + var accepted = await db.AssignmentAcceptances.IgnoreQueryFilters() + .AnyAsync(a => a.AssignmentId == assignmentId, cancellationToken); + + if (accepted) + return false; + + db.Assignments.Remove(assignment); + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task> ListAcceptancesAsync(int courseId, int assignmentId, CancellationToken cancellationToken = default) => + await db.AssignmentAcceptances.IgnoreQueryFilters().AsNoTracking() + .Include(a => a.User) + .Where(a => a.CourseId == courseId && a.AssignmentId == assignmentId) + .OrderByDescending(a => a.AcceptedAt) + .ToListAsync(cancellationToken); + + public async Task> CountAcceptancesAsync(int courseId, CancellationToken cancellationToken = default) + { + var counts = await db.AssignmentAcceptances.IgnoreQueryFilters().AsNoTracking() + .Where(a => a.CourseId == courseId) + .GroupBy(a => a.AssignmentId) + .Select(g => new { AssignmentId = g.Key, Count = g.Count() }) + .ToListAsync(cancellationToken); + + return counts.ToDictionary(c => c.AssignmentId, c => c.Count); + } + + public async Task CheckTemplateAsync(int courseId, string templateRepoName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(templateRepoName)) + return new TemplateCheck(false, false, null, "No template repository is set."); + + var token = await tokens.GetForCourseAsync(courseId, bypassCache: false, cancellationToken); + if (token is null) + return new TemplateCheck(false, false, null, "This course has no GitHub App configured, so the template cannot be checked."); + + var qualified = await QualifyRepoNameAsync(courseId, templateRepoName, cancellationToken); + var (owner, name) = IAssignmentService.SplitRepoName(qualified); + + try + { + var repository = await gitHub.GetRepositoryAsync(owner, name, token.Token, cancellationToken); + if (repository is null) + return new TemplateCheck(false, false, null, $"GitHub has no repository called '{qualified}', or the App cannot see it."); + + return repository.IsTemplate + ? new TemplateCheck(true, true, repository.HtmlUrl, null) + : new TemplateCheck(true, false, repository.HtmlUrl, $"'{qualified}' exists but is not marked as a template repository. Turn on \"Template repository\" in its settings."); + } + catch (GitHubOperationException ex) + { + return new TemplateCheck(false, false, null, ex.GitHubMessage ?? ex.Message); + } + catch (HttpRequestException ex) + { + return new TemplateCheck(false, false, null, $"GitHub could not be reached: {ex.Message}"); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new TemplateCheck(false, false, null, "GitHub did not respond in time."); + } + } + + private async Task TrackedAsync(int courseId, int assignmentId, CancellationToken cancellationToken) => + await db.Assignments.IgnoreQueryFilters() + .FirstOrDefaultAsync(a => a.Id == assignmentId && a.CourseId == courseId, cancellationToken); + + /// + /// Accepts a bare repository name and completes it with the course's organization, so an instructor does + /// not have to retype "ahk-viaubc01/" on every assignment. Always stored normalized, like every other + /// repository name in the model. + /// + private async Task QualifyRepoNameAsync(int courseId, string templateRepoName, CancellationToken cancellationToken) + { + var value = (templateRepoName ?? string.Empty).Trim().Trim('/'); + if (value.Contains('/', StringComparison.Ordinal)) + return Normalize.RepoName(value); + + var organization = await db.Courses.AsNoTracking() + .Where(c => c.Id == courseId) + .Select(c => c.GitHubOrganization) + .FirstOrDefaultAsync(cancellationToken); + + return Normalize.RepoName(string.IsNullOrWhiteSpace(organization) ? value : $"{organization}/{value}"); + } + + private static string? Trimmed(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// + /// Stores the repo-name prefix normalized like every other repository name (lowercased, trimmed), or null + /// when blank so falls back to the template name. + /// + private static string? NormalizedPrefix(string? value) => + string.IsNullOrWhiteSpace(value) ? null : Normalize.RepoName(value); +} diff --git a/ahk-backend/Ahk.Web.Services/Assignments/StudentAssignmentService.cs b/ahk-backend/Ahk.Web.Services/Assignments/StudentAssignmentService.cs new file mode 100644 index 0000000..2f14628 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Assignments/StudentAssignmentService.cs @@ -0,0 +1,233 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Ahk.Web.Services.Assignments; + +/// Whether the student can actually open the repository yet. +public enum RepositoryAccess +{ + /// They have push access. + Active, + + /// GitHub has invited them and is waiting; the repository is invisible to them until they accept. + InvitationPending, + + /// The invitation ran out. It has to be re-sent before they can get in. + InvitationExpired, + + /// GitHub could not be asked just now, so the last known state is reported. + Unknown, +} + +/// One repository a student holds, with the course and assignment it belongs to. +public sealed class StudentRepository +{ + public int AcceptanceId { get; set; } + + public string CourseSlug { get; set; } = string.Empty; + + public string CourseName { get; set; } = string.Empty; + + public string AssignmentName { get; set; } = string.Empty; + + public string GitHubRepoName { get; set; } = string.Empty; + + public string RepoUrl { get; set; } = string.Empty; + + public DateTimeOffset AcceptedAt { get; set; } + + public RepositoryAccess Access { get; set; } + + /// Where the student accepts a pending invitation; null once they have access. + public string? InvitationUrl { get; set; } + + public DateTimeOffset? InvitationSentAt { get; set; } +} + +public interface IStudentAssignmentService +{ + Task> ListForUserAsync(int userId, CancellationToken cancellationToken = default); + + /// + /// Issues a fresh invitation for a repository the student still cannot open. Returns null when the + /// acceptance is not theirs or does not exist. + /// + Task ResendInvitationAsync(int userId, int acceptanceId, CancellationToken cancellationToken = default); +} + +/// +/// The student's own view of the system: every repository they hold, across every course, and a way to get back +/// in when a GitHub invitation lapsed before they clicked it. +/// +/// ⚠️ These calls arrive on routes with no {course} segment, so no current course is resolved and the ambient +/// query filter would match nothing. Every read here uses IgnoreQueryFilters() and filters on the user. +/// +public sealed class StudentAssignmentService : IStudentAssignmentService +{ + private readonly ApplicationDbContext db; + private readonly IGitHubRepositoryService gitHub; + private readonly ICourseGitHubAppTokenProvider tokens; + private readonly ILogger logger; + + public StudentAssignmentService( + ApplicationDbContext db, + IGitHubRepositoryService gitHub, + ICourseGitHubAppTokenProvider tokens, + ILogger logger) + { + this.db = db; + this.gitHub = gitHub; + this.tokens = tokens; + this.logger = logger; + } + + public async Task> ListForUserAsync(int userId, CancellationToken cancellationToken = default) + { + var acceptances = await db.AssignmentAcceptances.IgnoreQueryFilters() + .Include(a => a.Assignment) + .Include(a => a.Course) + .Where(a => a.UserId == userId) + .OrderByDescending(a => a.AcceptedAt) + .ToListAsync(cancellationToken); + + var changed = false; + var results = new List(acceptances.Count); + + foreach (var acceptance in acceptances) + { + // Only rows we believe are waiting cost a GitHub call — a student holds a handful of repositories, + // and most of them are settled. + if (acceptance.InvitationPending) + changed |= await RefreshInvitationStateAsync(acceptance, cancellationToken); + + results.Add(Project(acceptance)); + } + + if (changed) + await db.SaveChangesAsync(cancellationToken); + + return results; + } + + public async Task ResendInvitationAsync(int userId, int acceptanceId, CancellationToken cancellationToken = default) + { + var acceptance = await db.AssignmentAcceptances.IgnoreQueryFilters() + .Include(a => a.Assignment) + .Include(a => a.Course) + .FirstOrDefaultAsync(a => a.Id == acceptanceId && a.UserId == userId, cancellationToken); + + if (acceptance is null) + return null; + + var token = await tokens.GetForCourseAsync(acceptance.CourseId, bypassCache: false, cancellationToken); + if (token is null) + { + acceptance.InvitationPending = true; + return Project(acceptance); + } + + var (owner, name) = IAssignmentService.SplitRepoName(acceptance.GitHubRepoName); + + // They may have accepted since the page was drawn; re-sending would then be a pointless invitation. + if (await gitHub.IsCollaboratorAsync(owner, name, acceptance.GitHubUsername, token.Token, cancellationToken)) + { + ClearInvitation(acceptance); + await db.SaveChangesAsync(cancellationToken); + return Project(acceptance); + } + + // GitHub has no "extend"; the stale invitation has to go before a fresh one can be issued. + var existing = await gitHub.FindInvitationAsync(owner, name, acceptance.GitHubUsername, token.Token, cancellationToken); + if (existing is not null) + await gitHub.DeleteInvitationAsync(owner, name, existing.Id, token.Token, cancellationToken); + + var result = await gitHub.AddCollaboratorAsync(owner, name, acceptance.GitHubUsername, token.Token, cancellationToken); + + acceptance.InvitationPending = result.InvitationCreated; + acceptance.InvitationId = result.InvitationId; + acceptance.InvitationSentAt = result.InvitationCreated ? DateTimeOffset.UtcNow : null; + + await db.SaveChangesAsync(cancellationToken); + return Project(acceptance); + } + + /// + /// Asks GitHub what really happened to a pending invitation. Returns true when the stored state changed. + /// Never throws: this runs while rendering the student's home page, and an unreachable GitHub must degrade + /// to "unknown", not to an error page. + /// + private async Task RefreshInvitationStateAsync(AssignmentAcceptance acceptance, CancellationToken cancellationToken) + { + try + { + var token = await tokens.GetForCourseAsync(acceptance.CourseId, bypassCache: false, cancellationToken); + if (token is null) + return false; + + var (owner, name) = IAssignmentService.SplitRepoName(acceptance.GitHubRepoName); + + if (await gitHub.IsCollaboratorAsync(owner, name, acceptance.GitHubUsername, token.Token, cancellationToken)) + { + ClearInvitation(acceptance); + return true; + } + + var invitation = await gitHub.FindInvitationAsync(owner, name, acceptance.GitHubUsername, token.Token, cancellationToken); + + // No access and no invitation at all: it lapsed and GitHub cleaned it up. Treat it as expired so + // the student is offered the resend button rather than left waiting for nothing. + if (invitation is null) + { + acceptance.InvitationId = null; + return true; + } + + if (invitation.Id != acceptance.InvitationId) + { + acceptance.InvitationId = invitation.Id; + return true; + } + + return false; + } + catch (Exception ex) when (ex is GitHubOperationException or HttpRequestException or TaskCanceledException) + { + logger.LogWarning(ex, "Could not refresh the invitation state of {Repository}.", acceptance.GitHubRepoName); + return false; + } + } + + private static void ClearInvitation(AssignmentAcceptance acceptance) + { + acceptance.InvitationPending = false; + acceptance.InvitationId = null; + acceptance.InvitationSentAt = null; + } + + private static StudentRepository Project(AssignmentAcceptance acceptance) + { + var (owner, name) = IAssignmentService.SplitRepoName(acceptance.GitHubRepoName); + + // A pending row with no invitation id left is one GitHub has already dropped: expired, not waiting. + var access = acceptance.InvitationPending + ? acceptance.InvitationId is null ? RepositoryAccess.InvitationExpired : RepositoryAccess.InvitationPending + : RepositoryAccess.Active; + + return new StudentRepository + { + AcceptanceId = acceptance.Id, + CourseSlug = acceptance.Course?.Slug ?? string.Empty, + CourseName = acceptance.Course?.Name ?? string.Empty, + AssignmentName = acceptance.Assignment?.Name ?? string.Empty, + GitHubRepoName = acceptance.GitHubRepoName, + RepoUrl = acceptance.RepoUrl, + AcceptedAt = acceptance.AcceptedAt, + Access = access, + InvitationUrl = acceptance.InvitationPending ? $"https://github.com/{owner}/{name}/invitations" : null, + InvitationSentAt = acceptance.InvitationSentAt, + }; + } +} diff --git a/ahk-backend/Ahk.Web.Services/Courses/CourseResolutionService.cs b/ahk-backend/Ahk.Web.Services/Courses/CourseResolutionService.cs new file mode 100644 index 0000000..f499aaa --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Courses/CourseResolutionService.cs @@ -0,0 +1,70 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Courses; + +public interface ICourseResolutionService +{ + Task ResolveBySlugAsync(string slug, CancellationToken cancellationToken = default); + + Task ResolveByRepositoryAsync(string gitHubRepoName, CancellationToken cancellationToken = default); + + Task ResolveByWebhookTokenAsync(string token, CancellationToken cancellationToken = default); +} + +/// +/// Maps an incoming request to a course. Browser requests use the {course} route slug; machine-to-machine +/// requests have no such segment, so they resolve from the payload's repository (organization + repo-name +/// prefix) or from the CI callback token. +/// +/// The repo-name prefix rule is the explicit form of the original system's implicit convention, where a course +/// *was* a repository-name prefix (ListConfirmedWithRepositoryPrefix / ListEventsForRepositories). +/// +public sealed class CourseResolutionService : ICourseResolutionService +{ + private readonly ApplicationDbContext db; + + public CourseResolutionService(ApplicationDbContext db) => this.db = db; + + public Task ResolveBySlugAsync(string slug, CancellationToken cancellationToken = default) + => db.Courses.AsNoTracking().FirstOrDefaultAsync(c => c.Slug == slug, cancellationToken); + + public async Task ResolveByRepositoryAsync(string gitHubRepoName, CancellationToken cancellationToken = default) + { + var repo = Normalize.RepoName(gitHubRepoName); + if (string.IsNullOrEmpty(repo)) + return null; + + // "owner/name" — the owner is the GitHub organization. + var slashIndex = repo.IndexOf('/', StringComparison.Ordinal); + var org = slashIndex > 0 ? repo[..slashIndex] : null; + var nameOnly = slashIndex > 0 ? repo[(slashIndex + 1)..] : repo; + + var candidates = await db.Courses.AsNoTracking() + .Where(c => c.GitHubOrganization != null && c.GitHubOrganization == org) + .ToListAsync(cancellationToken); + + if (candidates.Count == 0) + return null; + + if (candidates.Count == 1) + return candidates[0]; + + // Several courses share the organization: disambiguate on the repo-name prefix, longest match wins. + return candidates + .Where(c => !string.IsNullOrEmpty(c.RepoNamePrefix) && nameOnly.StartsWith(c.RepoNamePrefix!, StringComparison.Ordinal)) + .OrderByDescending(c => c.RepoNamePrefix!.Length) + .FirstOrDefault(); + } + + public async Task ResolveByWebhookTokenAsync(string token, CancellationToken cancellationToken = default) + { + var match = await db.CourseWebhookTokens + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Token == token && t.RevokedAt == null, cancellationToken); + + return match is null ? null : await db.Courses.AsNoTracking().FirstOrDefaultAsync(c => c.Id == match.CourseId, cancellationToken); + } +} diff --git a/ahk-backend/Ahk.Web.Services/Courses/WebhookTokenService.cs b/ahk-backend/Ahk.Web.Services/Courses/WebhookTokenService.cs new file mode 100644 index 0000000..b4062e0 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Courses/WebhookTokenService.cs @@ -0,0 +1,108 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Ahk.Web.Services.Courses; + +public interface IWebhookTokenService +{ + Task GetSecretForTokenAsync(string token, CancellationToken cancellationToken = default); + + /// + /// Lists a course's tokens, newest first, including the plaintext secret. Every caller is an admin who could + /// mint an equivalent token anyway, so the console can copy an existing secret rather than re-issue. + /// + Task> ListForCourseAsync(int courseId, CancellationToken cancellationToken = default); + + /// Issues a new token/secret pair for a course. The returned entity carries the plaintext secret. + Task CreateAsync(int courseId, string? description, CancellationToken cancellationToken = default); + + /// Revokes a token so callbacks signed with it are rejected. Returns false when it does not exist. + Task RevokeAsync(int courseId, int tokenId, CancellationToken cancellationToken = default); +} + +/// +/// Owns the CI callback tokens: the hot-path secret lookup used by the evaluation-result webhook and the +/// administrative issue/revoke operations. Port of +/// grade-management/.../TokenManagement/TokenManagementService.cs, including its one-hour memory cache — +/// this runs on every evaluation-result callback, so the cache matters. +/// +/// Issue and revoke live here rather than in the controller precisely because of that cache: a revoked token +/// would otherwise keep authenticating callbacks for up to an hour. +/// +public sealed class WebhookTokenService : IWebhookTokenService +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + private readonly ApplicationDbContext db; + private readonly IMemoryCache cache; + + public WebhookTokenService(ApplicationDbContext db, IMemoryCache cache) + { + this.db = db; + this.cache = cache; + } + + public async Task GetSecretForTokenAsync(string token, CancellationToken cancellationToken = default) + { + var key = CacheKey(token); + if (cache.TryGetValue(key, out var cached)) + return cached; + + var secret = await db.CourseWebhookTokens + .IgnoreQueryFilters() + .AsNoTracking() + .Where(t => t.Token == token && t.RevokedAt == null) + .Select(t => t.Secret) + .FirstOrDefaultAsync(cancellationToken); + + cache.Set(key, secret, CacheDuration); + return secret; + } + + public async Task> ListForCourseAsync(int courseId, CancellationToken cancellationToken = default) => + await db.CourseWebhookTokens + .IgnoreQueryFilters() + .AsNoTracking() + .Where(t => t.CourseId == courseId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + + public async Task CreateAsync(int courseId, string? description, CancellationToken cancellationToken = default) + { + var entity = new CourseWebhookToken + { + CourseId = courseId, + Token = TokenGenerator.UrlSafe(24), + Secret = TokenGenerator.UrlSafe(32), + Description = description, + }; + + db.CourseWebhookTokens.Add(entity); + await db.SaveChangesAsync(cancellationToken); + return entity; + } + + public async Task RevokeAsync(int courseId, int tokenId, CancellationToken cancellationToken = default) + { + var entity = await db.CourseWebhookTokens + .IgnoreQueryFilters() + .FirstOrDefaultAsync(t => t.Id == tokenId && t.CourseId == courseId, cancellationToken); + + if (entity is null) + return false; + + if (entity.RevokedAt is null) + { + entity.RevokedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + + // Drop the cached secret, otherwise the revoked token keeps authenticating callbacks until it expires. + cache.Remove(CacheKey(entity.Token)); + return true; + } + + private static string CacheKey(string token) => $"secrettotoken{token}"; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubAppTokenProvider.cs b/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubAppTokenProvider.cs new file mode 100644 index 0000000..87eaf39 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubAppTokenProvider.cs @@ -0,0 +1,203 @@ +using System.Buffers.Text; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Ahk.Web.Services.GitHub; + +/// An installation access token plus the permissions GitHub actually granted it. +public sealed record GitHubInstallationToken(string Token, long InstallationId, IReadOnlyDictionary Permissions, string? RepositorySelection) +{ + /// + /// True when the installation may create repositories, add collaborators and change repository settings — + /// everything the assignment flow does. Reported by the health check so an administrator sees the gap + /// before a student walks into a 403. + /// + public bool HasAdministrationWrite => + Permissions.TryGetValue("administration", out var level) && string.Equals(level, "write", StringComparison.Ordinal); +} + +public interface ICourseGitHubAppTokenProvider +{ + /// + /// Mints (or returns a cached) installation token for the course's organization. Returns null — never + /// throws — when the course has no GitHub App configured or no organization, so callers can turn that into + /// a clean "this course is not connected to GitHub yet" message. + /// + Task GetForCourseAsync(int courseId, bool bypassCache = false, CancellationToken cancellationToken = default); + + /// Overload for callers that already loaded the course with its . + Task GetForCourseAsync(Course course, bool bypassCache = false, CancellationToken cancellationToken = default); +} + +/// +/// Turns a course's GitHub App credentials into an installation access token, the identity every write the +/// portal performs on GitHub runs as. Port of the flow in +/// github-monitor/.../GitHubClientFactory.cs, with two differences: the installation is looked up from +/// the organization (there is no webhook payload to read it from), and the JWT is built with +/// rather than the hand-rolled DER reader that predates it. +/// +public sealed class CourseGitHubAppTokenProvider : ICourseGitHubAppTokenProvider +{ + /// GitHub issues installation tokens for 60 minutes; renew with room to spare. + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(50); + + private readonly ApplicationDbContext db; + private readonly IHttpClientFactory httpClientFactory; + private readonly IMemoryCache cache; + + public CourseGitHubAppTokenProvider(ApplicationDbContext db, IHttpClientFactory httpClientFactory, IMemoryCache cache) + { + this.db = db; + this.httpClientFactory = httpClientFactory; + this.cache = cache; + } + + public async Task GetForCourseAsync(int courseId, bool bypassCache = false, CancellationToken cancellationToken = default) + { + // Course itself is not course-scoped, but the include is, so this is a plain lookup by primary key. + var course = await db.Courses + .AsNoTracking() + .Include(c => c.GitHubConfig) + .FirstOrDefaultAsync(c => c.Id == courseId, cancellationToken); + + return course is null ? null : await GetForCourseAsync(course, bypassCache, cancellationToken); + } + + public async Task GetForCourseAsync(Course course, bool bypassCache = false, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(course); + + var appId = course.GitHubConfig?.GitHubAppId; + var privateKey = course.GitHubConfig?.GitHubAppPrivateKey; + var organization = course.GitHubOrganization; + + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(privateKey) || string.IsNullOrWhiteSpace(organization)) + return null; + + var key = $"githubinstallationtoken_{course.Id}"; + if (!bypassCache && cache.TryGetValue(key, out var cached) && cached is not null) + return cached; + + var token = await MintAsync(appId, privateKey, organization, cancellationToken); + cache.Set(key, token, CacheDuration); + return token; + } + + private async Task MintAsync(string appId, string privateKey, string organization, CancellationToken cancellationToken) + { + var jwt = CreateAppJwt(appId, privateKey); + + using var client = httpClientFactory.CreateClient(GitHubApiDefaults.HttpClientName); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + + // Which installation of this App owns the course's organization. + using var installationResponse = await client.GetAsync( + new Uri($"orgs/{Uri.EscapeDataString(organization)}/installation", UriKind.Relative), cancellationToken); + await EnsureSuccessAsync(installationResponse, $"looking up the App installation on '{organization}'", cancellationToken); + + using var installationDocument = await ReadJsonAsync(installationResponse, cancellationToken); + var installationId = installationDocument.RootElement.GetProperty("id").GetInt64(); + + // Exchange the App JWT for a token scoped to that installation. + using var tokenResponse = await client.PostAsync( + new Uri($"app/installations/{installationId}/access_tokens", UriKind.Relative), content: null, cancellationToken); + await EnsureSuccessAsync(tokenResponse, "creating an installation access token", cancellationToken); + + using var tokenDocument = await ReadJsonAsync(tokenResponse, cancellationToken); + var root = tokenDocument.RootElement; + + var permissions = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("permissions", out var permissionsElement) && permissionsElement.ValueKind == JsonValueKind.Object) + { + foreach (var permission in permissionsElement.EnumerateObject()) + permissions[permission.Name] = permission.Value.GetString() ?? string.Empty; + } + + var repositorySelection = root.TryGetProperty("repository_selection", out var selection) ? selection.GetString() : null; + + return new GitHubInstallationToken( + root.GetProperty("token").GetString() ?? string.Empty, + installationId, + permissions, + repositorySelection); + } + + /// + /// The App-level JWT: RS256, ten minutes, issued by the App id. GitHub accepts nothing else at + /// /app/*, and it is only ever used to obtain the installation token. + /// + private static string CreateAppJwt(string appId, string privateKey) + { + var now = DateTimeOffset.UtcNow; + + // 60 seconds of backdating absorbs clock skew between this server and GitHub, which otherwise rejects + // the JWT outright ("'iat' is in the future"). + var header = """{"alg":"RS256","typ":"JWT"}"""; + var payload = $$"""{"iat":{{now.AddSeconds(-60).ToUnixTimeSeconds()}},"exp":{{now.AddMinutes(10).ToUnixTimeSeconds()}},"iss":"{{appId}}"}"""; + + var signingInput = $"{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(header))}.{Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payload))}"; + + using var rsa = ImportPrivateKey(privateKey); + var signature = rsa.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{signingInput}.{Base64Url.EncodeToString(signature)}"; + } + + /// + /// Accepts either the PEM file GitHub hands out on key creation, or the bare base64 DER body that + /// github-monitor's AHK_GitHubAppPrivateKey holds — an administrator migrating a course should be + /// able to paste what they already have. + /// + private static RSA ImportPrivateKey(string privateKey) + { + var value = privateKey.Trim(); + var rsa = RSA.Create(); + + try + { + if (value.Contains("-----BEGIN", StringComparison.Ordinal)) + { + rsa.ImportFromPem(value); + return rsa; + } + + var der = Convert.FromBase64String(new string(value.Where(c => !char.IsWhiteSpace(c)).ToArray())); + try + { + rsa.ImportRSAPrivateKey(der, out _); + } + catch (CryptographicException) + { + rsa.ImportPkcs8PrivateKey(der, out _); + } + + return rsa; + } + catch + { + rsa.Dispose(); + throw; + } + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var message = await GitHubApiDefaults.ReadErrorMessageAsync(response, cancellationToken); + throw new GitHubOperationException(operation, response.StatusCode, message); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubClientFactory.cs b/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubClientFactory.cs new file mode 100644 index 0000000..40d262c --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHub/CourseGitHubClientFactory.cs @@ -0,0 +1,65 @@ +using Octokit; +using Octokit.Internal; + +namespace Ahk.Web.Services.GitHub; + +public interface ICourseGitHubClientFactory +{ + /// + /// An Octokit client authenticated as the course's GitHub App installation, or null when the course has no + /// working App credentials. Null rather than an exception, matching + /// . + /// + Task CreateForCourseAsync(int courseId, CancellationToken cancellationToken = default); + + /// + /// An Octokit client for a token the caller already holds. A null or empty token yields an anonymous + /// client, which still works for public reads at GitHub's 60-requests-an-hour rate. + /// + IGitHubClient CreateForToken(string? token); +} + +/// +/// Builds the portal's Octokit clients. Every GitHub API call the portal makes goes through one of these — the +/// assignment flow, the webhook handlers and the chatops commands alike. +/// +/// Deliberately not routed through the named "github" : Octokit wants a +/// Func<HttpMessageHandler> rather than a configured client, and the named client's +/// BaseAddress and default headers would fight Octokit's own. That named client stays in use by +/// (the App-JWT bootstrap, which is not an API call) and by the +/// health checks. +/// +public sealed class CourseGitHubClientFactory : ICourseGitHubClientFactory +{ + /// + /// Carried over from github-monitor's client factory. Deliberately shorter than it looks like it + /// should be: a webhook delivery that takes longer than GitHub's own delivery timeout is already lost, so + /// failing fast beats hanging on to the request. + /// + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(15); + + private static readonly ProductHeaderValue Product = new("ahk-portal", "1.0"); + + private readonly ICourseGitHubAppTokenProvider tokenProvider; + + public CourseGitHubClientFactory(ICourseGitHubAppTokenProvider tokenProvider) => this.tokenProvider = tokenProvider; + + public async Task CreateForCourseAsync(int courseId, CancellationToken cancellationToken = default) + { + var token = await tokenProvider.GetForCourseAsync(courseId, bypassCache: false, cancellationToken); + return token is null ? null : CreateForToken(token.Token); + } + + public IGitHubClient CreateForToken(string? token) + { + var credentials = string.IsNullOrWhiteSpace(token) + ? Credentials.Anonymous + : new Credentials(token); + + var connection = new Connection(Product, new InMemoryCredentialStore(credentials)); + var client = new GitHubClient(connection); + client.SetRequestTimeout(RequestTimeout); + + return client; + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHub/GitHubApiDefaults.cs b/ahk-backend/Ahk.Web.Services/GitHub/GitHubApiDefaults.cs new file mode 100644 index 0000000..15d534f --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHub/GitHubApiDefaults.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Ahk.Web.Services.GitHub; + +/// Shared constants and helpers for the raw api.github.com calls the portal makes. +public static class GitHubApiDefaults +{ + /// Named registered by AddAhkServices, based at api.github.com. + public const string HttpClientName = "github"; + + /// + /// GitHub's own explanation of a failure, from the message field of its error body. Returns null + /// rather than throwing on an unreadable body — this runs on the error path, where a second failure would + /// only hide the first. + /// + public static async Task ReadErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(response); + + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return document.RootElement.TryGetProperty("message", out var message) ? message.GetString() : null; + } + catch (Exception ex) when (ex is JsonException or HttpRequestException or InvalidOperationException) + { + return null; + } + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHub/GitHubOperationException.cs b/ahk-backend/Ahk.Web.Services/GitHub/GitHubOperationException.cs new file mode 100644 index 0000000..510f53f --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHub/GitHubOperationException.cs @@ -0,0 +1,43 @@ +using System.Net; + +namespace Ahk.Web.Services.GitHub; + +/// +/// A GitHub call that was expected to succeed did not. Carries GitHub's own status and message so the caller +/// can tell a student something true ("the organization does not allow this") rather than "something failed". +/// +public sealed class GitHubOperationException : Exception +{ + public GitHubOperationException(string operation, HttpStatusCode status, string? gitHubMessage) + : base(BuildMessage(operation, status, gitHubMessage)) + { + Operation = operation; + Status = status; + GitHubMessage = gitHubMessage; + } + + public GitHubOperationException() + { + } + + public GitHubOperationException(string message) + : base(message) + { + } + + public GitHubOperationException(string message, Exception innerException) + : base(message, innerException) + { + } + + public string? Operation { get; } + + public HttpStatusCode Status { get; } + + public string? GitHubMessage { get; } + + private static string BuildMessage(string operation, HttpStatusCode status, string? gitHubMessage) => + string.IsNullOrWhiteSpace(gitHubMessage) + ? $"GitHub returned {(int)status} {status} for {operation}." + : $"GitHub returned {(int)status} {status} for {operation}: {gitHubMessage}"; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHub/GitHubRepositoryService.cs b/ahk-backend/Ahk.Web.Services/GitHub/GitHubRepositoryService.cs new file mode 100644 index 0000000..26c206b --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHub/GitHubRepositoryService.cs @@ -0,0 +1,231 @@ +using System.Net; +using Octokit; + +namespace Ahk.Web.Services.GitHub; + +/// A GitHub account, as much of it as this application cares about. +public sealed record GitHubUser(string Login, long Id); + +/// A GitHub repository, as much of it as this application cares about. +public sealed record GitHubRepository(string FullName, string HtmlUrl, bool IsTemplate, string? DefaultBranch); + +/// +/// Outcome of sharing a repository with a student. GitHub adds an organization member outright (204) but only +/// *invites* anyone else (201) — and that invitation expires, so the two cases are not interchangeable. +/// +public sealed record CollaboratorResult(bool InvitationCreated, long? InvitationId); + +/// A pending repository invitation. +public sealed record GitHubInvitation(long Id, string? InviteeLogin, bool Expired, DateTimeOffset? CreatedAt); + +public interface IGitHubRepositoryService +{ + /// The account behind a login, or null when there is none. This is the typo check. + Task GetUserAsync(string login, string? token, CancellationToken cancellationToken = default); + + /// The repository, or null when it does not exist (or the token cannot see it). + Task GetRepositoryAsync(string owner, string name, string token, CancellationToken cancellationToken = default); + + /// Creates a private repository from a template repository. The template must be marked as one. + Task GenerateFromTemplateAsync(string templateOwner, string templateName, string owner, string name, string token, CancellationToken cancellationToken = default); + + /// Turns Actions on for a repository. Cheap insurance; a freshly generated repository normally has it already. + Task EnsureActionsEnabledAsync(string owner, string name, string token, CancellationToken cancellationToken = default); + + /// Grants a login push access, directly or by invitation depending on organization membership. + Task AddCollaboratorAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default); + + /// Whether the login already has access — the definitive answer to "did they accept the invitation". + Task IsCollaboratorAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default); + + /// The outstanding invitation for a login, or null when there is none. + Task FindInvitationAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default); + + Task DeleteInvitationAsync(string owner, string name, long invitationId, string token, CancellationToken cancellationToken = default); +} + +/// +/// The GitHub REST calls the assignment flow needs, over Octokit. +/// +/// The interface and its four record types are deliberately Octokit-free. They are narrow projections that keep +/// AssignmentInviteService and every test double away from Octokit's large, awkward-to-construct models, +/// and they are what lets AssignmentInviteTests mock this service strictly. +/// +/// Every method takes the caller's installation token explicitly rather than resolving a course itself, so the +/// service stays a thin, testable transport with no ambient state. +/// +public sealed class GitHubRepositoryService : IGitHubRepositoryService +{ + private readonly ICourseGitHubClientFactory clientFactory; + + public GitHubRepositoryService(ICourseGitHubClientFactory clientFactory) => this.clientFactory = clientFactory; + + public Task GetUserAsync(string login, string? token, CancellationToken cancellationToken = default) + { + // GET /users/{login} works unauthenticated; the token only lifts the rate limit from 60 to 5000/hour. + var client = clientFactory.CreateForToken(token); + + return ExecuteAsync( + $"looking up the GitHub user '{login}'", + async () => + { + var user = await client.User.Get(login); + return new GitHubUser(user.Login ?? login, user.Id); + }, + notFound: () => null); + } + + public Task GetRepositoryAsync(string owner, string name, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + return ExecuteAsync( + $"reading the repository '{owner}/{name}'", + async () => ToRepository(await client.Repository.Get(owner, name), $"{owner}/{name}"), + notFound: () => null); + } + + public Task GenerateFromTemplateAsync(string templateOwner, string templateName, string owner, string name, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + // include_all_branches is not sent, and GitHub defaults it to false: the student starts from the + // template's default branch, which is what the evaluator and the branch-protection rules assume. + var request = new NewRepositoryFromTemplate(name) + { + Owner = owner, + Private = true, + }; + + return ExecuteAsync( + $"creating '{owner}/{name}' from the template '{templateOwner}/{templateName}'", + async () => ToRepository(await client.Repository.Generate(templateOwner, templateName, request), $"{owner}/{name}")); + } + + public Task EnsureActionsEnabledAsync(string owner, string name, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + // Octokit has no first-class client for the Actions permissions endpoint; Connection keeps it on the + // same authenticated client rather than opening a second transport for one call. + return ExecuteAsync( + $"enabling Actions on '{owner}/{name}'", + async () => + { + await client.Connection.Put( + new Uri($"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}/actions/permissions", UriKind.Relative), + new { enabled = true }); + return true; + }); + } + + public Task AddCollaboratorAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + return ExecuteAsync( + $"granting '{login}' access to '{owner}/{name}'", + async () => + { + // GitHub answers 204 when the login was added outright (they are already an organization + // member) and 201 with the invitation when it was not. Octokit surfaces that as null vs a + // RepositoryInvitation, so the null check *is* the 204/201 distinction. + var invitation = await client.Repository.Collaborator.Add(owner, name, login, new CollaboratorRequest("push")); + + return invitation is null + ? new CollaboratorResult(InvitationCreated: false, InvitationId: null) + : new CollaboratorResult(InvitationCreated: true, InvitationId: invitation.Id); + }); + } + + public Task IsCollaboratorAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + return ExecuteAsync( + $"checking whether '{login}' can access '{owner}/{name}'", + () => client.Repository.Collaborator.IsCollaborator(owner, name, login), + notFound: () => false); + } + + public Task FindInvitationAsync(string owner, string name, string login, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + // Octokit's invitation client is keyed by repository *id*, which would cost an extra repository read + // per call. Connection keeps it to the one request the REST API actually needs. + return ExecuteAsync( + $"listing invitations of '{owner}/{name}'", + async () => + { + var response = await client.Connection.Get>( + new Uri($"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}/invitations", UriKind.Relative), + parameters: null); + + var invitation = response.Body?.FirstOrDefault( + i => string.Equals(i.Invitee?.Login, login, StringComparison.OrdinalIgnoreCase)); + + if (invitation is null) + return null; + + // GitHub reports expiry itself. Never compute it here: the window is GitHub's policy to change, + // and a stale local constant would have the portal telling students something untrue. + return new GitHubInvitation(invitation.Id, invitation.Invitee?.Login, invitation.Expired, invitation.CreatedAt); + }, + notFound: () => null); + } + + public Task DeleteInvitationAsync(string owner, string name, long invitationId, string token, CancellationToken cancellationToken = default) + { + var client = clientFactory.CreateForToken(token); + + return ExecuteAsync( + $"withdrawing invitation {invitationId} on '{owner}/{name}'", + async () => + { + await client.Connection.Delete( + new Uri($"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}/invitations/{invitationId}", UriKind.Relative)); + return true; + }, + // Already gone is the desired state, not a failure — the student may have accepted it meanwhile. + notFound: () => true); + } + + private static GitHubRepository ToRepository(Repository repository, string fallbackFullName) + { + var fullName = string.IsNullOrEmpty(repository.FullName) ? fallbackFullName : repository.FullName; + + return new GitHubRepository( + fullName, + string.IsNullOrEmpty(repository.HtmlUrl) ? $"https://github.com/{fullName}" : repository.HtmlUrl, + repository.IsTemplate, + repository.DefaultBranch); + } + + /// + /// Runs a GitHub call and translates Octokit's exceptions into , which + /// controllers surface as a 502 carrying GitHub's own explanation. turns a 404 + /// into a value instead, for the calls where "absent" is an answer rather than a failure. + /// + private static async Task ExecuteAsync(string operation, Func> call, Func? notFound = null) + { + try + { + return await call(); + } + catch (NotFoundException) when (notFound is not null) + { + return notFound(); + } + catch (ApiException ex) + { + throw new GitHubOperationException(operation, ex.StatusCode, ex.ApiError?.Message); + } + catch (OperationCanceledException ex) + { + // Octokit surfaces its own request timeout as a cancellation, which would otherwise look like the + // caller giving up rather than GitHub being slow. + throw new GitHubOperationException(operation, HttpStatusCode.GatewayTimeout, ex.Message); + } + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/ConfigYamlParser.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/ConfigYamlParser.cs new file mode 100644 index 0000000..6b69b3c --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/ConfigYamlParser.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; + +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// Reads the enabled flag out of a repository's .github/ahk-monitor.yml. Ported verbatim from +/// github-monitor: this is the opt-in gate that keeps the portal from acting on every repository in an +/// organization, and its exact accepted spellings (enabled, enabled: true, yes, 1) +/// are what existing course templates rely on. +/// +internal static partial class ConfigYamlParser +{ + [GeneratedRegex(@"^enabled:?\s*(?\w+)?", RegexOptions.IgnoreCase | RegexOptions.Multiline)] + private static partial Regex EnabledRegex(); + + public static bool IsEnabled(string? fileContent) + { + // Missing file -> disabled. + if (string.IsNullOrEmpty(fileContent)) + return false; + + // File content does not match -> disabled. + var m = EnabledRegex().Match(fileContent); + if (!m.Success) + return false; + + var value = m.Groups["value"]; + + // No "true" or other part, just "enabled" -> ok. + if (!value.Success) + return true; + + return value.Value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Value.Equals("1", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/EventHandlerResult.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/EventHandlerResult.cs new file mode 100644 index 0000000..11d7fc1 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/EventHandlerResult.cs @@ -0,0 +1,22 @@ +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// One handler's verdict on one delivery. The prefixes ("payload error", "no action needed", …) are what shows +/// up in the delivery log, so they are kept verbatim from github-monitor. +/// +public sealed class EventHandlerResult +{ + public EventHandlerResult(string result) => this.Result = result; + + public string Result { get; } + + public static EventHandlerResult PayloadError(string message) => new($"payload error: {message}"); + + public static EventHandlerResult NoActionNeeded(string message) => new($"no action needed: {message}"); + + public static EventHandlerResult ActionPerformed(string message) => new($"action performed: {message}"); + + public static EventHandlerResult EventNotOfInterest(string action) => new($"action not of interest: {action}"); + + public static EventHandlerResult Disabled(string? message = null) => new(message is null ? "event handler disabled" : $"event handler disabled: {message}"); +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubSignatureValidator.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubSignatureValidator.cs new file mode 100644 index 0000000..10affba --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubSignatureValidator.cs @@ -0,0 +1,40 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// Validates GitHub's X-Hub-Signature-256 header against the course's webhook secret. Ported verbatim +/// from github-monitor/.../Helpers/GitHubSignatureValidator.cs; the ASCII key encoding is GitHub's +/// scheme, not an oversight, and must not be "fixed" to UTF-8. +/// +public static class GitHubSignatureValidator +{ + public static bool IsSignatureValid(string requestBody, string? receivedSignature, string? secret) + { + if (string.IsNullOrEmpty(receivedSignature) || string.IsNullOrEmpty(secret)) + return false; + + var key = Encoding.ASCII.GetBytes(secret); + var requestBytes = Encoding.UTF8.GetBytes(requestBody ?? string.Empty); + + using var hmac = new HMACSHA256(key); + var hash = hmac.ComputeHash(requestBytes); + var expectedSignature = "sha256=" + ToHexString(hash); + + // Constant-time: github-monitor used string.Equals here, which returns at the first differing + // character and lets a forged signature be refined one character at a time by timing. See + // SignatureComparison. + return SignatureComparison.FixedTimeEquals(receivedSignature, expectedSignature); + } + + private static string ToHexString(byte[] bytes) + { + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + builder.AppendFormat(CultureInfo.InvariantCulture, "{0:x2}", b); + + return builder.ToString(); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookContext.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookContext.cs new file mode 100644 index 0000000..edab844 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookContext.cs @@ -0,0 +1,38 @@ +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// Everything one webhook delivery carries into the handlers. All per-delivery state lives here rather than on +/// the handler, which is what lets handlers be ordinary scoped DI registrations instead of the reflectively +/// activated, mutable objects github-monitor needed. +/// +/// The body has already been signature-verified by the time a context exists; handlers may trust it. +/// +public sealed class GitHubWebhookContext +{ + /// + /// The course the delivery's repository belongs to. Always passed explicitly into services — nothing on the + /// webhook path may rely on the ambient ICurrentCourseProvider, because the EF course filter matches + /// nothing when no course is set. + /// + public required int CourseId { get; init; } + + /// The X-GitHub-Event header. + public required string GitHubEventName { get; init; } + + /// The X-GitHub-Delivery header; empty when absent. Makes status-event writes idempotent. + public required string DeliveryId { get; init; } + + /// The raw, signature-verified request body. + public required string RequestBody { get; init; } + + /// Authenticated as the course's GitHub App installation. + public required IGitHubClient GitHubClient { get; init; } + + /// + /// From CourseGitHubConfig.WorkflowRunThreshold. Was a compile-time constant of 5 in + /// github-monitor, since each deployment served exactly one course. + /// + public required int WorkflowRunThreshold { get; init; } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookDispatcher.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookDispatcher.cs new file mode 100644 index 0000000..2e97ffc --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GitHubWebhookDispatcher.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; + +namespace Ahk.Web.Services.GitHubWebhooks; + +public interface IGitHubWebhookDispatcher +{ + Task ProcessAsync(GitHubWebhookContext context, WebhookResult result, CancellationToken cancellationToken = default); +} + +/// +/// Routes a delivery to every handler subscribed to its event name. Port of +/// github-monitor/.../Services/EventDispatch/EventDispatchService.cs. +/// +/// Two things carried over deliberately: handlers run sequentially in registration order (some +/// post comments and the order they appear in matters), and each is wrapped in its own try/catch so one +/// failing handler does not cost the others their run. The reflective +/// ActivatorUtilities.CreateInstance is gone — it only existed to hand the Azure Function's +/// per-invocation logger to a handler, which plain DI does for free. +/// +internal sealed class GitHubWebhookDispatcher : IGitHubWebhookDispatcher +{ + private readonly IReadOnlyList handlers; + private readonly ILogger logger; + + public GitHubWebhookDispatcher(IEnumerable handlers, ILogger logger) + { + this.handlers = handlers.ToList(); + this.logger = logger; + } + + public async Task ProcessAsync(GitHubWebhookContext context, WebhookResult result, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(result); + + var forEvent = handlers + .Where(h => string.Equals(h.GitHubEventName, context.GitHubEventName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (forEvent.Count == 0) + { + result.LogInfo($"Event {context.GitHubEventName} is not of interest"); + logger.LogInformation("Event {EventName} is not of interest", context.GitHubEventName); + return; + } + + foreach (var handler in forEvent) + { + var name = handler.GetType().Name; + logger.LogInformation("Event {EventName} being handled by {Handler}", context.GitHubEventName, name); + + try + { + var handlerResult = await handler.ExecuteAsync(context, cancellationToken); + logger.LogInformation("{Handler} result: {Result}", name, handlerResult.Result); + result.LogInfo($"{name} -> {handlerResult.Result}"); + } +#pragma warning disable CA1031 // One handler's failure must not cost the others their run. + catch (Exception ex) +#pragma warning restore CA1031 + { + logger.LogError(ex, "{Handler} execution failed", name); + result.LogError(ex, $"{name} -> exception"); + } + } + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GradeCommentParser.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GradeCommentParser.cs new file mode 100644 index 0000000..94f6b5d --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/GradeCommentParser.cs @@ -0,0 +1,66 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// Parses the teacher's /ahk ok chatops command out of a comment body. Ported verbatim from +/// github-monitor/.../Helpers/GradeCommentParser.cs. +/// +/// /ahk ok alone confirms the automated evaluation; /ahk ok 5 3.5 0 overrides it, the +/// numbers mapping positionally onto exercises. Both comma and dot are accepted as the decimal separator, +/// because Hungarian keyboards produce the comma. +/// +/// Two behaviours that look like bugs and are not: the loop does not break, so in a multi-line +/// comment the last matching line wins; and an unparseable number becomes +/// rather than throwing. +/// +internal sealed partial class GradeCommentParser +{ + [GeneratedRegex(@"^/ahk ok($|(\s.*))", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex CommandRegex(); + + [GeneratedRegex(@"[0-9]+([,\.][0-9]{1,3})?", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex GradesRegex(); + + public GradeCommentParser(string? value) + { + this.Grades = Array.Empty(); + + if (string.IsNullOrEmpty(value)) + return; + + var lines = value.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var m = CommandRegex().Match(line); + if (m.Success) + { + this.IsMatch = true; + this.Grades = GetGrades(m.Value); + } + } + } + + public bool IsMatch { get; } + + public IReadOnlyList Grades { get; } + + public bool HasGrades => IsMatch && Grades.Count > 0; + + private static IReadOnlyList GetGrades(string value) + => GradesRegex().Matches(value).Select(m => ParseNum(m.Value)).ToArray(); + + private static double ParseNum(string value) + { + // Try to parse as an int, or as a double with a decimal point. + if (double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var d1)) + return d1; + + // Replace commas with a decimal point. + if (double.TryParse(value.Replace(",", ".", StringComparison.OrdinalIgnoreCase), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var d2)) + return d2; + + return double.NaN; + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/ActionWorkflowRunHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/ActionWorkflowRunHandler.cs new file mode 100644 index 0000000..377ece7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/ActionWorkflowRunHandler.cs @@ -0,0 +1,79 @@ +using Ahk.Web.Services.GitHubWebhooks.Payloads; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Warns a student who has run the automated evaluation more times than the course allows. Ported from +/// github-monitor/.../EventHandlers/ActionWorkflowRunHandler.cs. +/// +/// The one behavioural change in the port: the threshold was a compile-time constant of 5, because each +/// deployment served exactly one course. It now comes from CourseGitHubConfig.WorkflowRunThreshold. +/// +public sealed class ActionWorkflowRunHandler : RepositoryEventHandlerBase +{ + private const string WarningText = ":exclamation: **You triggered too many automated evaluations; extra evaluations are penalized. Túl sok automata értékelést futtattál; az extra futtatások pontlevonással járnak.** "; + + public ActionWorkflowRunHandler(IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + } + + public override string GitHubEventName => "workflow_run"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, WorkflowRunEventPayload payload, CancellationToken cancellationToken) + { + if (payload.Action is null || !payload.Action.Equals("completed", StringComparison.OrdinalIgnoreCase)) + return EventHandlerResult.EventNotOfInterest(payload.Action ?? string.Empty); + + if (string.IsNullOrEmpty(payload.Sender?.Login)) + return EventHandlerResult.PayloadError("missing actor user"); + + if (await IsUserOrganizationMemberAsync(context, payload, payload.Sender.Login)) + return EventHandlerResult.NoActionNeeded("workflow_run ok, not triggered by student"); + + var workflowRuns = await CountWorkflowRunsAsync(context, payload.Repository.Owner.Login, payload.Repository.Name, payload.Sender.Login); + if (workflowRuns <= context.WorkflowRunThreshold) + return EventHandlerResult.NoActionNeeded("workflow_run ok, has less then threshold"); + + var prNum = await GetMostRecentPullRequestAsync(context, payload); + if (prNum.HasValue) + await context.GitHubClient.Issue.Comment.Create(payload.Repository.Id, prNum.Value, WarningText); + + return EventHandlerResult.ActionPerformed("workflow_run warning, threshold exceeded"); + } + + /// + /// Kept as a raw Connection call rather than Octokit's Actions.Workflows.Runs client. What is + /// wanted is GitHub's own total_count for the filtered query; Octokit's paginating client would + /// count differently, and a silent change here changes a student's grade. + /// + private static async Task CountWorkflowRunsAsync(GitHubWebhookContext context, string owner, string repo, string actor) + { + var response = await context.GitHubClient.Connection.Get( + uri: new Uri($"repos/{owner}/{repo}/actions/runs", UriKind.Relative), + parameters: new Dictionary + { + ["actor"] = actor, + ["status"] = "completed", + }, + accepts: AcceptHeaders.StableVersionJson); + + return response.Body.TotalCount; + } + + private static async Task GetMostRecentPullRequestAsync(GitHubWebhookContext context, WorkflowRunEventPayload payload) + { + var list = await context.GitHubClient.PullRequest.GetAllForRepository( + payload.Repository.Id, new PullRequestRequest { State = ItemStateFilter.All }); + + return list.OrderByDescending(p => p.UpdatedAt).FirstOrDefault()?.Number; + } + + internal sealed class ListWorkflowRunsResponse + { + public int TotalCount { get; set; } + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/BranchProtectionRuleHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/BranchProtectionRuleHandler.cs new file mode 100644 index 0000000..07e4229 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/BranchProtectionRuleHandler.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Applies the course's branch rules when a branch is created: the default branch requires a review (so the +/// student cannot merge their own pull request), every other branch is left alone beyond disabling force push. +/// Ported from github-monitor/.../EventHandlers/BranchProtectionRuleHandler.cs. +/// +public sealed class BranchProtectionRuleHandler : RepositoryEventHandlerBase +{ + public BranchProtectionRuleHandler(IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + } + + public override string GitHubEventName => "create"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, CreateEventPayload payload, CancellationToken cancellationToken) + { + // StringValue rather than the RefType enum: Octokit throws on ref types it does not know, and a new + // one appearing must not take the handler down. + if (!payload.RefType.StringValue.Equals("branch", StringComparison.OrdinalIgnoreCase)) + return EventHandlerResult.NoActionNeeded($"create event for ref {payload.RefType} is not of interest"); + + await context.GitHubClient.Repository.Branch.UpdateBranchProtection( + payload.Repository.Id, payload.Ref, GetBranchProtectionSettingsUpdate(payload.Ref, payload.Repository.DefaultBranch)); + + return EventHandlerResult.ActionPerformed("branch protection rule applied"); + } + + private static BranchProtectionSettingsUpdate GetBranchProtectionSettingsUpdate(string branchName, string repositoryDefaultBranch) + { + // For default: prohibits the merge request into default to be merged. + // For other branches: disables force push. + return new BranchProtectionSettingsUpdate( + requiredStatusChecks: null, + requiredPullRequestReviews: GetBranchProtectionRequiredReviewsUpdate(branchName, repositoryDefaultBranch), + restrictions: null, + enforceAdmins: false); + } + + private static BranchProtectionRequiredReviewsUpdate? GetBranchProtectionRequiredReviewsUpdate(string branchName, string repositoryDefaultBranch) + => branchName.Equals(repositoryDefaultBranch, StringComparison.OrdinalIgnoreCase) + ? new BranchProtectionRequiredReviewsUpdate(false, false, 1) // Prohibits the student from merging the pull request. + : null; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/CommentPayloadFacades.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/CommentPayloadFacades.cs new file mode 100644 index 0000000..022bb66 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/CommentPayloadFacades.cs @@ -0,0 +1,57 @@ +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; + +/// +/// The parts of a comment the grading command needs, regardless of whether it arrived as an issue comment or +/// as a pull request review. Lets one base class serve both events. +/// +public interface ICommentPayload + where T : ActivityPayload +{ + T Payload { get; } + + Repository Repository { get; } + + int PullRequestNumber { get; } + + string CommentingUser { get; } + + string CommentHtmlUrl { get; } + + string CommentBody { get; } +} + +internal sealed class IssueCommentPayloadFacade : ICommentPayload +{ + public IssueCommentPayloadFacade(IssueCommentPayload payload) => this.Payload = payload; + + public IssueCommentPayload Payload { get; } + + public Repository Repository => Payload.Repository; + + public int PullRequestNumber => Payload.Issue.Number; + + public string CommentingUser => Payload.Comment.User.Login; + + public string CommentHtmlUrl => Payload.Comment.HtmlUrl; + + public string CommentBody => Payload.Comment.Body; +} + +internal sealed class ReviewCommentPayloadFacade : ICommentPayload +{ + public ReviewCommentPayloadFacade(PullRequestReviewEventPayload payload) => this.Payload = payload; + + public PullRequestReviewEventPayload Payload { get; } + + public Repository Repository => Payload.Repository; + + public int PullRequestNumber => Payload.PullRequest.Number; + + public string CommentingUser => Payload.Review.User.Login; + + public string CommentHtmlUrl => Payload.Review.HtmlUrl; + + public string CommentBody => Payload.Review.Body; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandHandlerBase.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandHandlerBase.cs new file mode 100644 index 0000000..81e18bc --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandHandlerBase.cs @@ -0,0 +1,141 @@ +using Ahk.Web.Services.Grading; +using Ahk.Web.Services.Grading.Dto; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; + +/// +/// The /ahk ok chatops command: a teacher comment that approves a student's pull request, merges it, and +/// records the grade. Ported from github-monitor/.../EventHandlers/GradeComment/GradeCommandHandlerBase.cs. +/// +/// The one structural change: where the original enqueued a message onto Azure Queue Storage for +/// grade-management to pick up, this calls directly. A failure is therefore visible +/// in the delivery log instead of disappearing into a queue — and because the 👍 reaction is added only after +/// the grade write, its presence remains an honest signal that the whole command succeeded. +/// +public abstract class GradeCommandHandlerBase : RepositoryEventHandlerBase + where T : ActivityPayload +{ + private const string WarningText = ":exclamation: **@{} is not allowed to do that. @{} Ez nem engedelyezett szamodra.**"; + + private readonly IGradeService grades; + + protected GradeCommandHandlerBase(IGradeService grades, IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + this.grades = grades; + } + + protected abstract Task HandleReactionAsync(GitHubWebhookContext context, ICommentPayload payload, ReactionType reactionType); + + protected async Task ProcessCommentAsync(GitHubWebhookContext context, ICommentPayload payload, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + + var gradeCommand = new GradeCommentParser(payload.CommentBody); + if (!gradeCommand.IsMatch) + return EventHandlerResult.NoActionNeeded("not recognized as command"); + + // Only organization members may grade. A student posting "/ahk ok" in their own repository gets told so. + if (!await IsAllowedAsync(context, payload)) + return await HandleUserNotAllowedAsync(context, payload); + + var pr = await GetPullRequestAsync(context, payload); + if (pr is null) + return await HandleNotPrAsync(context, payload); + + await HandleApproveAsync(context, payload, pr); + await HandleStoreGradeAsync(context, payload, gradeCommand, pr, cancellationToken); + + await HandleReactionAsync(context, payload, ReactionType.Plus1); + return EventHandlerResult.ActionPerformed($"comment operation to grade done; grades: {string.Join(" ", gradeCommand.Grades)}"); + } + + private static async Task GetPullRequestAsync(GitHubWebhookContext context, ICommentPayload payload) + { + try + { + return await context.GitHubClient.PullRequest.Get(payload.Repository.Id, payload.PullRequestNumber); + } + catch (NotFoundException) + { + return null; + } + } + + private async Task HandleStoreGradeAsync(GitHubWebhookContext context, ICommentPayload payload, GradeCommentParser gradeCommand, PullRequest pr, CancellationToken cancellationToken) + { + var neptun = await GetNeptunAsync(context, payload.Repository.Id, pr.Head.Ref); + Logger.LogInformation("storing grades for {Neptun}", neptun); + + if (gradeCommand.HasGrades) + { + await grades.SetGradeAsync( + context.CourseId, + new SetGradeInput + { + Neptun = neptun ?? string.Empty, + Repository = payload.Repository.FullName, + PrNumber = pr.Number, + PrUrl = pr.HtmlUrl, + Actor = payload.CommentingUser, + Origin = payload.CommentHtmlUrl, + Results = gradeCommand.Grades, + }, + cancellationToken); + } + else + { + await grades.ConfirmAutoGradeAsync( + context.CourseId, + new ConfirmAutoGradeInput + { + Neptun = neptun ?? string.Empty, + Repository = payload.Repository.FullName, + PrNumber = pr.Number, + PrUrl = pr.HtmlUrl, + Actor = payload.CommentingUser, + Origin = payload.CommentHtmlUrl, + }, + cancellationToken); + } + } + + private async Task HandleApproveAsync(GitHubWebhookContext context, ICommentPayload payload, PullRequest pr) + { + if (pr.State.Value == ItemState.Open && pr.Mergeable == true) + { + Logger.LogInformation("PR is being merged"); + await context.GitHubClient.PullRequest.Review.Create( + payload.Repository.Id, payload.PullRequestNumber, new PullRequestReviewCreate { Event = PullRequestReviewEvent.Approve }); + await context.GitHubClient.PullRequest.Merge(payload.Repository.Id, payload.PullRequestNumber, new MergePullRequest()); + } + else + { + Logger.LogInformation("PR is not mergable"); + } + } + + private async Task HandleNotPrAsync(GitHubWebhookContext context, ICommentPayload payload) + { + await HandleReactionAsync(context, payload, ReactionType.Confused); + return EventHandlerResult.ActionPerformed("comment operation to grade not called for PR"); + } + + private async Task HandleUserNotAllowedAsync(GitHubWebhookContext context, ICommentPayload payload) + { + await HandleReactionAsync(context, payload, ReactionType.Confused); + + var comment = WarningText.Replace("{}", payload.CommentingUser, StringComparison.OrdinalIgnoreCase); + await context.GitHubClient.Issue.Comment.Create(payload.Repository.Id, payload.PullRequestNumber, comment); + + return EventHandlerResult.ActionPerformed("comment operation to grade not allowed for user"); + } + + private Task IsAllowedAsync(GitHubWebhookContext context, ICommentPayload payload) + => payload.Repository.Owner.Type != AccountType.Organization + ? Task.FromResult(false) + : IsOrganizationMemberAsync(context, payload.Repository.Owner.Login, payload.CommentingUser); +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandIssueCommentHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandIssueCommentHandler.cs new file mode 100644 index 0000000..df74e1c --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandIssueCommentHandler.cs @@ -0,0 +1,31 @@ +using Ahk.Web.Services.Grading; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; + +/// /ahk ok posted as an ordinary pull request comment. The common case. +public sealed class GradeCommandIssueCommentHandler : GradeCommandHandlerBase +{ + public GradeCommandIssueCommentHandler(IGradeService grades, IMemoryCache cache, ILogger logger) + : base(grades, cache, logger) + { + } + + public override string GitHubEventName => "issue_comment"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, IssueCommentPayload payload, CancellationToken cancellationToken) + { + if (payload.Issue is null) + return EventHandlerResult.PayloadError("no issue information in webhook payload"); + + if (payload.Action.Equals("created", StringComparison.OrdinalIgnoreCase)) + return await ProcessCommentAsync(context, new IssueCommentPayloadFacade(payload), cancellationToken); + + return EventHandlerResult.EventNotOfInterest(payload.Action); + } + + protected override Task HandleReactionAsync(GitHubWebhookContext context, ICommentPayload payload, ReactionType reactionType) + => context.GitHubClient.Reaction.IssueComment.Create(payload.Repository.Id, payload.Payload.Comment.Id, new NewReaction(reactionType)); +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandReviewCommentHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandReviewCommentHandler.cs new file mode 100644 index 0000000..9248516 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/GradeComment/GradeCommandReviewCommentHandler.cs @@ -0,0 +1,37 @@ +using Ahk.Web.Services.Grading; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; + +/// /ahk ok written in the body of a submitted pull request review rather than as a comment. +public sealed class GradeCommandReviewCommentHandler : GradeCommandHandlerBase +{ + public GradeCommandReviewCommentHandler(IGradeService grades, IMemoryCache cache, ILogger logger) + : base(grades, cache, logger) + { + } + + public override string GitHubEventName => "pull_request_review"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, PullRequestReviewEventPayload payload, CancellationToken cancellationToken) + { + if (payload.Review is null) + return EventHandlerResult.PayloadError("no review information in webhook payload"); + + if (payload.Action.Equals("submitted", StringComparison.OrdinalIgnoreCase)) + return await ProcessCommentAsync(context, new ReviewCommentPayloadFacade(payload), cancellationToken); + + return EventHandlerResult.EventNotOfInterest(payload.Action); + } + + /// + /// Deliberately does nothing. Reacting to a *review* returns an error for a GitHub App: the documentation + /// says pull-request read/write should be enough, and it is not. Carried over from + /// github-monitor, where the same attempt was made and reverted — so a teacher grading through a + /// review gets no 👍, only the grade. + /// + protected override Task HandleReactionAsync(GitHubWebhookContext context, ICommentPayload payload, ReactionType reactionType) + => Task.CompletedTask; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/IssueCommentEditDeleteHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/IssueCommentEditDeleteHandler.cs new file mode 100644 index 0000000..30d6e55 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/IssueCommentEditDeleteHandler.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Leaves a visible trace when somebody edits or deletes another person's comment — the audit trail of a +/// graded pull request is part of the evaluation. Editing your own comment is fine. Ported from +/// github-monitor/.../EventHandlers/IssueCommentEditDeleteHandler.cs. +/// +public sealed class IssueCommentEditDeleteHandler : RepositoryEventHandlerBase +{ + private const string WarningText = ":exclamation: **An issue comment was deleted / edited. Egy megjegyzes torolve vagy modositva lett.**"; + + public IssueCommentEditDeleteHandler(IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + } + + public override string GitHubEventName => "issue_comment"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, IssueCommentPayload payload, CancellationToken cancellationToken) + { + if (payload.Issue is null) + return EventHandlerResult.PayloadError("no issue information in webhook payload"); + + if (payload.Action.Equals("edited", StringComparison.OrdinalIgnoreCase) || payload.Action.Equals("deleted", StringComparison.OrdinalIgnoreCase)) + { + if (payload.Sender is not null && payload.Comment?.User is not null && payload.Sender.Login == payload.Comment.User.Login) + return EventHandlerResult.NoActionNeeded($"comment action {payload.Action} by {payload.Sender.Login} allowed, referencing own comment"); + + await context.GitHubClient.Issue.Comment.Create(payload.Repository.Id, payload.Issue.Number, WarningText); + return EventHandlerResult.ActionPerformed("comment action resulting in warning"); + } + + return EventHandlerResult.EventNotOfInterest(payload.Action); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestOpenDuplicateHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestOpenDuplicateHandler.cs new file mode 100644 index 0000000..6ba0b06 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestOpenDuplicateHandler.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Warns when a student opens more than one pull request for the same work — several open at once, or a new +/// one after a teacher already closed an earlier one. Ported from +/// github-monitor/.../EventHandlers/PullRequestOpenDuplicateHandler.cs. +/// +/// ⚠️ The slowest handler by some way: it lists every pull request in the repository and, for each closed one, +/// its issue events. On a repository with a long history this alone can approach GitHub's delivery timeout. +/// +public sealed class PullRequestOpenDuplicateHandler : RepositoryEventHandlerBase +{ + private const string WarningText = ":exclamation: **You have multiple pull requests. Tobb pull request-et nyitottal.** {} \n\n"; + + public PullRequestOpenDuplicateHandler(IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + } + + public override string GitHubEventName => "pull_request"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, PullRequestEventPayload payload, CancellationToken cancellationToken) + { + if (payload.PullRequest is null) + return EventHandlerResult.PayloadError("no pull request information in webhook payload"); + + if (!payload.Action.Equals("opened", StringComparison.OrdinalIgnoreCase)) + return EventHandlerResult.EventNotOfInterest(payload.Action); + + var repositoryPrs = await context.GitHubClient.PullRequest.GetAllForRepository( + payload.Repository.Id, new PullRequestRequest { State = ItemStateFilter.All }); + + if (repositoryPrs.Count <= 1) + return EventHandlerResult.NoActionNeeded("pull request open is ok, there are no other PRs"); + + var (handledOpen, resultOpen) = await HandleAnyOpenPrsAsync(context, payload, repositoryPrs); + var (handledClosed, resultClosed) = await HandleAnyClosedPrsAsync(context, payload, repositoryPrs); + + return !handledOpen && !handledClosed + ? EventHandlerResult.NoActionNeeded($"{resultOpen}; {resultClosed}") + : EventHandlerResult.ActionPerformed($"{resultOpen}; {resultClosed}"); + } + + private static string GetWarningText(int currentPrNumber, IEnumerable foundPrNumbers) + { + var prReferencesText = string.Join( + " ", + foundPrNumbers.Union(new[] { currentPrNumber }).Distinct().OrderBy(num => num).Select(n => $"#{n}").ToArray()); + + return WarningText.Replace("{}", prReferencesText, StringComparison.OrdinalIgnoreCase); + } + + private static async Task<(bool HasProblem, string ResultText)> HandleAnyOpenPrsAsync( + GitHubWebhookContext context, PullRequestEventPayload payload, IReadOnlyCollection repositoryPrs) + { + var openPrs = repositoryPrs.Where(otherPr => otherPr.State == ItemState.Open).ToList(); + if (openPrs.Count <= 1) + return (false, "pull request open is ok, there are no other open PRs"); + + var warningText = GetWarningText(payload.PullRequest.Number, openPrs.Select(pr => pr.Number)); + foreach (var openPullRequest in openPrs) + await context.GitHubClient.Issue.Comment.Create(payload.Repository.Id, openPullRequest.Number, warningText); + + return (true, "pull request open handled with multiple open PRs"); + } + + private static async Task<(bool HasProblem, string ResultText)> HandleAnyClosedPrsAsync( + GitHubWebhookContext context, PullRequestEventPayload payload, IReadOnlyCollection repositoryPrs) + { + var closedPrs = repositoryPrs.Where(otherPr => otherPr.State == ItemState.Closed).ToList(); + if (closedPrs.Count == 0) + return (false, "pull request open is ok, there are no other closed PRs"); + + var prsClosedByNotStudent = new List(); + foreach (var otherClosedPr in closedPrs) + { + if (await IsPrClosedByNotStudentAsync(context, payload, otherClosedPr)) + prsClosedByNotStudent.Add(otherClosedPr.Number); + } + + if (prsClosedByNotStudent.Count == 0) + return (false, "pull request open is ok, there are no other evaluated PRs"); + + var warningText = GetWarningText(payload.PullRequest.Number, prsClosedByNotStudent); + await context.GitHubClient.Issue.Comment.Create(payload.Repository.Id, payload.Number, warningText); + + return (true, "pull request open handled with already closed PRs"); + } + + private static async Task IsPrClosedByNotStudentAsync(GitHubWebhookContext context, PullRequestEventPayload payload, PullRequest pr) + { + var issueEvents = await context.GitHubClient.Issue.Events.GetAllForIssue(payload.Repository.Id, pr.Number); + + // A PR the student opened and somebody else closed is one a teacher already evaluated. + return issueEvents.Any(e => e.Event.Value == EventInfoState.Closed && e.Actor?.Id != pr.User.Id); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestReviewToAssigneeHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestReviewToAssigneeHandler.cs new file mode 100644 index 0000000..84297bb --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/PullRequestReviewToAssigneeHandler.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Mirrors a requested reviewer onto the pull request's assignee, so the teacher dashboard's "who is reviewing +/// this" column is populated by the act of requesting a review. Ported from +/// github-monitor/.../EventHandlers/PullRequestReviewToAssigneeHandler.cs. +/// +public sealed class PullRequestReviewToAssigneeHandler : RepositoryEventHandlerBase +{ + public PullRequestReviewToAssigneeHandler(IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + } + + public override string GitHubEventName => "pull_request"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, PullRequestEventPayload payload, CancellationToken cancellationToken) + { + if (payload.PullRequest is null) + return EventHandlerResult.PayloadError("no pull request information in webhook payload"); + + if (!payload.Action.Equals("review_requested", StringComparison.OrdinalIgnoreCase)) + return EventHandlerResult.EventNotOfInterest(payload.Action); + + if (payload.PullRequest.RequestedReviewers is null || payload.PullRequest.RequestedReviewers.Count == 0) + return EventHandlerResult.PayloadError("no requested reviewer in webhook payload"); + + if (IsPrAssignedToReviewer(payload)) + return EventHandlerResult.NoActionNeeded("pull request review_requested is ok, assignee is present"); + + await context.GitHubClient.Issue.Assignee.AddAssignees( + payload.Repository.Owner.Login, payload.Repository.Name, payload.PullRequest.Number, GetUsersToAssign(payload)); + + return EventHandlerResult.ActionPerformed("pull request review_requested handled, assignee set"); + } + + private static AssigneesUpdate GetUsersToAssign(PullRequestEventPayload payload) + => new(payload.PullRequest.RequestedReviewers.Select(r => r.Login).ToList()); + + private static bool IsPrAssignedToReviewer(PullRequestEventPayload payload) + => payload.PullRequest.Assignee is not null + && payload.PullRequest.RequestedReviewers.Any(r => r.Id == payload.PullRequest.Assignee.Id); +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/RepositoryEventHandlerBase.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/RepositoryEventHandlerBase.cs new file mode 100644 index 0000000..7dd659d --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/RepositoryEventHandlerBase.cs @@ -0,0 +1,204 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; +using Octokit.Internal; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers; + +/// +/// Base for every handler that acts on a repository. Port of +/// github-monitor/.../EventHandlers/RepositoryEventBase.cs, with the GitHub client and the delivery's +/// identity moved onto so the handler itself holds no state. +/// +/// Two behaviours are inherited by everything that derives from this and must not be bypassed: payload +/// deserialization with its four distinct error messages, and the .github/ahk-monitor.yml opt-in gate. +/// +public abstract class RepositoryEventHandlerBase : IGitHubWebhookHandler + where TPayload : ActivityPayload +{ + /// + /// How long a repository's opt-in answer is remembered. Long, because it is asked on every delivery and the + /// answer almost never changes — with the consequence that enabling a repository can take up to + /// half a day to be noticed. Restarting the application is the only faster flush. + /// + private static readonly TimeSpan EnabledCacheDuration = TimeSpan.FromHours(12); + + private static readonly TimeSpan NeptunCacheDuration = TimeSpan.FromHours(12); + + private static readonly TimeSpan OrganizationMemberCacheDuration = TimeSpan.FromHours(1); + + protected RepositoryEventHandlerBase(IMemoryCache cache, ILogger logger) + { + this.Cache = cache; + this.Logger = logger; + } + + public abstract string GitHubEventName { get; } + + protected ILogger Logger { get; } + + protected IMemoryCache Cache { get; } + + public async Task ExecuteAsync(GitHubWebhookContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (!TryParsePayload(context.RequestBody, out var payload, out var errorResult)) + return errorResult; + + if (!await IsEnabledForRepositoryAsync(context, payload)) + { + Logger.LogInformation("no ahk-monitor.yml or disabled"); + return EventHandlerResult.Disabled("no ahk-monitor.yml or disabled"); + } + + return await ExecuteCoreAsync(context, payload, cancellationToken); + } + + protected abstract Task ExecuteCoreAsync(GitHubWebhookContext context, TPayload payload, CancellationToken cancellationToken); + + protected bool TryParsePayload(string requestBody, out TPayload payload, out EventHandlerResult errorResult) + { + payload = null!; + + if (string.IsNullOrEmpty(requestBody)) + { + errorResult = EventHandlerResult.PayloadError("request body was empty"); + Logger.LogError("request body was empty"); + return false; + } + + try + { + payload = new SimpleJsonSerializer().Deserialize(requestBody); + } +#pragma warning disable CA1031 // Any deserialization failure is reported to the delivery log, never thrown. + catch (Exception ex) +#pragma warning restore CA1031 + { + errorResult = EventHandlerResult.PayloadError($"request body deserialization failed: {ex.Message}"); + Logger.LogError(ex, "request body deserialization failed"); + return false; + } + + if (payload is null) + { + errorResult = EventHandlerResult.PayloadError("parsed payload was null or empty"); + Logger.LogError("parsed payload was null or empty"); + return false; + } + + if (payload.Repository is null) + { + errorResult = EventHandlerResult.PayloadError("no repository information in webhook payload"); + Logger.LogError("no repository information in webhook payload"); + return false; + } + + errorResult = null!; + return true; + } + + /// + /// The student's Neptun code, read from neptun.txt on the given branch. Null when the file is + /// missing — a repository whose owner never filled it in still gets its events recorded, just without a + /// student attached. + /// + protected Task GetNeptunAsync(GitHubWebhookContext context, long repositoryId, string branchName) + => Cache.GetOrCreateAsync( + key: $"neptuntxtfile{repositoryId}{branchName}", + factory: async cacheEntry => + { + var value = await GetNeptunTxtFileContentAsync(context, repositoryId, branchName); + cacheEntry.SetValue(value); + cacheEntry.SetAbsoluteExpiration(NeptunCacheDuration); + return value; + }); + + /// + /// Whether a login belongs to the repository's organization — the portal's definition of "is staff, not a + /// student". Non-organization repositories always answer false. + /// + protected Task IsUserOrganizationMemberAsync(GitHubWebhookContext context, TPayload payload, string username) + { + ArgumentNullException.ThrowIfNull(payload); + + if (payload.Repository.Owner.Type != AccountType.Organization) + return Task.FromResult(false); + + return IsOrganizationMemberAsync(context, payload.Repository.Owner.Login, username); + } + + protected Task IsOrganizationMemberAsync(GitHubWebhookContext context, string organization, string username) + => Cache.GetOrCreateAsync( + key: $"githubisorgmember{organization}{username}", + factory: async cacheEntry => + { + var isMember = await CheckOrganizationMemberAsync(context, organization, username); + cacheEntry.SetValue(isMember); + cacheEntry.SetAbsoluteExpiration(OrganizationMemberCacheDuration); + return isMember; + }); + + /// + /// The opt-in gate. A repository is acted on only when its default branch carries + /// .github/ahk-monitor.yml with enabled: true — otherwise every event from it is ignored. + /// Cached per repository id, which is globally unique, so no course qualifier is needed in the key. + /// + private Task IsEnabledForRepositoryAsync(GitHubWebhookContext context, TPayload payload) + => Cache.GetOrCreateAsync( + key: $"ahkmonitorisenabledinrepo{payload.Repository.Id}", + factory: async cacheEntry => + { + var isEnabled = await GetConfigIsEnabledInRepositoryAsync(context, payload); + cacheEntry.SetValue(isEnabled); + cacheEntry.SetAbsoluteExpiration(EnabledCacheDuration); + return isEnabled; + }); + + private static async Task GetConfigIsEnabledInRepositoryAsync(GitHubWebhookContext context, TPayload payload) + { + try + { + var contents = await context.GitHubClient.Repository.Content.GetAllContentsByRef( + payload.Repository.Id, ".github/ahk-monitor.yml", payload.Repository.DefaultBranch); + + if (contents.Count == 0) + return false; + + return ConfigYamlParser.IsEnabled(contents[0].Content); + } + catch (NotFoundException) + { + return false; + } + } + + private static async Task GetNeptunTxtFileContentAsync(GitHubWebhookContext context, long repositoryId, string branchName) + { + try + { + var contents = await context.GitHubClient.Repository.Content.GetAllContentsByRef(repositoryId, "neptun.txt", branchName); + if (contents.Count == 0) + return null; + + return contents[0].Content?.Trim(); + } + catch (NotFoundException) + { + return null; + } + } + + private static async Task CheckOrganizationMemberAsync(GitHubWebhookContext context, string organization, string username) + { + try + { + return await context.GitHubClient.Organization.Member.CheckMember(organization, username); + } + catch (NotFoundException) + { + return false; + } + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/BranchCreateStatusTrackingHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/BranchCreateStatusTrackingHandler.cs new file mode 100644 index 0000000..123f3c7 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/BranchCreateStatusTrackingHandler.cs @@ -0,0 +1,54 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.StatusTracking; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; + +/// +/// Records branch creation in the submission event log. Ported from +/// github-monitor/.../EventHandlers/StatusTracking/BranchCreateStatusTrackingHandler.cs. +/// +/// The two outcomes are mutually exclusive, which is what keeps the one-status-event-per-delivery +/// invariant intact for the create event. +/// +public sealed class BranchCreateStatusTrackingHandler : RepositoryEventHandlerBase, IStatusEventWriter +{ + private readonly ISubmissionEventService events; + + public BranchCreateStatusTrackingHandler(ISubmissionEventService events, IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + this.events = events; + } + + public override string GitHubEventName => "create"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, CreateEventPayload payload, CancellationToken cancellationToken) + { + if (!payload.RefType.Equals(RefType.Branch)) + return EventHandlerResult.EventNotOfInterest($"branch create ignored for RefType: {payload.RefType}, Ref: {payload.Ref}"); + + // Repository creation is recognised here rather than from the dedicated `repository` event: at that + // point the repository is still empty and carries no ahk-monitor.yml, so the opt-in gate would reject + // it. Creation of the default branch is the first moment the repository is recognisable as ours. + var isRepositoryCreate = payload.Ref.Equals(payload.Repository.DefaultBranch, StringComparison.OrdinalIgnoreCase); + + SubmissionEvent submissionEvent = isRepositoryCreate + ? new RepositoryCreatedEvent() + : new BranchCreatedEvent { Branch = payload.Ref }; + + submissionEvent.GitHubDeliveryId = NullIfEmpty(context.DeliveryId); + submissionEvent.Timestamp = DateTimeOffset.UtcNow; + + var recorded = await events.RecordAsync(context.CourseId, payload.Repository.FullName, submissionEvent, cancellationToken: cancellationToken); + if (!recorded) + return EventHandlerResult.NoActionNeeded("redelivery, event already recorded"); + + return EventHandlerResult.ActionPerformed( + isRepositoryCreate ? "repository create lifecycle handled" : "branch create lifecycle handled"); + } + + internal static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/PullRequestStatusTrackingHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/PullRequestStatusTrackingHandler.cs new file mode 100644 index 0000000..653028a --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/PullRequestStatusTrackingHandler.cs @@ -0,0 +1,60 @@ +using Ahk.Web.Services.StatusTracking; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Octokit; +using PullRequestStatusEvent = Ahk.Web.Data.Entities.PullRequestEvent; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; + +/// +/// Records pull request lifecycle events. The status projection reads the latest action per pull request +/// number, so the four actions tracked here are exactly the ones that change what a teacher sees. Ported from +/// github-monitor/.../EventHandlers/StatusTracking/PullRequestStatusTrackingHandler.cs. +/// +public sealed class PullRequestStatusTrackingHandler : RepositoryEventHandlerBase, IStatusEventWriter +{ + private readonly ISubmissionEventService events; + + public PullRequestStatusTrackingHandler(ISubmissionEventService events, IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + this.events = events; + } + + public override string GitHubEventName => "pull_request"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, PullRequestEventPayload payload, CancellationToken cancellationToken) + { + if (payload.PullRequest is null) + return EventHandlerResult.PayloadError("no pull request information in webhook payload"); + + if (!payload.Action.Equals("opened", StringComparison.OrdinalIgnoreCase) + && !payload.Action.Equals("assigned", StringComparison.OrdinalIgnoreCase) + && !payload.Action.Equals("review_requested", StringComparison.OrdinalIgnoreCase) + && !payload.Action.Equals("closed", StringComparison.OrdinalIgnoreCase)) + { + return EventHandlerResult.EventNotOfInterest(payload.Action); + } + + var neptun = await GetNeptunAsync(context, payload.Repository.Id, payload.PullRequest.Head.Ref); + + var submissionEvent = new PullRequestStatusEvent + { + Number = payload.PullRequest.Number, + Action = payload.Action, + HtmlUrl = payload.PullRequest.HtmlUrl, + Neptun = neptun, + Assignees = payload.PullRequest.Assignees?.Select(u => u.Login).ToList() ?? new List(), + GitHubDeliveryId = BranchCreateStatusTrackingHandler.NullIfEmpty(context.DeliveryId), + Timestamp = DateTimeOffset.UtcNow, + }; + + // The neptun is passed on as well, not just stored on the event: it is what links the submission to a + // student row, and a pull request is usually the first place it becomes known. + var recorded = await events.RecordAsync(context.CourseId, payload.Repository.FullName, submissionEvent, neptun, cancellationToken); + if (!recorded) + return EventHandlerResult.NoActionNeeded("redelivery, event already recorded"); + + return EventHandlerResult.ActionPerformed("pull request lifecycle handled"); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/WorkflowRunStatusTrackingHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/WorkflowRunStatusTrackingHandler.cs new file mode 100644 index 0000000..c01331e --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Handlers/StatusTracking/WorkflowRunStatusTrackingHandler.cs @@ -0,0 +1,47 @@ +using Ahk.Web.Services.GitHubWebhooks.Payloads; +using Ahk.Web.Services.StatusTracking; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using WorkflowRunStatusEvent = Ahk.Web.Data.Entities.WorkflowRunEvent; + +namespace Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; + +/// +/// Records each completed evaluation run, which is what the dashboard's run count and last-conclusion columns +/// project over. Ported from +/// github-monitor/.../EventHandlers/StatusTracking/WorkflowRunStatusTrackingHandler.cs. +/// +public sealed class WorkflowRunStatusTrackingHandler : RepositoryEventHandlerBase, IStatusEventWriter +{ + private readonly ISubmissionEventService events; + + public WorkflowRunStatusTrackingHandler(ISubmissionEventService events, IMemoryCache cache, ILogger logger) + : base(cache, logger) + { + this.events = events; + } + + public override string GitHubEventName => "workflow_run"; + + protected override async Task ExecuteCoreAsync(GitHubWebhookContext context, WorkflowEventPayload payload, CancellationToken cancellationToken) + { + if (payload.WorkflowRun is null) + return EventHandlerResult.PayloadError("no workflow run information in webhook payload"); + + if (payload.Action is null || !payload.Action.Equals("completed", StringComparison.OrdinalIgnoreCase)) + return EventHandlerResult.EventNotOfInterest(payload.Action ?? string.Empty); + + var submissionEvent = new WorkflowRunStatusEvent + { + Conclusion = payload.WorkflowRun.Conclusion, + GitHubDeliveryId = BranchCreateStatusTrackingHandler.NullIfEmpty(context.DeliveryId), + Timestamp = DateTimeOffset.UtcNow, + }; + + var recorded = await events.RecordAsync(context.CourseId, payload.Repository.FullName, submissionEvent, cancellationToken: cancellationToken); + if (!recorded) + return EventHandlerResult.NoActionNeeded("redelivery, event already recorded"); + + return EventHandlerResult.ActionPerformed("workflow_run lifecycle handled"); + } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/IGitHubWebhookHandler.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/IGitHubWebhookHandler.cs new file mode 100644 index 0000000..d78155d --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/IGitHubWebhookHandler.cs @@ -0,0 +1,28 @@ +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// One rule enforced, or one fact recorded, in response to a GitHub webhook event. Handlers are registered in +/// DI and selected by ; several may subscribe to the same event and each runs +/// independently, so one throwing does not stop the others. +/// +public interface IGitHubWebhookHandler +{ + /// The X-GitHub-Event name this handler subscribes to, e.g. pull_request. + string GitHubEventName { get; } + + Task ExecuteAsync(GitHubWebhookContext context, CancellationToken cancellationToken = default); +} + +/// +/// Marks a handler that appends to the submission event log. +/// +/// ⚠️ SubmissionEvent.GitHubDeliveryId is globally unique, but one delivery fans out to several +/// handlers — so at most one handler per event name may write a status event, or the second +/// write is silently dropped as a redelivery (and would violate the unique index on SQL Server). The invariant +/// is asserted by WebhookHandlerRegistrationTests; if you need a second writer for an event, key the +/// delivery id per handler first. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "A marker is the point: it is what the registration test can see without running a handler.")] +public interface IStatusEventWriter +{ +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Payloads/WorkflowPayloads.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Payloads/WorkflowPayloads.cs new file mode 100644 index 0000000..bfbd316 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/Payloads/WorkflowPayloads.cs @@ -0,0 +1,31 @@ +using Octokit; + +namespace Ahk.Web.Services.GitHubWebhooks.Payloads; + +/// +/// The workflow_run payload, as much of it as the run-count rule needs. +/// +/// Octokit has no type for this event. github-monitor declared its equivalents inside +/// namespace Octokit so they looked native; that is a landmine for the next Octokit upgrade (the day +/// Octokit ships its own WorkflowRunEventPayload, the project stops compiling for reasons nobody will +/// connect to this file), so they live in our own namespace here. Octokit's +/// binds by convention, not by namespace, so nothing else changes. +/// +public class WorkflowRunEventPayload : ActivityPayload +{ + public string? Action { get; set; } +} + +/// The workflow_run payload including the run's conclusion, for the status event log. +public class WorkflowEventPayload : ActivityPayload +{ + public string? Action { get; set; } + + public WorkflowRun? WorkflowRun { get; set; } +} + +/// An Actions workflow run, as much of it as the status projection needs. +public class WorkflowRun +{ + public string? Conclusion { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Services/GitHubWebhooks/WebhookResult.cs b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/WebhookResult.cs new file mode 100644 index 0000000..a2cf516 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/GitHubWebhooks/WebhookResult.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Ahk.Web.Services.GitHubWebhooks; + +/// +/// What each handler made of one delivery, returned as the webhook's response body. This is what an +/// administrator reads in the GitHub App's Advanced → Recent Deliveries tab, which is the only +/// diagnostic surface GitHub offers — so the message strings are a user interface and are kept verbatim from +/// github-monitor. +/// +public sealed class WebhookResult +{ + [SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Result object is JSON serialized.")] + public List Messages { get; } = new(); + + public void LogInfo(string message) => Messages.Add(message); + + public void LogError(Exception ex, string message) => Messages.Add(message + ": " + ex?.ToString()); +} diff --git a/ahk-backend/Ahk.Web.Services/Grading/CsvExporter.cs b/ahk-backend/Ahk.Web.Services/Grading/CsvExporter.cs new file mode 100644 index 0000000..ac8ace6 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Grading/CsvExporter.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using System.Text; +using Ahk.Web.Services.Grading.Dto; + +namespace Ahk.Web.Services.Grading; + +/// +/// Semicolon-separated grade export. Ported verbatim from +/// grade-management/.../ListGrades/CsvExporter.cs — the column layout (Neptun;GitHubRepo;GitHubPr then +/// one column per distinct exercise name, sorted) and the "0.##" invariant number format are relied on by +/// downstream administration, so they must not drift. +/// +public static class CsvExporter +{ + public static string GetCsv(IReadOnlyCollection results) + { + var exNames = results.SelectMany(r => r.Points.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + var str = new StringBuilder(); + + var values = new List { "Neptun", "GitHubRepo", "GitHubPr" }; + values.AddRange(exNames); + str.AppendLine(FormatLine(values)); + + foreach (var r in results) + { + values.Clear(); + values.Add(r.Neptun.ToUpperInvariant()); + values.Add(r.Repo); + values.Add(r.PrUrl ?? string.Empty); + + foreach (var exName in exNames) + values.Add(r.Points.TryGetValue(exName, out var p) ? p.ToString("0.##", CultureInfo.InvariantCulture) : string.Empty); + + str.AppendLine(FormatLine(values)); + } + + return str.ToString(); + } + + private static string FormatLine(IReadOnlyCollection values) + { + if (values is null || values.Count == 0) + return string.Empty; + + var valuesString = values.Select(s => s is null ? string.Empty : s.Replace("\"", string.Empty, StringComparison.OrdinalIgnoreCase)); + return string.Join(";", valuesString); + } +} diff --git a/ahk-backend/Ahk.Web.Services/Grading/Dto/FinalStudentGrade.cs b/ahk-backend/Ahk.Web.Services/Grading/Dto/FinalStudentGrade.cs new file mode 100644 index 0000000..70f4f18 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Grading/Dto/FinalStudentGrade.cs @@ -0,0 +1,17 @@ +namespace Ahk.Web.Services.Grading.Dto; + +/// +/// One student's final (latest confirmed) grade for a submission. Shape preserved from +/// grade-management/.../ListGrades/Dto/FinalStudentGrade.cs, including the exercise-name → points map +/// that drives the CSV column layout. +/// +public sealed class FinalStudentGrade +{ + public string Neptun { get; set; } = string.Empty; + + public string Repo { get; set; } = string.Empty; + + public string? PrUrl { get; set; } + + public IReadOnlyDictionary Points { get; set; } = new Dictionary(); +} diff --git a/ahk-backend/Ahk.Web.Services/Grading/Dto/GradeInputs.cs b/ahk-backend/Ahk.Web.Services/Grading/Dto/GradeInputs.cs new file mode 100644 index 0000000..1ac1531 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Grading/Dto/GradeInputs.cs @@ -0,0 +1,66 @@ +namespace Ahk.Web.Services.Grading.Dto; + +/// Teacher grade/override via the "/ahk ok 5 3.5 0" chatops command (was SetGradeEvent). +public sealed class SetGradeInput +{ + public string Neptun { get; set; } = string.Empty; + + public string Repository { get; set; } = string.Empty; + + public int PrNumber { get; set; } + + public string? PrUrl { get; set; } + + public string? Actor { get; set; } + + public string? Origin { get; set; } + + /// Positional point values; index maps to exercise order. + public IReadOnlyList Results { get; set; } = Array.Empty(); +} + +/// Teacher approval that keeps the automated points as-is (was ConfirmAutoGradeEvent). +public sealed class ConfirmAutoGradeInput +{ + public string Neptun { get; set; } = string.Empty; + + public string Repository { get; set; } = string.Empty; + + public int PrNumber { get; set; } + + public string? PrUrl { get; set; } + + public string? Actor { get; set; } + + public string? Origin { get; set; } +} + +/// Automated evaluation result posted by publish-results-pr (was AhkProcessResult). +public sealed class EvaluationResultInput +{ + public string NeptunCode { get; set; } = string.Empty; + + public string GitHubRepoName { get; set; } = string.Empty; + + public string? GitHubBranch { get; set; } + + public string? GitHubCommitHash { get; set; } + + public int? GitHubPullRequestNum { get; set; } + + public string? Origin { get; set; } + + public IReadOnlyList Result { get; set; } = Array.Empty(); +} + +/// One task line from result.txt (was AhkTaskResult). Points are summed per exercise before storage. +public sealed class EvaluationTaskResult +{ + public string? ExerciseName { get; set; } + + public string TaskName { get; set; } = string.Empty; + + public double Points { get; set; } + + public string? Comment { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Services/Grading/GradeListingService.cs b/ahk-backend/Ahk.Web.Services/Grading/GradeListingService.cs new file mode 100644 index 0000000..efaf80b --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Grading/GradeListingService.cs @@ -0,0 +1,56 @@ +using Ahk.Web.Data; +using Ahk.Web.Services.Grading.Dto; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Grading; + +public interface IGradeListingService +{ + Task> ListAsync(int courseId, CancellationToken cancellationToken = default); + + Task ExportCsvAsync(int courseId, CancellationToken cancellationToken = default); +} + +/// +/// Final-grade listing and export. Port of grade-management/.../ListGrades/GradeListing.cs: take the +/// confirmed results, group by (neptun, submission), and keep the most recent one — the append-only history +/// means "current grade" is always the latest row. +/// +public sealed class GradeListingService : IGradeListingService +{ + private readonly ApplicationDbContext db; + + public GradeListingService(ApplicationDbContext db) => this.db = db; + + public async Task> ListAsync(int courseId, CancellationToken cancellationToken = default) + { + var confirmed = await db.GradeRecords + .IgnoreQueryFilters() + .AsNoTracking() + .Where(g => g.CourseId == courseId && g.Confirmed) + .Include(g => g.Points) + .Include(g => g.Submission) + .ToListAsync(cancellationToken); + + return confirmed + .GroupBy(g => new { g.Neptun, Repo = g.Submission!.GitHubRepoName }) + .Select(group => + { + var latest = group.OrderByDescending(g => g.Date).First(); + return new FinalStudentGrade + { + Neptun = group.Key.Neptun, + Repo = group.Key.Repo, + PrUrl = latest.PrUrl, + Points = latest.Points + .OrderBy(p => p.Order) + .GroupBy(p => p.Name) + .ToDictionary(p => p.Key, p => p.Last().Point), + }; + }) + .ToList(); + } + + public async Task ExportCsvAsync(int courseId, CancellationToken cancellationToken = default) + => CsvExporter.GetCsv(await ListAsync(courseId, cancellationToken)); +} diff --git a/ahk-backend/Ahk.Web.Services/Grading/GradeService.cs b/ahk-backend/Ahk.Web.Services/Grading/GradeService.cs new file mode 100644 index 0000000..d2ce7ed --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Grading/GradeService.cs @@ -0,0 +1,152 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Grading.Dto; +using Ahk.Web.Services.Submissions; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Grading; + +public interface IGradeService +{ + Task SetGradeAsync(int courseId, SetGradeInput input, CancellationToken cancellationToken = default); + + Task ConfirmAutoGradeAsync(int courseId, ConfirmAutoGradeInput input, CancellationToken cancellationToken = default); + + Task RecordEvaluationResultAsync(int courseId, EvaluationResultInput input, DateTimeOffset timestamp, CancellationToken cancellationToken = default); +} + +/// +/// Grade writes. Port of SetGradeService + ResultProcessor. Every operation **inserts** a new +/// — the history is append-only and the newest row is the current grade. +/// +/// Reachable from three entry points with three different course-resolution mechanisms (teacher endpoint via +/// route, chatops via webhook payload, CI callback via token), which is why is +/// always explicit rather than read from the ambient course context. +/// +public sealed class GradeService : IGradeService +{ + /// Actor recorded for results arriving from the automated evaluation callback. + public const string AutomatedActor = "grade-management-api"; + + private readonly ApplicationDbContext db; + private readonly ISubmissionResolver submissions; + + public GradeService(ApplicationDbContext db, ISubmissionResolver submissions) + { + this.db = db; + this.submissions = submissions; + } + + public async Task SetGradeAsync(int courseId, SetGradeInput input, CancellationToken cancellationToken = default) + { + var submission = await submissions.GetOrCreateAsync(courseId, input.Repository, input.Neptun, cancellationToken); + var previous = await GetLastResultAsync(courseId, submission.Id, input.PrNumber, cancellationToken); + + var points = BuildPoints(input.Results, previous?.Points); + return await AddResultAsync(courseId, submission, Normalize.Neptun(input.Neptun), input.PrNumber, input.PrUrl, + DateTimeOffset.UtcNow, input.Actor, input.Origin, confirmed: true, points, cancellationToken); + } + + public async Task ConfirmAutoGradeAsync(int courseId, ConfirmAutoGradeInput input, CancellationToken cancellationToken = default) + { + var submission = await submissions.GetOrCreateAsync(courseId, input.Repository, input.Neptun, cancellationToken); + var previous = await GetLastResultAsync(courseId, submission.Id, input.PrNumber, cancellationToken); + + // Confirmation keeps whatever the automated evaluation produced. + var points = previous?.Points + .OrderBy(p => p.Order) + .Select(p => (p.Name, p.Point)) + .ToList() ?? new List<(string, double)>(); + + return await AddResultAsync(courseId, submission, Normalize.Neptun(input.Neptun), input.PrNumber, input.PrUrl, + DateTimeOffset.UtcNow, input.Actor, input.Origin, confirmed: true, points, cancellationToken); + } + + public async Task RecordEvaluationResultAsync(int courseId, EvaluationResultInput input, DateTimeOffset timestamp, CancellationToken cancellationToken = default) + { + var submission = await submissions.GetOrCreateAsync(courseId, input.GitHubRepoName, input.NeptunCode, cancellationToken); + + var prUrl = input.GitHubPullRequestNum.HasValue + ? $"https://github.com/{Normalize.RepoName(input.GitHubRepoName)}/pull/{input.GitHubPullRequestNum}" + : null; + + var origin = string.IsNullOrEmpty(input.Origin) + ? $"https://github.com/{Normalize.RepoName(input.GitHubRepoName)}/commit/{input.GitHubCommitHash}" + : input.Origin; + + return await AddResultAsync(courseId, submission, Normalize.Neptun(input.NeptunCode), input.GitHubPullRequestNum, prUrl, + timestamp, AutomatedActor, origin, confirmed: false, AggregatePoints(input.Result), cancellationToken); + } + + /// + /// Sums task points per exercise name, ordered by name. Port of ResultProcessor.GetTotalPoints — + /// per-task detail is intentionally not persisted. + /// + internal static List<(string Name, double Point)> AggregatePoints(IReadOnlyList tasks) + { + if (tasks is null) + return new List<(string, double)>(); + + return tasks + .GroupBy(r => string.IsNullOrEmpty(r.ExerciseName) ? string.Empty : r.ExerciseName) + .Select(g => (Name: g.Key, Point: g.Sum(r => r.Points))) + .OrderBy(x => x.Name, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Positional values keep the previous result's exercise names where available, otherwise "ex{i}". + /// Port of SetGradeService.getPoints. + /// + internal static List<(string Name, double Point)> BuildPoints(IReadOnlyList values, ICollection? previousPoints) + { + var previous = previousPoints?.OrderBy(p => p.Order).ToList(); + var result = new List<(string, double)>(values.Count); + + for (var i = 0; i < values.Count; i++) + { + var name = previous is not null && previous.Count > i ? previous[i].Name : $"ex{i}"; + result.Add((name, values[i])); + } + + return result; + } + + private Task GetLastResultAsync(int courseId, int submissionId, int? prNumber, CancellationToken cancellationToken) + => db.GradeRecords + .IgnoreQueryFilters() + .Include(g => g.Points) + .Where(g => g.CourseId == courseId && g.SubmissionId == submissionId && g.PrNumber == prNumber) + .OrderByDescending(g => g.Date) + .FirstOrDefaultAsync(cancellationToken); + + private async Task AddResultAsync( + int courseId, Submission submission, string neptun, int? prNumber, string? prUrl, + DateTimeOffset date, string? actor, string? origin, bool confirmed, + List<(string Name, double Point)> points, CancellationToken cancellationToken) + { + var record = new GradeRecord + { + CourseId = courseId, + SubmissionId = submission.Id, + StudentId = submission.StudentId, + Neptun = neptun, + PrNumber = prNumber, + PrUrl = prUrl, + Date = date, + Actor = actor, + Origin = origin, + Confirmed = confirmed, + }; + + for (var i = 0; i < points.Count; i++) + record.Points.Add(new GradeExercisePoint { Name = points[i].Name, Point = points[i].Point, Order = i }); + + db.GradeRecords.Add(record); + + submission.LastEventAt = date; + await db.SaveChangesAsync(cancellationToken); + + return record; + } +} diff --git a/ahk-backend/Ahk.Web.Services/Health/CiCallbackTokenHealthCheck.cs b/ahk-backend/Ahk.Web.Services/Health/CiCallbackTokenHealthCheck.cs new file mode 100644 index 0000000..bd8b570 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/CiCallbackTokenHealthCheck.cs @@ -0,0 +1,52 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Health; + +/// +/// Checks that the course can still receive evaluation results: publish-results-pr signs its callback with a +/// , and without a live one every evaluation is rejected. +/// +public sealed class CiCallbackTokenHealthCheck : ICourseHealthCheck +{ + private readonly ApplicationDbContext db; + + public CiCallbackTokenHealthCheck(ApplicationDbContext db) => this.db = db; + + public string Id => "ci-callback-token"; + + public string Title => "CI callback token"; + + public int Order => 30; + + public async Task RunAsync(Course course, CancellationToken cancellationToken = default) + { + // IgnoreQueryFilters: health checks run in the host/admin context, where no current course is set and + // the course filter would otherwise match nothing. + var tokens = await db.CourseWebhookTokens + .IgnoreQueryFilters() + .AsNoTracking() + .Where(t => t.CourseId == course.Id) + .Select(t => new { t.RevokedAt }) + .ToListAsync(cancellationToken); + + var active = tokens.Count(t => t.RevokedAt is null); + if (active > 0) + { + return HealthCheckResult.Healthy( + this, + active == 1 ? "One active token accepts evaluation results." : $"{active} active tokens accept evaluation results."); + } + + return tokens.Count == 0 + ? HealthCheckResult.NotConfigured( + this, + "No callback token exists, so evaluation results from GitHub Actions will be rejected.", + "Create a token under CI callback tokens and set it on the course's evaluator workflow.") + : HealthCheckResult.Failed( + this, + "Every callback token for this course has been revoked, so evaluation results are being rejected.", + "Create a replacement token and update the course's evaluator workflow."); + } +} diff --git a/ahk-backend/Ahk.Web.Services/Health/CourseHealthModels.cs b/ahk-backend/Ahk.Web.Services/Health/CourseHealthModels.cs new file mode 100644 index 0000000..3d2db46 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/CourseHealthModels.cs @@ -0,0 +1,89 @@ +namespace Ahk.Web.Services.Health; + +/// Outcome of a single health check. Ordered by severity so a report can take the worst. +public enum HealthStatus +{ + /// The check had nothing to verify (an optional setting is not in use). + NotConfigured = 0, + + /// The check passed. + Healthy = 1, + + /// The check passed, but something needs attention before it becomes a failure. + Warning = 2, + + /// The check failed — this part of the course's integration will not work. + Failed = 3, +} + +/// Result of one run against one course. +public sealed class HealthCheckResult +{ + /// Stable identifier of the check that produced this result (). + public string CheckId { get; init; } = string.Empty; + + /// Human-readable name of the check, e.g. "GitHub access token". + public string Title { get; init; } = string.Empty; + + public HealthStatus Status { get; init; } + + /// One sentence stating what was found. Shown verbatim in the admin UI. + public string Message { get; init; } = string.Empty; + + /// Optional next step when the check did not pass. + public string? Remediation { get; init; } + + public int DurationMs { get; init; } + + public static HealthCheckResult Healthy(ICourseHealthCheck check, string message) => + Create(check, HealthStatus.Healthy, message, null); + + public static HealthCheckResult Warning(ICourseHealthCheck check, string message, string? remediation = null) => + Create(check, HealthStatus.Warning, message, remediation); + + public static HealthCheckResult Failed(ICourseHealthCheck check, string message, string? remediation = null) => + Create(check, HealthStatus.Failed, message, remediation); + + public static HealthCheckResult NotConfigured(ICourseHealthCheck check, string message, string? remediation = null) => + Create(check, HealthStatus.NotConfigured, message, remediation); + + private static HealthCheckResult Create(ICourseHealthCheck check, HealthStatus status, string message, string? remediation) => new() + { + CheckId = check.Id, + Title = check.Title, + Status = status, + Message = message, + Remediation = remediation, + }; +} + +/// All check results for one course, plus the aggregate status the course list shows. +public sealed class CourseHealthReport +{ + public int CourseId { get; init; } + + public string CourseSlug { get; init; } = string.Empty; + + public string CourseName { get; init; } = string.Empty; + + public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow; + + public IReadOnlyList Checks { get; init; } = Array.Empty(); + + /// + /// The worst status across the checks. results do not drag the + /// aggregate down on their own — a course with nothing wired up yet is incomplete, not broken — but a + /// report made up entirely of them reports . + /// + public HealthStatus Status + { + get + { + if (Checks.Count == 0) + return HealthStatus.NotConfigured; + + var worst = Checks.Max(c => c.Status); + return worst; + } + } +} diff --git a/ahk-backend/Ahk.Web.Services/Health/CourseHealthService.cs b/ahk-backend/Ahk.Web.Services/Health/CourseHealthService.cs new file mode 100644 index 0000000..953b800 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/CourseHealthService.cs @@ -0,0 +1,91 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Health; + +public interface ICourseHealthService +{ + /// Runs every registered check against one course. Returns null when the course does not exist. + Task CheckCourseAsync(int courseId, CancellationToken cancellationToken = default); + + /// Runs every registered check against every course. + Task> CheckAllCoursesAsync(CancellationToken cancellationToken = default); +} + +/// +/// Runs the registered s and assembles the reports the admin dashboard shows. +/// +/// Courses are checked sequentially: the checks share the request's , which is +/// not thread-safe, and the only slow check () is bounded by its own +/// 10-second HTTP timeout. +/// +public sealed class CourseHealthService : ICourseHealthService +{ + private readonly ApplicationDbContext db; + private readonly IReadOnlyList checks; + + public CourseHealthService(ApplicationDbContext db, IEnumerable checks) + { + this.db = db; + this.checks = checks.OrderBy(c => c.Order).ThenBy(c => c.Id, StringComparer.Ordinal).ToList(); + } + + public async Task CheckCourseAsync(int courseId, CancellationToken cancellationToken = default) + { + var course = await LoadCourses().FirstOrDefaultAsync(c => c.Id == courseId, cancellationToken); + return course is null ? null : await RunChecksAsync(course, cancellationToken); + } + + public async Task> CheckAllCoursesAsync(CancellationToken cancellationToken = default) + { + var courses = await LoadCourses().OrderBy(c => c.Slug).ToListAsync(cancellationToken); + + var reports = new List(courses.Count); + foreach (var course in courses) + reports.Add(await RunChecksAsync(course, cancellationToken)); + + return reports; + } + + private IQueryable LoadCourses() => db.Courses.AsNoTracking().Include(c => c.GitHubConfig); + + private async Task RunChecksAsync(Course course, CancellationToken cancellationToken) + { + var results = new List(checks.Count); + + foreach (var check in checks) + { + var started = DateTimeOffset.UtcNow; + HealthCheckResult result; + try + { + result = await check.RunAsync(course, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A check is not allowed to take the dashboard down with it. + result = HealthCheckResult.Failed(check, $"The check could not complete: {ex.Message}"); + } + + var elapsed = (int)(DateTimeOffset.UtcNow - started).TotalMilliseconds; + results.Add(new HealthCheckResult + { + CheckId = result.CheckId, + Title = result.Title, + Status = result.Status, + Message = result.Message, + Remediation = result.Remediation, + DurationMs = elapsed, + }); + } + + return new CourseHealthReport + { + CourseId = course.Id, + CourseSlug = course.Slug, + CourseName = course.Name, + Checks = results, + }; + } +} diff --git a/ahk-backend/Ahk.Web.Services/Health/GitHubAccessHealthCheck.cs b/ahk-backend/Ahk.Web.Services/Health/GitHubAccessHealthCheck.cs new file mode 100644 index 0000000..4452698 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/GitHubAccessHealthCheck.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Services.Health; + +/// +/// Verifies that the course's GitHub access token still works and reaches the course's organization. +/// Two calls, both cheap: GET /user proves the token is valid, GET /orgs/{org} proves it can see +/// the organization the course's repositories live in. +/// +/// This is the only check that leaves the process, so it carries its own short timeout — the admin dashboard +/// runs it for every course at once and must stay responsive when GitHub is slow. +/// +public sealed class GitHubAccessHealthCheck : ICourseHealthCheck +{ + /// Named registered by AddAhkServices. + public const string HttpClientName = "github-health"; + + private readonly IHttpClientFactory httpClientFactory; + + public GitHubAccessHealthCheck(IHttpClientFactory httpClientFactory) => this.httpClientFactory = httpClientFactory; + + public string Id => "github-access-token"; + + public string Title => "GitHub access token"; + + public int Order => 20; + + public async Task RunAsync(Course course, CancellationToken cancellationToken = default) + { + var token = course.GitHubConfig?.GitHubAccessToken; + if (string.IsNullOrWhiteSpace(token)) + { + return HealthCheckResult.NotConfigured( + this, + "No access token is stored for this course.", + "Add a token under GitHub integration to let the portal talk to GitHub."); + } + + using var client = httpClientFactory.CreateClient(HttpClientName); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var (identity, failure) = await GetLoginAsync(client, "user", cancellationToken); + if (failure is not null) + return failure; + + if (string.IsNullOrWhiteSpace(course.GitHubOrganization)) + { + return HealthCheckResult.Warning( + this, + $"The token is valid (authenticated as {identity}), but the course has no GitHub organization to check against.", + "Set the course's GitHub organization so repository access can be verified."); + } + + var (org, orgFailure) = await GetLoginAsync(client, $"orgs/{Uri.EscapeDataString(course.GitHubOrganization)}", cancellationToken); + if (orgFailure is not null) + { + // A valid token that cannot see the org is the interesting failure: report both facts. + return HealthCheckResult.Failed( + this, + $"The token is valid (authenticated as {identity}) but cannot read the organization '{course.GitHubOrganization}'. {orgFailure.Message}", + $"Grant the token access to '{course.GitHubOrganization}', or correct the organization on the course."); + } + + return HealthCheckResult.Healthy(this, $"Authenticated as {identity}; organization '{org}' is reachable."); + } + + /// + /// Calls a GitHub endpoint and returns its login field. The failure result is returned rather than + /// thrown so a single unreachable course cannot fail the whole dashboard. + /// + private async Task<(string? Login, HealthCheckResult? Failure)> GetLoginAsync(HttpClient client, string path, CancellationToken cancellationToken) + { + try + { + using var response = await client.GetAsync(path, cancellationToken); + + if (!response.IsSuccessStatusCode) + return (null, HealthCheckResult.Failed(this, DescribeFailure(response.StatusCode), RemediationFor(response.StatusCode))); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + var login = document.RootElement.TryGetProperty("login", out var value) ? value.GetString() : null; + return (login ?? "unknown", null); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (null, HealthCheckResult.Failed(this, "GitHub did not respond within 10 seconds.", "Check network access from the server to api.github.com.")); + } + catch (HttpRequestException ex) + { + return (null, HealthCheckResult.Failed(this, $"GitHub could not be reached: {ex.Message}", "Check network access from the server to api.github.com.")); + } + catch (JsonException) + { + return (null, HealthCheckResult.Failed(this, "GitHub returned a response the portal could not read.", "Retry; if it persists, check whether the API base address is correct.")); + } + } + + private static string DescribeFailure(HttpStatusCode status) => status switch + { + HttpStatusCode.Unauthorized => "GitHub rejected the token (401). It is invalid, revoked or expired.", + HttpStatusCode.Forbidden => "GitHub refused the request (403). The token lacks the required scope, or the rate limit is exhausted.", + HttpStatusCode.NotFound => "GitHub returned 404. The organization does not exist, or the token cannot see it.", + _ => $"GitHub returned {(int)status} {status}.", + }; + + private static string RemediationFor(HttpStatusCode status) => status switch + { + HttpStatusCode.Unauthorized => "Issue a new access token and save it under GitHub integration.", + HttpStatusCode.Forbidden => "Give the token the read:org and repo scopes, then try again.", + HttpStatusCode.NotFound => "Check the organization name on the course and the token's access to it.", + _ => "Retry the check; if it persists, verify the token on GitHub.", + }; +} diff --git a/ahk-backend/Ahk.Web.Services/Health/GitHubAppInstallationHealthCheck.cs b/ahk-backend/Ahk.Web.Services/Health/GitHubAppInstallationHealthCheck.cs new file mode 100644 index 0000000..e0b9cde --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/GitHubAppInstallationHealthCheck.cs @@ -0,0 +1,123 @@ +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.GitHub; + +namespace Ahk.Web.Services.Health; + +/// +/// Verifies the course's GitHub App can actually act on its organization: that the credentials mint an +/// installation token at all, that the installation covers every repository, and that it was granted +/// administration: write. +/// +/// That last one is the whole point. Creating a repository from a template and adding a student as a +/// collaborator both sit behind it, so without this check the first sign of a missing permission would be a +/// student staring at a failed invite. Here an administrator sees it on the dashboard instead. +/// +public sealed class GitHubAppInstallationHealthCheck : ICourseHealthCheck +{ + /// The dashboard runs every course at once; no single check may hold it open. + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(10); + + private readonly ICourseGitHubAppTokenProvider tokens; + + public GitHubAppInstallationHealthCheck(ICourseGitHubAppTokenProvider tokens) => this.tokens = tokens; + + public string Id => "github-app-installation"; + + public string Title => "GitHub App installation"; + + public int Order => 25; + + public async Task RunAsync(Course course, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(course); + + if (string.IsNullOrWhiteSpace(course.GitHubConfig?.GitHubAppId) || string.IsNullOrWhiteSpace(course.GitHubConfig?.GitHubAppPrivateKey)) + { + return HealthCheckResult.NotConfigured( + this, + "No GitHub App id and private key are stored for this course.", + "Register a GitHub App for the organization and add its id and private key under GitHub integration. See docs/github-app.md."); + } + + if (string.IsNullOrWhiteSpace(course.GitHubOrganization)) + { + return HealthCheckResult.Failed( + this, + "The course has no GitHub organization, so the App installation cannot be located.", + "Set the course's GitHub organization."); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(Budget); + + try + { + // bypassCache: a health check that reports a cached success from 40 minutes ago is not a health check. + var token = await tokens.GetForCourseAsync(course, bypassCache: true, timeout.Token); + if (token is null) + { + return HealthCheckResult.NotConfigured( + this, + "The GitHub App is not fully configured for this course.", + "Check the App id, private key and organization under GitHub integration."); + } + + var granted = token.Permissions.Count == 0 + ? "none" + : string.Join(", ", token.Permissions.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => $"{p.Key}: {p.Value}")); + + if (!token.HasAdministrationWrite) + { + return HealthCheckResult.Failed( + this, + $"Installation {token.InstallationId} works, but it was not granted 'administration: write'. Assignments cannot create repositories or add students. Granted: {granted}.", + "Edit the App's repository permissions, set Administration to Read & write, and accept the new permissions on the organization's installation."); + } + + // "selected" means new repositories fall outside the installation, so the collaborator call that + // follows repository creation would 404 on a repository the App itself just made. + if (!string.Equals(token.RepositorySelection, "all", StringComparison.Ordinal)) + { + return HealthCheckResult.Warning( + this, + $"Installation {token.InstallationId} is limited to selected repositories, so repositories created for students fall outside it.", + "Change the installation's repository access to 'All repositories'."); + } + + return HealthCheckResult.Healthy(this, $"Installation {token.InstallationId} covers all repositories and can administer them."); + } + catch (GitHubOperationException ex) + { + return HealthCheckResult.Failed(this, ex.Message, RemediationFor(ex)); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + return HealthCheckResult.Failed( + this, + $"The stored private key could not be read: {ex.Message}", + "Re-download the App's private key (.pem) from GitHub and paste its full contents under GitHub integration."); + } + catch (FormatException) + { + return HealthCheckResult.Failed( + this, + "The stored private key is not valid PEM or base64.", + "Re-download the App's private key (.pem) from GitHub and paste its full contents under GitHub integration."); + } + catch (HttpRequestException ex) + { + return HealthCheckResult.Failed(this, $"GitHub could not be reached: {ex.Message}", "Check network access from the server to api.github.com."); + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return HealthCheckResult.Failed(this, "GitHub did not respond within 10 seconds.", "Check network access from the server to api.github.com."); + } + } + + private static string RemediationFor(GitHubOperationException ex) => ex.Status switch + { + System.Net.HttpStatusCode.NotFound => "The App is registered but not installed on this organization. Install it, with access to all repositories.", + System.Net.HttpStatusCode.Unauthorized => "The App id or private key is wrong, or the key was revoked. Generate a new key and store it again.", + _ => "Check the App registration and its installation on the organization.", + }; +} diff --git a/ahk-backend/Ahk.Web.Services/Health/ICourseHealthCheck.cs b/ahk-backend/Ahk.Web.Services/Health/ICourseHealthCheck.cs new file mode 100644 index 0000000..0bd7fa9 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/ICourseHealthCheck.cs @@ -0,0 +1,26 @@ +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Services.Health; + +/// +/// One verifiable property of a course's integration. Implementations are registered in DI and run by +/// ; adding a check to the admin dashboard means adding one class and one +/// registration line — nothing else changes. +/// +public interface ICourseHealthCheck +{ + /// Stable machine identifier, e.g. "github-access-token". Used as the result key. + string Id { get; } + + /// Short name shown as the check's label in the admin UI. + string Title { get; } + + /// Order within a course's report; lower runs and displays first. + int Order { get; } + + /// + /// Runs the check. The is loaded with its . + /// Implementations must not throw: return a result instead. + /// + Task RunAsync(Course course, CancellationToken cancellationToken = default); +} diff --git a/ahk-backend/Ahk.Web.Services/Health/WebhookConfigurationHealthCheck.cs b/ahk-backend/Ahk.Web.Services/Health/WebhookConfigurationHealthCheck.cs new file mode 100644 index 0000000..1d6a79a --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Health/WebhookConfigurationHealthCheck.cs @@ -0,0 +1,48 @@ +using Ahk.Web.Data.Entities; + +namespace Ahk.Web.Services.Health; + +/// +/// Checks the settings the GitHub webhook receiver will need: an organization to resolve incoming deliveries +/// to this course, and a secret to validate their X-Hub-Signature-256 header. Local check, no network. +/// +public sealed class WebhookConfigurationHealthCheck : ICourseHealthCheck +{ + public string Id => "github-webhook-config"; + + public string Title => "Webhook settings"; + + public int Order => 10; + + public Task RunAsync(Course course, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(course.GitHubOrganization)) + { + return Task.FromResult(HealthCheckResult.NotConfigured( + this, + "The course has no GitHub organization, so incoming deliveries cannot be routed to it.", + "Set the GitHub organization on the course's general settings.")); + } + + var config = course.GitHubConfig; + if (config is null || string.IsNullOrWhiteSpace(config.GitHubWebhookSecret)) + { + return Task.FromResult(HealthCheckResult.Warning( + this, + $"Deliveries route to '{course.GitHubOrganization}', but no webhook secret is stored, so their signature cannot be validated.", + "Paste the secret configured on the GitHub App into GitHub integration.")); + } + + if (!config.Enabled) + { + return Task.FromResult(HealthCheckResult.Warning( + this, + "The integration is turned off — the portal will ignore this course's webhooks.", + "Turn the integration back on under GitHub integration.")); + } + + return Task.FromResult(HealthCheckResult.Healthy( + this, + $"Deliveries from '{course.GitHubOrganization}' route to this course and their signature is validated.")); + } +} diff --git a/ahk-backend/Ahk.Web.Services/Integrations/HmacSha256Validator.cs b/ahk-backend/Ahk.Web.Services/Integrations/HmacSha256Validator.cs new file mode 100644 index 0000000..1f7dc09 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Integrations/HmacSha256Validator.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Ahk.Web.Services.Integrations; + +/// +/// Verifies the signature the evaluator container puts on an evaluation result. Ported verbatim from +/// grade-management/.../Helpers/HmacSha256Validator.cs; the counterpart that produces these signatures +/// is Go, in publish-results-pr/internal/publishtoapi, and the two are pinned to each other by an +/// identical set of golden vectors in both test suites. +/// +/// The string to sign is four parts joined by single \n characters, with no trailing newline: +/// UPPERCASE(verb), lowercase(url), the RFC1123 date, then the raw body. Two details are +/// load-bearing and must not be "modernised": the key is ASCII bytes (matching Go's +/// []byte(secret) for the ASCII-only secrets the generator produces), and the whole URL including +/// any query string is signed — which is why the callback URL has to match byte for byte on both +/// sides. +/// +public static class HmacSha256Validator +{ + public static bool IsSignatureValid(string httpVerb, string httpUrl, DateTime date, string requestBody, string? receivedSignature, string? secret) + { + if (string.IsNullOrEmpty(receivedSignature) || string.IsNullOrEmpty(secret)) + return false; + + var key = Encoding.ASCII.GetBytes(secret); + var payloadSignedBytes = GetBytesToSign(httpVerb, httpUrl, date, requestBody); + + using var hmac = new HMACSHA256(key); + var hash = hmac.ComputeHash(payloadSignedBytes); + var expectedSignature = Convert.ToBase64String(hash); + + // Constant-time: grade-management used string.Equals here, which returns at the first differing + // character and lets a forged signature be refined one character at a time by timing. See + // SignatureComparison. + return SignatureComparison.FixedTimeEquals(receivedSignature, expectedSignature); + } + + /// The signed string, exposed so a failing request can be diagnosed without leaking the secret. + [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "URL normalized to lowercase by design; the Go client does the same.")] + public static string GetStringToSign(string httpVerb, string httpUrl, DateTime date, string requestBody) + => string.Concat( + httpVerb.ToUpperInvariant(), + "\n", + httpUrl.ToLowerInvariant(), + "\n", + date.ToString("R", CultureInfo.InvariantCulture), + "\n", + requestBody); + + private static byte[] GetBytesToSign(string httpVerb, string httpUrl, DateTime date, string requestBody) + => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(GetStringToSign(httpVerb, httpUrl, date, requestBody)); +} diff --git a/ahk-backend/Ahk.Web.Services/ServiceCollectionExtensions.cs b/ahk-backend/Ahk.Web.Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..02a0d24 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/ServiceCollectionExtensions.cs @@ -0,0 +1,121 @@ +using System.Net.Http.Headers; +using Ahk.Web.Services.Assignments; +using Ahk.Web.Services.Courses; +using Ahk.Web.Services.GitHub; +using Ahk.Web.Services.GitHubWebhooks; +using Ahk.Web.Services.GitHubWebhooks.Handlers; +using Ahk.Web.Services.GitHubWebhooks.Handlers.GradeComment; +using Ahk.Web.Services.GitHubWebhooks.Handlers.StatusTracking; +using Ahk.Web.Services.Grading; +using Ahk.Web.Services.Health; +using Ahk.Web.Services.StatusTracking; +using Ahk.Web.Services.Submissions; +using Microsoft.Extensions.DependencyInjection; + +namespace Ahk.Web.Services; + +public static class ServiceCollectionExtensions +{ + /// Registers the domain services. All are scoped: they use the request's ApplicationDbContext. + public static IServiceCollection AddAhkServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddAhkGitHubApi(); + services.AddAhkGitHubWebhooks(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddAhkCourseHealthChecks(); + + return services; + } + + /// + /// The GitHub transport: Octokit clients for every API call, plus the per-course installation-token + /// provider that authenticates them. + /// + /// The named "github" registered here is *not* the API transport. It backs + /// only 's App-JWT bootstrap (signing a JWT and exchanging it for + /// an installation token is not an API call, and converting it to Octokit would change the shape of the + /// permissions the health check reads). + /// + public static IServiceCollection AddAhkGitHubApi(this IServiceCollection services) + { + services.AddHttpClient(GitHubApiDefaults.HttpClientName, client => + { + client.BaseAddress = new Uri("https://api.github.com/"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ahk-portal", "1.0")); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + + // Longer than the health client's 10s: creating a repository from a template is a write, and a + // student is watching a spinner rather than a dashboard of many courses. + client.Timeout = TimeSpan.FromSeconds(30); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// The GitHub webhook receiver: the dispatcher plus every handler, ported from github-monitor. + /// + /// Registration order is the dispatch order and is kept identical to that app's + /// Startup.registerEventHandlers — DI hands an IEnumerable<T> back in registration + /// order, which is why the explicit config builder it used is not needed here. + /// + public static IServiceCollection AddAhkGitHubWebhooks(this IServiceCollection services) + { + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// Registers the course health checks and the service that runs them. Adding a check to the admin + /// dashboard is one line here plus the class itself — the service discovers checks through DI. + /// + public static IServiceCollection AddAhkCourseHealthChecks(this IServiceCollection services) + { + services.AddHttpClient(GitHubAccessHealthCheck.HttpClientName, client => + { + client.BaseAddress = new Uri("https://api.github.com/"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("ahk-portal", "1.0")); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + + // The admin dashboard checks every course in one request; a hung call must not hold it open. + client.Timeout = TimeSpan.FromSeconds(10); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/ahk-backend/Ahk.Web.Services/SignatureComparison.cs b/ahk-backend/Ahk.Web.Services/SignatureComparison.cs new file mode 100644 index 0000000..bad8d02 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/SignatureComparison.cs @@ -0,0 +1,30 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace Ahk.Web.Services; + +/// +/// Compares two signature strings without leaking, through how long the comparison took, how much of a guess +/// was correct. +/// +/// Both webhook schemes in this application originally compared their signatures with +/// string.Equals, which returns at the first differing character. That is the textbook setup for a +/// remote timing attack: an attacker who can measure response times refines a forged signature one character +/// at a time, turning an infeasible search into a linear one. Neither validator's accept/reject behaviour +/// changes by fixing it — only the timing does. +/// +/// The length check is deliberately left fast and early. Signature length is fixed by the algorithm and +/// public knowledge, so it is not a secret to leak. +/// +internal static class SignatureComparison +{ + public static bool FixedTimeEquals(string received, string expected) + { + if (received.Length != expected.Length) + return false; + + return CryptographicOperations.FixedTimeEquals( + MemoryMarshal.AsBytes(received.AsSpan()), + MemoryMarshal.AsBytes(expected.AsSpan())); + } +} diff --git a/ahk-backend/Ahk.Web.Services/StatusTracking/Dto/RepositoryStatus.cs b/ahk-backend/Ahk.Web.Services/StatusTracking/Dto/RepositoryStatus.cs new file mode 100644 index 0000000..45345b8 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/StatusTracking/Dto/RepositoryStatus.cs @@ -0,0 +1,39 @@ +namespace Ahk.Web.Services.StatusTracking.Dto; + +/// +/// Projected current state of one submission, derived from its append-only event log. Shape preserved from the +/// original grade-management/.../StatusTracking/Dto/RepositoryStatus.cs so the teacher dashboard renders +/// the same information. +/// +public sealed class RepositoryStatus +{ + public string Repository { get; set; } = string.Empty; + + public string Neptun { get; set; } = string.Empty; + + public IReadOnlyCollection Branches { get; set; } = Array.Empty(); + + public IReadOnlyCollection PullRequests { get; set; } = Array.Empty(); + + public WorkflowRunsStatus WorkflowRuns { get; set; } = new(); +} + +public sealed class PullRequestStatus +{ + public int Number { get; set; } + + public string? HtmlUrl { get; set; } + + /// The most recent action seen for this pull request. + public string? Status { get; set; } + + /// Distinct assignees across the PR's events, comma-joined (as in the original implementation). + public string? Assignee { get; set; } +} + +public sealed class WorkflowRunsStatus +{ + public int Count { get; set; } + + public string? LastStatus { get; set; } +} diff --git a/ahk-backend/Ahk.Web.Services/StatusTracking/StatusTrackingService.cs b/ahk-backend/Ahk.Web.Services/StatusTracking/StatusTrackingService.cs new file mode 100644 index 0000000..c9f4f78 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/StatusTracking/StatusTrackingService.cs @@ -0,0 +1,85 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.StatusTracking.Dto; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.StatusTracking; + +public interface IStatusTrackingService +{ + Task> ListStatusesAsync(int courseId, CancellationToken cancellationToken = default); +} + +/// +/// Projects the append-only log into the per-submission status view. +/// Port of grade-management/.../StatusTracking/StatusTrackingService.cs: the grouping and +/// "latest event wins" rules are preserved exactly, with the repo-prefix filter replaced by the course id. +/// +public sealed class StatusTrackingService : IStatusTrackingService +{ + private readonly ApplicationDbContext db; + + public StatusTrackingService(ApplicationDbContext db) => this.db = db; + + public async Task> ListStatusesAsync(int courseId, CancellationToken cancellationToken = default) + { + var submissions = await db.Submissions + .IgnoreQueryFilters() + .AsNoTracking() + .Where(s => s.CourseId == courseId) + .Include(s => s.Events) + .Include(s => s.Student) + .ToListAsync(cancellationToken); + + return submissions.Select(CreateStatus).ToList(); + } + + private static RepositoryStatus CreateStatus(Submission submission) + { + var events = submission.Events; + + return new RepositoryStatus + { + Repository = submission.GitHubRepoName, + Neptun = GetNeptun(submission, events), + Branches = events.OfType().Select(e => e.Branch).Distinct().ToArray(), + PullRequests = events.OfType() + .GroupBy(e => e.Number) + .Select(GetPrStatus) + .ToArray(), + WorkflowRuns = GetWorkflowRunsStatus(events), + }; + } + + /// Latest non-empty neptun from the PR events, falling back to the linked student. + private static string GetNeptun(Submission submission, IEnumerable events) + => events.OfType() + .Where(e => !string.IsNullOrEmpty(e.Neptun)) + .OrderByDescending(e => e.Timestamp) + .Select(e => e.Neptun!) + .FirstOrDefault() + ?? submission.Student?.Neptun + ?? string.Empty; + + private static PullRequestStatus GetPrStatus(IGrouping events) + { + var latest = events.OrderByDescending(e => e.Timestamp).First(); + return new PullRequestStatus + { + Number = events.Key, + HtmlUrl = latest.HtmlUrl, + Status = latest.Action, + Assignee = string.Join(", ", events.SelectMany(e => e.Assignees).Distinct()), + }; + } + + private static WorkflowRunsStatus GetWorkflowRunsStatus(IEnumerable events) + { + var items = events.OfType().ToList(); + return new WorkflowRunsStatus + { + Count = items.Count, + LastStatus = items.OrderByDescending(e => e.Timestamp).FirstOrDefault()?.Conclusion, + }; + } +} diff --git a/ahk-backend/Ahk.Web.Services/StatusTracking/SubmissionEventService.cs b/ahk-backend/Ahk.Web.Services/StatusTracking/SubmissionEventService.cs new file mode 100644 index 0000000..ad657ed --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/StatusTracking/SubmissionEventService.cs @@ -0,0 +1,57 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Ahk.Web.Services.Submissions; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.StatusTracking; + +public interface ISubmissionEventService +{ + /// + /// Appends a status event. Returns false when the event was a webhook redelivery and was skipped. + /// + Task RecordAsync(int courseId, string gitHubRepoName, SubmissionEvent submissionEvent, string? neptun = null, CancellationToken cancellationToken = default); +} + +/// +/// Appends to the status event log (was StatusTrackingService.InsertNewEvent plus the queue plumbing). +/// Resolves the submission first, so events and grades share the same anchor row. +/// +public sealed class SubmissionEventService : ISubmissionEventService +{ + private readonly ApplicationDbContext db; + private readonly ISubmissionResolver submissions; + + public SubmissionEventService(ApplicationDbContext db, ISubmissionResolver submissions) + { + this.db = db; + this.submissions = submissions; + } + + public async Task RecordAsync(int courseId, string gitHubRepoName, SubmissionEvent submissionEvent, string? neptun = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(submissionEvent); + + // GitHub redelivers webhooks; the delivery id makes appending idempotent. + if (!string.IsNullOrEmpty(submissionEvent.GitHubDeliveryId)) + { + var alreadySeen = await db.SubmissionEvents.IgnoreQueryFilters() + .AnyAsync(e => e.GitHubDeliveryId == submissionEvent.GitHubDeliveryId, cancellationToken); + if (alreadySeen) + return false; + } + + var submission = await submissions.GetOrCreateAsync(courseId, gitHubRepoName, neptun, cancellationToken); + + submissionEvent.CourseId = courseId; + submissionEvent.SubmissionId = submission.Id; + if (submissionEvent.Timestamp == default) + submissionEvent.Timestamp = DateTimeOffset.UtcNow; + + db.SubmissionEvents.Add(submissionEvent); + submission.LastEventAt = submissionEvent.Timestamp; + + await db.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/ahk-backend/Ahk.Web.Services/Submissions/SubmissionResolver.cs b/ahk-backend/Ahk.Web.Services/Submissions/SubmissionResolver.cs new file mode 100644 index 0000000..03fa919 --- /dev/null +++ b/ahk-backend/Ahk.Web.Services/Submissions/SubmissionResolver.cs @@ -0,0 +1,67 @@ +using Ahk.Web.Data; +using Ahk.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Ahk.Web.Services.Submissions; + +public interface ISubmissionResolver +{ + Task GetOrCreateAsync(int courseId, string gitHubRepoName, string? neptun = null, CancellationToken cancellationToken = default); + + Task GetOrCreateStudentAsync(int courseId, string neptun, CancellationToken cancellationToken = default); +} + +/// +/// Resolves the (student, submission) pair every write path needs. In the original system these were just +/// normalized strings on each record; here they are rows, created on first sighting. +/// +/// Queries use IgnoreQueryFilters and filter on the explicit because callers +/// include machine-to-machine paths (webhooks, CI callbacks) that resolve their course from a payload or token +/// rather than the route, and must not depend on the ambient course context. +/// +public sealed class SubmissionResolver : ISubmissionResolver +{ + private readonly ApplicationDbContext db; + + public SubmissionResolver(ApplicationDbContext db) => this.db = db; + + public async Task GetOrCreateStudentAsync(int courseId, string neptun, CancellationToken cancellationToken = default) + { + var normalized = Normalize.Neptun(neptun); + + var student = await db.Students.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.CourseId == courseId && s.Neptun == normalized, cancellationToken); + + if (student is not null) + return student; + + student = new Student { CourseId = courseId, Neptun = normalized }; + db.Students.Add(student); + await db.SaveChangesAsync(cancellationToken); + return student; + } + + public async Task GetOrCreateAsync(int courseId, string gitHubRepoName, string? neptun = null, CancellationToken cancellationToken = default) + { + var repo = Normalize.RepoName(gitHubRepoName); + + var submission = await db.Submissions.IgnoreQueryFilters() + .FirstOrDefaultAsync(s => s.CourseId == courseId && s.GitHubRepoName == repo, cancellationToken); + + if (submission is null) + { + submission = new Submission { CourseId = courseId, GitHubRepoName = repo }; + db.Submissions.Add(submission); + } + + // The repository usually exists before neptun.txt is pushed, so link the student opportunistically. + if (!string.IsNullOrWhiteSpace(neptun) && submission.StudentId is null) + { + var student = await GetOrCreateStudentAsync(courseId, neptun, cancellationToken); + submission.StudentId = student.Id; + } + + await db.SaveChangesAsync(cancellationToken); + return submission; + } +} diff --git a/ahk-backend/Ahk.Web.slnx b/ahk-backend/Ahk.Web.slnx new file mode 100644 index 0000000..24ec34a --- /dev/null +++ b/ahk-backend/Ahk.Web.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ahk-backend/README.md b/ahk-backend/README.md new file mode 100644 index 0000000..4729c94 --- /dev/null +++ b/ahk-backend/README.md @@ -0,0 +1,205 @@ +# AHK Backend + +Centralized ASP.NET Core (.NET 10) Web API for the AHK portal at `ahk.aut.bme.hu`. Replaces the +per-course Azure Functions deployments with a single site whose domain data is assigned to +**Courses**. Provides ASP.NET Identity authentication (local username/password + generic OIDC, +cookie-based) and course-scoped, membership-authorized endpoints. + +> Milestone status: feature-complete against the original system. Auth, course-scoping, the domain +> model and services, the read endpoints, the site administration surface, **assignments** (the GitHub +> Classroom replacement) and now the write-side entry points — the **GitHub webhook receiver**, **`/ahk +> ok` chatops** and the **HMAC-verified CI callback** — are all in place, so a course can run on the +> portal with no Azure Functions. The four original apps remain in the repository, deployed, for +> courses that have not migrated yet; see [Cutover per course](docs/github-app.md#cutover-per-course). + +## Machine-to-machine endpoints + +Two routes are called by machines rather than by the SPA. Both are anonymous — a signature is the +authentication — and both are excluded from the OpenAPI document, so no TypeScript client is generated +for them. + +| Route | Authenticated by | Resolves its course from | +|---|---|---| +| `POST /api/integrations/github` | `X-Hub-Signature-256`, against the course's own webhook secret | `repository.full_name` in the payload (organization, then repo-name prefix) | +| `POST /api/integrations/evaluation-result` | `X-Ahk-Sha256` over verb + URL + date + body | the `X-Ahk-Token` header — the authenticated credential, not the caller-supplied repository name | + +Neither has a `{course}` path segment, which is why `ICourseResolutionService` exists. See +[docs/github-app.md](docs/github-app.md) and [docs/ci-callback.md](docs/ci-callback.md). + +⚠️ The webhook secret is per course, and the only thing identifying the course is the repository name +*inside* the body — so the receiver has to parse an unverified payload before it can verify it. +Everything before the signature check is deliberately inert: one property is read from a `JsonDocument` +that is then dropped, two indexed reads happen, and nothing is written, logged or called until the HMAC +passes. Keep it that way. + +## Projects + +- **Ahk.Web.Server** — Web API host: `Program.cs` wiring, `Auth/` (local + OIDC controllers), + `Admin/` (host-context course, user and health management), `Courses/` (course-context endpoints), + `CourseContext/` (course resolution middleware + membership authorization). +- **Ahk.Web.Services** — domain logic: grading, status tracking, submission resolution, course + resolution, CI callback tokens, `Assignments/` (assignment administration and the student invite + flow), `GitHub/` (the App installation-token provider and the REST calls it authenticates), + `GitHubWebhooks/` (the webhook dispatcher and the ten rule/status/chatops handlers ported from + `github-monitor`), `Integrations/` (the CI callback's HMAC scheme), and `Health/` (the course + health checks). + + **Octokit** is the portal's GitHub API client — one client, built by `ICourseGitHubClientFactory`. + The single exception is `CourseGitHubAppTokenProvider`, which stays on a raw `HttpClient`: it signs + an App JWT and exchanges it for an installation token, which is the auth bootstrap rather than an + API call, and moving it would change the shape of the permissions the health check reads. +- **Ahk.Web.Data** — EF Core `ApplicationDbContext` (Identity + `Course`/`CourseMembership` + + `ICourseScoped` global query filter), migrations, and the dev data seeder. +- **Ahk.Web.Server.Tests** — xUnit tests (course-scoping unit tests, health-check tests, grade parity + tests + API smoke tests via `WebApplicationFactory`). + +## Run + +```bash +# 1. Create/upgrade the database (LocalDB by default; see appsettings.Development.json) +dotnet ef database update --project Ahk.Web.Data --startup-project Ahk.Web.Data + +# 2. Run the API over HTTPS (Swagger UI at /swagger, OpenAPI at /swagger/v1/swagger.json) +dotnet run --project Ahk.Web.Server --launch-profile https # https://localhost:7443 + +# Tests +dotnet test +``` + +Dev data seeder (Development env only) creates: + +- site admin — `admin` / `Admin123!` +- instructor (member of `viaubc01` only) — `instructor` / `Instructor123!` +- sample courses `viaubc01`, `viaubb01` — deliberately configured differently so the health dashboard + shows a mix of states + +## Configuration (keys under `Authentication`/`ConnectionStrings`) + +- `ConnectionStrings:Default` — MSSQL connection string. +- `Authentication:Oidc:*` — OIDC provider. When `Authority`/`ClientId` are empty, OIDC is disabled and + the app runs with local login only. + +### OIDC against the BME IdP + +Registered as client `AUTAhkClient` at `https://idp.bme.hu` (Shibboleth). The defaults in +`Configuration/OidcOptions.cs` encode constraints of that provider — changing them breaks login: + +| Setting | Value | Why | +|---|---|---| +| `Scopes` | `openid email userinfo` | Exactly what is registered; `profile` is **not** registered | +| `UsePkce` | `false` | IdP does not advertise `code_challenge_methods_supported` | +| `ResponseMode` | `query` | `form_post` would drop the correlation cookie (SameSite=Lax) | +| `EndSessionEndpoint` | empty | IdP advertises none → logout is local-only | + +`neptun_code` and `eduperson_scoped_affiliation` arrive from the **userinfo** endpoint and are mapped +explicitly in `Program.cs` (ASP.NET maps no standard claims by default), then persisted onto +`ApplicationUser` by `Auth/ExternalClaimsMapper.cs` on every login. + +**The client secret is never committed**: use `dotnet user-secrets set "Authentication:Oidc:ClientSecret" "…"` +locally and the `Authentication__Oidc__ClientSecret` environment variable in production. + +### Local OIDC development + +Only the production redirect URI (`https://ahk.aut.bme.hu/signin-oidc`) is registered, so localhost +cannot talk to the real IdP. Development instead uses the in-app mock provider (`MockOidc/`), enabled by +`Authentication:Oidc:UseMockProvider` — it serves discovery/authorize/token/userinfo/JWKS under +`/mock-oidc` and signs real RS256 id_tokens, so the genuine validation path is exercised. + +```bash +# choose which fixed persona the next login returns +curl -k "https://localhost:7443/mock-oidc/persona?user=student" # instructor | student | noclaims +``` + +## Site administration API + +All under `/api/admin/...`, all requiring the site-level `Admin` role. This is the surface the SPA's +admin console is built on. + +| Route | Purpose | +|---|---| +| `GET/POST /courses`, `GET/PUT/DELETE /courses/{id}` | The course register. `DELETE` requires `?confirmSlug=` to match, and cascades to the course's students, submissions, events, grades, tokens and staff | +| `GET/PUT /courses/{id}/github` | GitHub integration: App ID + private key, access token, webhook secret, workflow-run limit, on/off | +| `GET/PUT/DELETE /courses/{id}/members[/{userId}]` | Course staff and the role each holds | +| `GET/POST/DELETE /courses/{id}/tokens[/{tokenId}]` | CI callback tokens. The creating response is the only place a token's secret appears | +| `GET /users`, `POST /users`, `GET/PUT/DELETE /users/{id}` | Accounts. Search by name/username/email/Neptun, filter by course | +| `PUT /users/{id}/roles` | Site roles. An admin cannot remove their own `Admin` role | +| `PUT/DELETE /users/{id}/courses[/{courseId}]` | Course assignments | +| `POST /users/{id}/password` | Set a local account's password | +| `GET /health`, `GET /health/{courseId}` | Run the course health checks | + +**Stored credentials are never returned.** The GitHub config DTO reports only whether each credential +is present (plus a last-four hint for the access token). On update, each credential field follows one +rule: `null` leaves the stored value alone, `""` clears it, anything else replaces it — so saving an +untouched form cannot wipe a secret the browser was never shown. + +## Course health checks + +`Ahk.Web.Services/Health/` answers "is this course's integration actually wired up?". Each check is an +`ICourseHealthCheck` registered in DI and discovered by `CourseHealthService`, so **adding a check is +one class plus one registration line** — the controller and the UI need no change. + +| Check | What it verifies | +|---|---| +| `github-webhook-config` | The course has an organization to route deliveries from, a secret to validate their signature, and the integration is on. Local | +| `github-access-token` | `GET /user` and `GET /orgs/{org}` against api.github.com with the stored token. Network, 10s timeout | +| `github-app-installation` | The App credentials mint an installation token, the installation covers **all** repositories, and it was granted `administration: write` — without which assignments cannot create repositories. Network, 10s timeout | +| `ci-callback-token` | At least one non-revoked `CourseWebhookToken` exists, so evaluation results are still accepted | + +Results carry a status (`Healthy` / `Warning` / `Failed` / `NotConfigured`), a message, and a +remediation line; a course's overall status is the worst of its checks. Checks must not throw — a +failure is a `Failed` result, so one unreachable course cannot take the dashboard down. + +## Assignments (the GitHub Classroom replacement) + +Instructors define assignments against a template repository and hand out an invite link; students +follow it and the portal creates their repository. **`docs/github-app.md` is the reference for the +GitHub App this depends on** — registration, permissions, installation scope and troubleshooting. + +| Route | Authorization | Purpose | +|---|---|---| +| `/api/{course}/assignments` | `CourseMember` | Instructor CRUD, archive/unarchive, regenerate invite link, acceptance roster | +| `/api/{course}/invite/{token}` | **`[Authorize]` only** | The student flow: state, then `POST accept` | +| `/api/my/assignments` | `[Authorize]` | Every repository the caller holds, across courses, plus `POST {id}/resend-invitation` | +| `/api/profile/github` | `[Authorize]` | Records the caller's GitHub username after verifying it exists | + +Three things about this are deliberate and easy to break: + +- **The invite and `/my` endpoints must not require course membership.** Accepting an assignment is + how a student first appears in a course at all, so gating them on `CourseMember` locks every + student out of the only endpoints meant for them. The invite *token* is the capability. +- **`/api/my/...` has no `{course}` segment**, so no current course is resolved and the course query + filter matches nothing. `StudentAssignmentService` reads with `IgnoreQueryFilters()` and filters on + the user id; a "helpful" cleanup that removes those calls silently returns zero rows. +- **Assignments are additive.** Repositories created outside the portal keep working exactly as + before — nothing in grading or status tracking may start requiring an `Assignment` row. + +Accepting is idempotent: the repository is looked up on GitHub before it is created, and a unique +index on `(AssignmentId, UserId)` settles the double-click race. Student repositories are private and +named `{template repository name}-{neptun}`, lower-cased like every other repository name in the model. + +Students who are not organization members receive a GitHub *invitation* rather than direct access, and +invitations expire. That state is tracked per acceptance and re-sendable from `/my`; the expiry is read +from GitHub's own `expired` flag, never computed here. + +## Auth model + +- Cookie-based ASP.NET Identity (the API returns 401/403 rather than redirecting). We intentionally + do **not** use `MapIdentityApi` — see the architecture plan for the rationale (no OIDC support, + bearer-token focus, fixed shapes). +- **The auth cookies are named `ahk.auth` / `ahk.auth.external`** (`Program.ApplicationCookieName`), + not the framework defaults. Browsers scope cookies by host and ignore the port, so on `localhost` + every ASP.NET Identity app would otherwise share `.AspNetCore.Identity.Application`; a cookie from + another project carrying a GUID user id crashes this int-keyed app inside `SecurityStampValidator`, + 500-ing every request including login. `OnValidatePrincipal` additionally wraps the stamp validator + so an unreadable cookie signs the caller out rather than throwing. Do not revert either. +- `POST /api/auth/login` distinguishes its failures: the 401 body carries a `LoginFailureResponse` + with `reason` = `InvalidCredentials` / `LockedOut` / `NotAllowed`. Sign-in uses + `lockoutOnFailure: true`, so five wrong attempts lock the account for five minutes — without the + distinction that is indistinguishable from a typo. +- Course context: `/api/{course}/...` routes are resolved to a `Course` and gated by the + `CourseMember` policy. Host/admin routes live under `/api/admin/...`. Machine-to-machine webhook + endpoints (added in the port) resolve their course from the payload/token, not the path segment. +- **Site admins can open any course.** `CourseMembershipAuthorizationHandler` grants the + `CourseMember` policy to the `Admin` role, and `GET /api/auth/me` therefore lists *every* course for + an admin — marked `viaSiteAdmin: true` where there is no membership record — so the SPA's course + switcher and route guard need no special case for them. diff --git a/ahk-backend/docs/ci-callback.md b/ahk-backend/docs/ci-callback.md new file mode 100644 index 0000000..f8fee89 --- /dev/null +++ b/ahk-backend/docs/ci-callback.md @@ -0,0 +1,133 @@ +# The CI callback + +When the evaluator finishes inside a student's repository, the `publish-results-pr` GitHub Action posts the +result to the portal. This document is the contract between the two. + +It matters more than most internal contracts because **the client cannot be redeployed on our schedule**: it +runs from a workflow file inside student repositories, which are created from a template and then belong to the +student. A change here breaks builds for a cohort that has already started. + +The client is Go (`publish-results-pr/internal/publishtoapi`); the server is +`Ahk.Web.Server/Integrations/EvaluationResultController.cs` and +`Ahk.Web.Services/Integrations/HmacSha256Validator.cs`. The scheme was ported unchanged from +`grade-management`, and both sides' test suites carry the same golden signatures. + +## Endpoint + +``` +POST https://ahk.aut.bme.hu/api/integrations/evaluation-result +``` + +⚠️ **The URL is part of the signature.** Scheme, host, path, trailing slash and any query string all have to +match what the action was configured with. Consequences worth knowing before you debug one of these: + +- **It must be `https`.** An `http://` value gets a 307 from `UseHttpsRedirection`, and even if the client + followed it, the client signed the `http` URL while the server computes the `https` one — a guaranteed + mismatch that looks nothing like a URL problem. +- **No trailing slash**, no extra path segments. +- **Casing is safe** — both sides lower-case the URL before signing. It is the only forgiving part. +- Behind IIS, `UseForwardedHeaders` running first in the pipeline is what makes the server see the *public* + URL rather than the internal one. Do not move it in `Program.cs`. + +## Action configuration + +Set these in the evaluator workflow of each **template** repository: + +| Input | Value | +|---|---| +| `AHK_APPURL` | `https://ahk.aut.bme.hu/api/integrations/evaluation-result` | +| `AHK_APPTOKEN` | The token from **Site administration → Courses → *course* → CI callback tokens** | +| `AHK_APPSECRET` | That token's secret, shown alongside it | + +Publishing is skipped entirely if any of the three is empty, so a template that has not been migrated yet +simply does not report — it does not fail. + +Tokens are per course and can be revoked. Revoking takes effect immediately; the secret lookup is cached for an +hour, and revoking evicts the entry. + +## Headers + +| Header | Purpose | +|---|---| +| `X-Ahk-Token` | Identifies the course. Sent in clear, so it is an identifier, not the secret. | +| `X-Ahk-Sha256` | Base64 HMAC-SHA256 of the string below. | +| `Date` | RFC1123, e.g. `Mon, 02 Jan 2006 15:04:05 GMT`. Signed as well as checked. | +| `X-Ahk-Delivery` | Logged only. Never validated, never used for de-duplication. | + +## The string to sign + +Four parts, joined by single `\n` characters, with **no trailing newline**: + +``` +UPPERCASE(http verb) +lowercase(full url, query string included) +RFC1123 date +raw request body +``` + +Signed with HMAC-SHA256, base64-encoded. The key is the token's secret as **ASCII** bytes — which matches Go's +`[]byte(secret)` for the ASCII-only secrets the generator produces, and is the reason secrets must stay ASCII. + +The `Date` header is checked against the server clock with a **±10 minute** window, and it is also what the +grade is timestamped with — not the moment the server happened to process it. + +## Request body + +```json +{ + "gitHubRepoName": "bmeaut/viaubc01-abc123", + "gitHubBranch": "refs/pull/12/merge", + "gitHubPullRequestNum": 12, + "gitHubCommitHash": "aa11cc33", + "neptunCode": "ABC123", + "imageFiles": [], + "result": [ + { "exerciseName": "ex1", "taskName": "t1", "points": 2, "comment": "ok" }, + { "exerciseName": "ex1", "taskName": "t2", "points": 3 } + ], + "origin": "https://github.com/bmeaut/viaubc01-abc123/commit/aa11cc33" +} +``` + +Two deliberate looseness's, neither of which should be tightened: + +- **Unknown members are ignored.** `imageFiles` has no counterpart on the server and never had one. A strict + deserializer would fail every student build at once. +- **`taskName` is not actually enforced**, despite being marked required, because .NET's validator does not + recurse into collections. It behaved this way in `grade-management` too; enforcing it now would start + rejecting results that pass today. + +`gitHubPullRequestNum` is omitted rather than sent as `0` when there is no pull request. + +### What is stored + +Per-task detail is **discarded**. Points are summed per `exerciseName` (tasks with none collapse into a single +unnamed group) and ordered by name, then written as an **unconfirmed** grade record — it does not appear in the +grade listing or the CSV export until a teacher confirms it with `/ahk ok`. + +## Responses + +`200` with an empty body on success. Everything else is a `400` naming the problem, in this order: + +| Message | Cause | +|---|---| +| `Date header missing` | No `Date` header. | +| `Date header value not valid RFC1123 string` | Malformed date. | +| `Date header value is not close enough to current date` | Runner clock is off by more than 10 minutes. | +| `X-Ahk-Sha256 header missing` | No signature. | +| `X-Ahk-Token header missing` | No token. | +| `X-Ahk-Token invalid` | Unknown token, revoked token, or a token whose course no longer exists. The three are deliberately indistinguishable. | +| `X-Ahk-Sha256 signature not valid` | Wrong secret, or — far more often — the signed URL does not match. See the endpoint section. | +| `Body cannot be deserialized as JSON: …` | Malformed body, or `gitHubRepoName`/`neptunCode` missing. | + +A `500` carries the exception text, because the caller's only view of it is a build log. + +⚠️ **The client treats any non-2xx as fatal and fails the student's build.** That is why an unknown repository +is not an error — the submission row is created on first sighting — and why a bad token is worth catching with +the health check before students meet it. + +## Diagnosing a signature mismatch + +The server logs the *URL component* of the string it signed at `Debug` level when a signature fails. Never the +body (it is student code) and never the secret. In practice the URL is the culprit almost every time; compare +it against `AHK_APPURL` character by character. diff --git a/ahk-backend/docs/github-app.md b/ahk-backend/docs/github-app.md new file mode 100644 index 0000000..f17f9a7 --- /dev/null +++ b/ahk-backend/docs/github-app.md @@ -0,0 +1,243 @@ +# The AHK GitHub App + +Every course in the portal acts on GitHub through a **GitHub App installed on that course's organization**. This +document covers what the App is for, how to register and install one, which permissions it needs and why, and +what to check when something does not work. + +Read this before setting up a new course. The health dashboard in Site administration checks most of what is +described here, but it can only tell you *that* something is wrong — this explains what to do about it. + +## Why an App, and why one per course + +Each course points at its own GitHub organization (`Course.GitHubOrganization`), and each organization is +administered by different people. A single shared credential would give every course's staff power over every +other course's repositories, and would die the day the person who created it left the university. + +A GitHub App avoids both problems: it is owned by the organization rather than a person, its permissions are +declared up front and visible to the organization's owners, and it can be uninstalled by them at any time +without touching any other course. + +The App is used for two things: + +| Purpose | Status | +|---|---| +| Receiving webhooks (pull requests, comments, workflow runs) and enforcing the course's rules | Live in the portal | +| Creating student repositories from an assignment's template and granting students access | Live in the portal | + +One App serves both. If the organization already has an App registered for `github-monitor`, extend that one +rather than registering a second — see [Permissions](#permissions) for the grant that has to be added — and +repoint its webhook URL as described in [Cutover per course](#cutover-per-course). + +## Registering the App + +Do this once per organization, as an organization owner. + +1. Go to the organization's **Settings → Developer settings → GitHub Apps → New GitHub App**. +2. **GitHub App name** — something identifiable, e.g. `AHK viaubc01`. +3. **Homepage URL** — `https://ahk.aut.bme.hu`. +4. **Webhook** + - Tick **Active**. + - **Webhook URL**: `https://ahk.aut.bme.hu/api/integrations/github` + - **Content type**: `application/json`. + - **Webhook secret**: generate a long random string. You will need it again in step 8. + There is no global secret — the receiver validates `X-Hub-Signature-256` against **that course's** stored + secret, and resolves which course a delivery belongs to from the repository name inside it. +5. **Permissions** — set exactly what the [table below](#permissions) lists. Nothing more: an App with rights + nobody uses is a liability, and organization owners are shown this list when they install it. +6. **Subscribe to events** — the five the portal has handlers for: *Create*, *Issue comment*, *Pull request*, + *Pull request review*, *Workflow run*. + Subscribing to more is harmless — anything else is answered `200` with `Event X is not of interest` — but it + makes the delivery log noisier to read. +7. **Where can this GitHub App be installed?** — *Only on this account*. +8. Create it, then on the App's page: + - Note the **App ID**. + - **Generate a private key**. GitHub downloads a `.pem` file — this is the only time you can get it. + +## Installing it + +On the App's page choose **Install App** and pick the organization. + +**Repository access must be “All repositories”.** + +This is not a convenience setting. Repositories created for students do not exist when the App is installed, so +under *Only select repositories* they fall outside the installation — the portal creates the repository +successfully and then gets a `404` from the very next call, trying to add the student to a repository it just +made itself. The health check reports this as a warning before it happens. + +## Permissions + +Set these under **Repository permissions**, plus the one **Organization permission** below. + +| Permission | Level | Why | +|---|---|---| +| **Administration** | **Read & write** | Creating a repository from a template (`POST /repos/{owner}/{repo}/generate`), adding a student as a collaborator (`PUT /repos/{owner}/{repo}/collaborators/{username}`), managing their invitations, setting a repository's Actions permissions, and applying branch protection all sit behind this one. **This is the grant to add if the App already exists for `github-monitor`.** | +| **Metadata** | Read-only | Mandatory for every App. Also backs `GET /repos/{owner}/{repo}`, which the portal uses to check the template exists and is marked as a template. | +| **Contents** | Read & write | Reading the template repository, and reading `.github/ahk-monitor.yml` and `neptun.txt` out of each student repository. | +| Pull requests | Read & write | Comments, merges, assignees — the `/ahk ok` command approves and merges. | +| Issues | Read & write | Issue comments carry the `/ahk ok` chatops and every warning the rules post. | +| Actions | Read-only | Counting workflow runs against the course's threshold. | + +Under **Organization permissions**: + +| Permission | Level | Why | +|---|---|---| +| **Members** | Read-only | `GET /orgs/{org}/members/{login}` is how the portal decides who may grade. **`/ahk ok` is refused for everyone without this**, and the workflow-run rule cannot tell staff from students, so it warns staff too. | + +Note that **Administration: write is broad** — it also permits changing repository settings and deleting +repositories. There is no narrower permission that covers creating repositories and adding collaborators, so +this is the floor, not a choice. + +Verifying a student's GitHub username (`GET /users/{login}`) needs no permission at all. The portal sends the +installation token anyway, purely to get GitHub's authenticated rate limit of 5000 requests an hour instead of +the anonymous 60. + +## Storing the credentials in the portal + +**Site administration → Courses → *course* → GitHub integration**: + +- **GitHub App id** — the App ID from the App's page. +- **GitHub App private key** — paste the entire contents of the `.pem` file, `-----BEGIN` line included. The + bare base64 body that `github-monitor`'s `AHK_GitHubAppPrivateKey` holds is also accepted, so a course being + migrated can paste what it already has. +- **Webhook secret** — the string from step 4. + +Stored credentials are never sent back to the browser. An empty field means *leave the stored value alone*; use +the explicit **Clear** control to remove one. That is what makes saving an untouched form safe. + +Never commit these anywhere. In production the connection string and any process-level secrets come from +environment variables; the App credentials live only in the database. + +## How the portal authenticates + +Each call runs as the App's *installation* on the course's organization: + +1. Build a short-lived **App JWT** — RS256, ten minutes, signed with the private key, issued by the App id. +2. `GET /orgs/{org}/installation` with that JWT → the installation id for the course's organization. +3. `POST /app/installations/{id}/access_tokens` → an **installation access token**, valid for 60 minutes. +4. The token is cached per course for 50 minutes and reused. + +The App JWT never leaves step 1–3; every repository operation uses the installation token. The code is +`Ahk.Web.Services/GitHub/CourseGitHubAppTokenProvider.cs`. + +Note the installation is looked up **from the organization**, not from the `installation.id` a webhook delivery +carries. The course is resolved *by* organization, so the two are the same installation by construction — and +deriving a credential from a payload field would be a weaker path to the same answer. A mismatch is logged as a +warning and otherwise ignored. + +## Delivery responses + +What the receiver answers, and what each status means when you read it in **Advanced → Recent Deliveries**. +Only the first two are normal. + +| Status | Body | Meaning | +|---|---|---| +| `200` | one line per handler | Delivered and processed. Read the lines: `no action needed` and `action not of interest` are both normal, and `event handler disabled: no ahk-monitor.yml or disabled` means the repository is not opted in. | +| `202` | `repository '…' is not mapped to a course` | The repository's organization (and repo-name prefix) match no course. Normal for repositories in the organization that are not coursework. | +| `202` | `GitHub integration is not configured` / `is turned off` | The course exists but has no GitHub integration saved, or it is switched off. **A course switched off silently ignores `/ahk ok`.** | +| `400` | `Payload signature not valid` | The secret stored on the course does not match the one on the App. | +| `400` | `X-GitHub-Event header missing` etc. | Something other than GitHub is posting, or the webhook is configured with the wrong content type. | +| `500` | `GitHub secret not configured` | The course has an organization but no webhook secret stored. Finish *Storing the credentials* below. | +| `500` | `GitHub App ID/Token not configured` | The App id or private key is missing or invalid, so no installation token could be minted. | + +Benign cases answer `202` rather than an error on purpose: GitHub colours every non-2xx red, and a delivery log +full of red is a log nobody reads. + +## Template repository requirements + +An assignment points at a template repository in the course's organization. It must: + +- be marked **Template repository** in its Settings (`is_template: true`) — without this GitHub refuses the + generate call outright; +- contain the evaluator workflow under `.github/workflows/`, with `AHK_APPURL` pointing at the portal (see + [ci-callback.md](ci-callback.md)); +- contain `.github/ahk-monitor.yml` with `enabled: true`, or **every event from repositories generated from it + is ignored**; +- have the branch students should start from as its **default branch** — only that branch is copied. + +⚠️ The opt-in answer is cached for **12 hours** per repository. Adding `ahk-monitor.yml` to a repository that +has already been seen therefore takes up to half a day to take effect; restarting the application is the only +faster flush. This is the most common reason a correctly wired webhook appears to do nothing. + +The assignment editor checks the first point when you save, and reports it as a warning rather than blocking: +an assignment may legitimately be drafted before its template exists. + +Student repositories are created **private**, named `{template repository name}-{neptun}` in lower case. + +## Repository invitations + +When a student accepts an assignment the portal grants them `push` on their repository. GitHub then does one of +two things: + +- the student is already an **organization member** → they are added outright (`204`) and can open the + repository immediately; +- they are **not** → GitHub creates an **invitation** (`201`). Until they accept it at + `https://github.com/{owner}/{repo}/invitations`, the repository 404s for them. + +Invitations expire (7 days at the time of writing). The portal tracks the pending state per acceptance, and the +student's own page (`/my`) shows it with a **Resend invitation** button. GitHub has no way to extend an +invitation, so re-sending withdraws the stale one and issues a fresh one. + +The portal never computes the expiry window itself — it reads GitHub's `expired` flag on the invitation. If +GitHub changes the policy, nothing here has to change. + +## Troubleshooting + +The admin health dashboard runs four checks per course. Each maps to something in this document: + +| Check | What it means when it fails | +|---|---| +| **Webhook settings** | No webhook secret stored — incoming webhooks cannot be verified. Step 4 and *Storing the credentials*. | +| **GitHub access token** | The course's personal access token is invalid or cannot see the organization. Independent of the App; used only for this check today. | +| **GitHub App installation** | The App credentials do not work, the App is not installed on the organization, it lacks `administration: write`, or the installation is limited to selected repositories. Everything above. | +| **CI callback token** | No token for evaluation results to be signed with. Unrelated to the App. | + +Specific failures: + +- **“Installation … was not granted 'administration: write'”** — edit the App's repository permissions, then + accept the new permissions on the organization's installation page. GitHub does **not** apply added + permissions until an owner approves them, so changing the App alone is not enough. +- **404 from `GET /orgs/{org}/installation`** — the App is registered but not installed on that organization, + or the course's organization name is wrong. +- **401 from the token endpoint** — wrong App id, or the private key was revoked or pasted incompletely. + Generate a new key and store it again. +- **“limited to selected repositories”** — change the installation's repository access to *All repositories*. +- **Student says the repository 404s** — they almost certainly have an unaccepted invitation. Point them at + `/my` on the portal. +- **`/ahk ok` answers "@you is not allowed to do that"**, even for an organization **owner** — the portal asks GitHub whether the commenting login is a member of the organization that owns the repository, and got "no". Usually this is a *visibility* problem rather than a membership one: an App installation is not itself an organization member, so without **Organization → Members: Read** GitHub answers as it would for any outsider and only confirms **public** memberships. Anyone whose membership is *Private* (GitHub's default) then reads as a non-member. + Grant the App **Members: Read** and have an owner **approve** the added permission on the installation page. To confirm the diagnosis in seconds without touching the App, set your own membership to Public at `https://github.com/orgs//people` and try again. + Other causes, if that is not it: the person really is an **outside collaborator** rather than a member (commenting on a pull request does not imply membership); or the repository lives under a *user* account rather than an organization, in which case grading can never be authorized. + ⚠️ The answer is cached for **one hour** per org+login. After any of these fixes, restart the application to flush it rather than waiting. +- **Every delivery is `405` with `Allow: GET, HEAD`** — the webhook URL points at the portal but not at + `/api/integrations/github`. The request fell through to the Angular single-page app's fallback route, which + only answers GET. Check the path, including that it has no trailing slash. +- **Deliveries are `200` but nothing happens** — read the response body. `no ahk-monitor.yml or disabled` means + the repository is not opted in; mind the 12-hour cache noted above. +- **No deliveries at all** — the App's installation is limited to *selected repositories*, so a repository + created after the installation falls outside it. Or the event is not one of the five subscribed. + +## Cutover per course + +⚠️ **A GitHub App has exactly one webhook URL, so for a single course this is a flip, not a parallel run.** +Courses migrate independently — course A can stay on `github-monitor` while course B is on the portal — but one +course cannot be served by both at once. Doing that would need a second App registration or an +organization-level webhook. + +1. Store the App id, private key, webhook secret, `WorkflowRunThreshold` and **Enabled** under + **Site administration → Courses → *course* → GitHub integration**. +2. Check all four health checks are green on **Site administration → Health**. +3. Make sure the course has a non-revoked CI callback token (mint one, or import the legacy one — the + `Ahk.Web.Import` tool already imports tokens). +4. Import the course's historical Cosmos data, if that has not been done. +5. **Flip** the App's webhook URL to `https://ahk.aut.bme.hu/api/integrations/github` and trim the subscribed + events to the five listed above. Use the **Redeliver** button on a recent delivery to test without waiting + for a student. +6. Smoke test on a scratch repository in the organization: push a branch (a branch event should appear under + the course's dashboard), open a pull request, and comment `/ahk ok 5` as an organization member — the pull + request should be approved and merged, and a grade should appear. +7. Update the course's **template** repositories' evaluator workflow: `AHK_APPURL`, `AHK_APPTOKEN` and + `AHK_APPSECRET` (see [ci-callback.md](ci-callback.md)). +8. ⚠️ **Student repositories already generated keep the old workflow** and go on posting results to + `grade-management`. Either leave that deployment running until the cohort finishes, or push a workflow + update across the existing student repositories. Decide this *before* step 7 and record the decision. +9. Leave `github-monitor` and `grade-management` deployed until every course has migrated and the semester has + closed. diff --git a/ahk-frontend/.editorconfig b/ahk-frontend/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/ahk-frontend/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/ahk-frontend/.gitignore b/ahk-frontend/.gitignore new file mode 100644 index 0000000..854acd5 --- /dev/null +++ b/ahk-frontend/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/ahk-frontend/.prettierrc b/ahk-frontend/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/ahk-frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/ahk-frontend/.vscode/extensions.json b/ahk-frontend/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/ahk-frontend/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/ahk-frontend/.vscode/launch.json b/ahk-frontend/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/ahk-frontend/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/ahk-frontend/.vscode/mcp.json b/ahk-frontend/.vscode/mcp.json new file mode 100644 index 0000000..956af8c --- /dev/null +++ b/ahk-frontend/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + // For more information, visit: https://angular.dev/ai/mcp + "servers": { + "angular-cli": { + "command": "npx", + "args": ["-y", "@angular/cli", "mcp"] + } + } +} diff --git a/ahk-frontend/.vscode/tasks.json b/ahk-frontend/.vscode/tasks.json new file mode 100644 index 0000000..244306f --- /dev/null +++ b/ahk-frontend/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + } + ] +} diff --git a/ahk-frontend/README.md b/ahk-frontend/README.md new file mode 100644 index 0000000..83b8f12 --- /dev/null +++ b/ahk-frontend/README.md @@ -0,0 +1,81 @@ +# AHK Frontend + +Angular 21 SPA (teacher portal) for `ahk.aut.bme.hu`, talking to **ahk-backend**. Standalone +components + signals. Cookie-based auth; in dev the SPA and API are made same-origin by a proxy so +**no CORS** is needed. + +> Screens today: sign-in (local + BME SSO), the site administration console (courses, per-course +> GitHub integration, CI callback tokens, staff, users, health), and the course submissions dashboard. + +## Structure + +- `src/app/api/` — **NSwag-generated** TypeScript clients + DTOs (do not edit by hand; run + `npm run generate-api`). +- `src/app/core/auth/` — `AuthService` (session signals), functional guards, HTTP interceptor. +- `src/app/core/course/` — active-course context + course membership guard. +- `src/app/layout/shell/` — authenticated app frame: topbar with the course switcher, and a rail whose + contents follow the context (course screens vs. site administration). +- `src/app/shared/health-chain/` — a course's health drawn as the pipeline it describes. +- `src/app/features/` — `login/`, `no-access/`, `course/dashboard/`, and `admin/` (`courses/` with the + course editor, `users/`, `health/`). +- `src/styles.scss` — the design system, in the **BME AUT visual identity** (crimson Georgia headings, + Verdana body, parchment table headers). Component stylesheets are Angular-scoped, so tokens and the + shared classes (`page`, `card`, `field`, `btn`, `table.data`, `badge`, `notice`) all live here. Reach + for those before writing new component CSS. `--brand`/`--link`/`--bad` are three different reds on + purpose (see the comments in the file). +- `public/` — static assets served at `/`, including the department logos (`bme-aut-logo*.png`) and the + `eduid-logo.png` on the login button. These are derived copies; the masters and the palette's + provenance are in `brand/` (BME AUT identity pack + eduID logo), alongside this README. + +## Run (dev, HTTPS) + +```bash +npm install +npm start # ng serve --ssl on https://localhost:4200, proxying /api → https://localhost:7443 +``` + +Start **ahk-backend** first (https://localhost:7443). Log in with a seeded account, e.g. +`admin` / `Admin123!` or `instructor` / `Instructor123!`. + +A single run: `npx ng test --watch=false`. + +## Signing in + +**eduID leads.** The login screen's primary action is the eduID button (a full-page navigation to +`/api/auth/external/challenge`); the local username/password form is collapsed behind an "I don't have +an eduID account" link and only appears when asked for. The button follows the +[eduID brand](https://eduid.hu/hu/depo/) but is rendered from the eduID logo plus an English "Login" +rather than shipping their Hungarian-label PNG. Its `--eduid` blue is scoped to `login.scss` and is +deliberately absent from the global palette. + +## Who sees what + +- **Site admins** land on `/admin/courses` and can open *any* course's instructor screens — the API + lists every course in `GET /api/auth/me` for them, so the course switcher and `courseGuard` need no + special case. Courses reached that way are marked in the rail, since the admin is not assigned staff. +- **Instructors** land on their first course. A signed-in user with no course assignment lands on + `/no-access`, which says who to ask, rather than being bounced back to the login form. + +## Regenerate the API client + +```bash +# ahk-backend must be running (its OpenAPI doc is the source) +npm run generate-api # NSwag reads https://localhost:7443/swagger/v1/swagger.json → src/app/api/api-client.ts +``` + +Note: `nswag.json` uses `"runtime": "Net100"` to match the installed .NET 10 SDK. + +## Build / test + +```bash +npm run build +npm test +``` + +## Dev proxy & auth notes + +- `proxy.conf.js` forwards `/api/*` to the backend over HTTPS (`secure: false` for the self-signed + dev cert). `API_BASE_URL` is provided as `''` so the generated clients issue **relative** requests + (same-origin → cookie flows, no CORS). +- OIDC login is a full-page navigation to `/api/auth/external/challenge` (proxied to the backend), + which runs the standard redirect flow and issues the Identity cookie. diff --git a/ahk-frontend/angular.json b/ahk-frontend/angular.json new file mode 100644 index 0000000..af5f676 --- /dev/null +++ b/ahk-frontend/angular.json @@ -0,0 +1,83 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "ahk-frontend": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.scss" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "ssl": true, + "port": 4200, + "proxyConfig": "proxy.conf.js" + }, + "configurations": { + "production": { + "buildTarget": "ahk-frontend:build:production" + }, + "development": { + "buildTarget": "ahk-frontend:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + } + } +} diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.eps b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.eps new file mode 100644 index 0000000..063651f Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.png b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.png new file mode 100644 index 0000000..eb1a4aa Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_BLACK-eng.png differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR-eng.png b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR-eng.png new file mode 100644 index 0000000..4920b4b Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR-eng.png differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_CMYK-eng.eps b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_CMYK-eng.eps new file mode 100644 index 0000000..1741be8 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_CMYK-eng.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_Pantone202-eng.eps b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_Pantone202-eng.eps new file mode 100644 index 0000000..5f6ba0b Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_COLOR_Pantone202-eng.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.eps b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.eps new file mode 100644 index 0000000..ce9928a Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.png b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.png new file mode 100644 index 0000000..21e62ab Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo english/BME_AUT_logo_WHITE-eng.png differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.eps b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.eps new file mode 100644 index 0000000..7da53ee Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.png b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.png new file mode 100644 index 0000000..f6d91be Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_BLACK-hun.png differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR-hun.png b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR-hun.png new file mode 100644 index 0000000..2e71003 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR-hun.png differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_CMYK-hun.eps b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_CMYK-hun.eps new file mode 100644 index 0000000..86fea0f Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_CMYK-hun.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_Pantone202-hun.eps b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_Pantone202-hun.eps new file mode 100644 index 0000000..7ce0674 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_COLOR_Pantone202-hun.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.eps b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.eps new file mode 100644 index 0000000..f28a20e Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.eps differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.png b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.png new file mode 100644 index 0000000..60cb240 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/BME_AUT_logo_WHITE-hun.png differ diff --git a/ahk-frontend/brand/BME-AUT logo hungarian/TeamsLogo.png b/ahk-frontend/brand/BME-AUT logo hungarian/TeamsLogo.png new file mode 100644 index 0000000..8442c41 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo hungarian/TeamsLogo.png differ diff --git a/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_BLACK-hun.png b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_BLACK-hun.png new file mode 100644 index 0000000..15b15e3 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_BLACK-hun.png differ diff --git a/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_COLOR-hun.png b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_COLOR-hun.png new file mode 100644 index 0000000..0571895 Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_COLOR-hun.png differ diff --git a/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_WHITE-hun.png b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_WHITE-hun.png new file mode 100644 index 0000000..6c3fedf Binary files /dev/null and b/ahk-frontend/brand/BME-AUT logo only/BME_AUT_logo_only_WHITE-hun.png differ diff --git a/ahk-frontend/brand/README.md b/ahk-frontend/brand/README.md new file mode 100644 index 0000000..d7879a1 --- /dev/null +++ b/ahk-frontend/brand/README.md @@ -0,0 +1,60 @@ +# BME AUT brand assets + +The source assets behind the portal's look: the official **BME AUT** identity pack, and the **eduID** +logo used on the login button. `ahk-frontend` derives its palette and its rendered images from here. + +This folder holds the design masters, which are not shipped. The web-ready copies the app actually +loads live in `../public/` (see the table at the end). + +## What is here + +| Folder | Contents | +|---|---| +| `BME-AUT logo english/` | Full wordmark, "Department of Automation and Applied Informatics" | +| `BME-AUT logo hungarian/` | Full wordmark in Hungarian, plus `TeamsLogo.png` | +| `BME-AUT logo only/` | The `BME /AUT` mark with no wordmark — language-neutral despite the `-hun` suffix | +| `eduID/` | The eduID (Hungarian federation) colour logo, rectangular and square | + +Each wordmark comes in `COLOR`, `BLACK` and `WHITE`, as **PNG** (3645×690, for screen) and **EPS** +(vector print masters, ~480 KB each). The EPS files are the only scalable and re-colourable form — +keep them. `COLOR_CMYK` is for process printing, `COLOR_Pantone202` for spot. + +## Colours + +| Value | What it is | Where it came from | +|---|---|---| +| `#900028` | The logo's own crimson | Exact pixel value sampled from `BME_AUT_logo_COLOR-eng.png` | +| `PANTONE 202 CVC`
CMYK `0 / 1 / 0.6510 / 0.4706` | Print specification of that crimson | `%%CMYKCustomColor` header inside `BME_AUT_logo_COLOR_Pantone202-*.eps` | +| `#a4001e` | Heading + accent crimson used on screen | `h1..h6 { color:#a4001e }` in aut.bme.hu's stylesheet | +| `#88000f` | Active/hover navigation marker | `#mainNavBar a:hover { border-bottom:3px solid #88000f }`, same source | +| `#801b1b` / `#ffcfcf` / `#e5a3a3` | Error text / wash / rule | `.errorBox`, `.critical`, same source | +| `#074371` | Body link navy — **not** crimson | `a[href] { color:#074371 }`, same source | +| `#dbd9c0` | Table-header parchment | `.gridViewHeader`, same source | + +Type on aut.bme.hu: `Georgia, "Times New Roman", Serif` at **normal weight** for every heading, +`Verdana, Arial, Helvetica, sans-serif` for body. + +## eduID + +The login button follows the [eduID brand](https://eduid.hu/hu/depo/). The official button bakes in a +Hungarian label ("Belépés"), so instead of shipping that PNG the login screen renders its own button +from the eduID colour logo plus an English "Login" — the deviation the brand guide permits. Sampled +brand blues: logo `#4070B8` / navy `#203954` / light `#66AADF`; login-button azure `#0068AD`. + +## Using the logo on the web + +`ahk-frontend/public/` holds **derived copies**, not the originals — edit them here and re-copy, do +not edit them there: + +| `ahk-frontend/public/` | Copied from | Used by | +|---|---|---| +| `bme-aut-logo-white.png` | `BME-AUT logo english/BME_AUT_logo_WHITE-eng.png` | Shell topbar (dark band) | +| `bme-aut-logo.png` | `BME-AUT logo english/BME_AUT_logo_COLOR-eng.png` | Login screen (light background) | +| `bme-aut-mark.png` | `BME-AUT logo only/BME_AUT_logo_only_COLOR-hun.png` | Reserved — wordless mark | +| `eduid-logo.png` | `eduID/eduid_logo_color_rect.png` | eduID login button | + +The BME AUT PNGs are 5.28:1, so give them an explicit `height` and `width: auto`, and `alt` text +naming the department. The English wordmark is used because the portal's interface text is English. + +**Known gap**: there is no square favicon. The wordless mark is 2.86:1 and becomes an unreadable +strip at 32px. Producing one needs a square crop from the EPS in a vector editor. diff --git a/ahk-frontend/brand/eduID/eduid_logo_color_rect.png b/ahk-frontend/brand/eduID/eduid_logo_color_rect.png new file mode 100644 index 0000000..204648a Binary files /dev/null and b/ahk-frontend/brand/eduID/eduid_logo_color_rect.png differ diff --git a/ahk-frontend/brand/eduID/eduid_logo_color_square.png b/ahk-frontend/brand/eduID/eduid_logo_color_square.png new file mode 100644 index 0000000..9554c31 Binary files /dev/null and b/ahk-frontend/brand/eduID/eduid_logo_color_square.png differ diff --git a/ahk-frontend/nswag.json b/ahk-frontend/nswag.json new file mode 100644 index 0000000..5bd8f42 --- /dev/null +++ b/ahk-frontend/nswag.json @@ -0,0 +1,33 @@ +{ + "runtime": "Net100", + "defaultVariables": null, + "documentGenerator": { + "fromDocument": { + "url": "https://localhost:7443/swagger/v1/swagger.json" + } + }, + "codeGenerators": { + "openApiToTypeScriptClient": { + "className": "{controller}Client", + "moduleName": "", + "namespace": "", + "typeScriptVersion": 5.2, + "template": "Angular", + "httpClass": "HttpClient", + "withCredentials": true, + "useSingletonProvider": true, + "injectionTokenType": "InjectionToken", + "rxJsVersion": 7.0, + "dateTimeType": "Date", + "generateClientClasses": true, + "generateClientInterfaces": true, + "generateDtoTypes": true, + "operationGenerationMode": "MultipleClientsFromFirstTagAndOperationName", + "markOptionalProperties": true, + "generateOptionalParameters": true, + "typeStyle": "Interface", + "enumStyle": "StringLiteral", + "output": "src/app/api/api-client.ts" + } + } +} diff --git a/ahk-frontend/package-lock.json b/ahk-frontend/package-lock.json new file mode 100644 index 0000000..089a52e --- /dev/null +++ b/ahk-frontend/package-lock.json @@ -0,0 +1,8586 @@ +{ + "name": "ahk-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ahk-frontend", + "version": "0.0.0", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^21.2.8", + "@angular/cli": "^21.2.8", + "@angular/compiler-cli": "^21.2.0", + "jsdom": "^28.0.0", + "nswag": "^14.7.1", + "prettier": "^3.8.1", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@algolia/abtesting": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", + "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", + "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", + "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", + "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", + "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", + "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", + "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", + "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", + "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", + "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", + "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", + "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", + "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", + "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2102.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.19.tgz", + "integrity": "sha512-cj4tzUMiloLTg5rNf17E8MsvIxCWYoBiBsaj7ns6dgXqT9XCeG+J0TA2t1M+N9uuqfeLd22U/rYoCkADmcircQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.19", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.19.tgz", + "integrity": "sha512-dtpJMQBz5nhkcIogPmXP/aT2Ak8m/wLRPOSTI/g4vSJSuGiI53PgtWq4/wfQga6E6wdM2XWsblAE89d8w5heQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.19.tgz", + "integrity": "sha512-AG3Fzh9wJCmKBfxUQOUWaEHMj5Gq2O+Msf1z52aDSxbVhs5/iSQcXGPv/DLdAXu7d4xmQhLouNe9Glaq2omDyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.19", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.3.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/build": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.19.tgz", + "integrity": "sha512-emy9mqrTXAwhZzcvx8MaHyz+cUR06PVGxnqy91+bpDxPP9S5x67sPoOkY9y/ETFFhRpB5ULlUxyq0eN/pi6QOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2102.19", + "@babel/core": "7.29.7", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "5.1.21", + "@vitejs/plugin-basic-ssl": "2.1.4", + "beasties": "0.4.1", + "browserslist": "^4.26.0", + "esbuild": "0.28.1", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "magic-string": "0.30.21", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.0", + "picomatch": "4.0.4", + "piscina": "5.2.0", + "rolldown": "1.0.0-rc.4", + "sass": "1.97.3", + "semver": "7.7.4", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.15", + "undici": "7.28.0", + "vite": "7.3.6", + "watchpack": "2.5.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.1" + }, + "peerDependencies": { + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/localize": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-server": "^21.0.0", + "@angular/service-worker": "^21.0.0", + "@angular/ssr": "^21.2.19", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^21.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.9 <6.0", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/cli": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.19.tgz", + "integrity": "sha512-i78NzvoNonAY17QgzSmqrYnXHmEfraLv4wZ/o/m3efxuz61ZJ+5X/PsCeAhbwBvQfRrPRQaJV2tK9vGjHa+U6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2102.19", + "@angular-devkit/core": "21.2.19", + "@angular-devkit/schematics": "21.2.19", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", + "@modelcontextprotocol/sdk": "1.26.0", + "@schematics/angular": "21.2.19", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.5.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.3.6" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.18.tgz", + "integrity": "sha512-gZugZ8gX/KkACIZ3/ekoImLu5z8a0Iu9O5b84kh8e+++VUjmkmd19IygBsq/8/fF3vKf0UcIKcx3P6A/gb2DJw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.18", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.18.tgz", + "integrity": "sha512-ccnDuKLuzIa0ayijR+alarsHNWIksuGV/lxGTZ0t6/0+B6J/RXupz6M2IO6ZHHEcg8r7pcrLCUBTyp3FoiRJrg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.18.tgz", + "integrity": "sha512-L5zbIp7YfTTB4I4xT33FgEBanzhppNejzZX0HJOEqKDyDL2jXq+flt83lE0XVuRJ6/zrG+UKj0B+y/CK6Ro7Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.29.7", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.18", + "typescript": ">=5.9 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/core": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.18.tgz", + "integrity": "sha512-AG4bb6GU0+qp+vVBjc/IhSPjgcRX8QsCb8jzN8dDyhnjsyuuG/LlIiU0l6n65BI9SGKE9m6TfsJ1ObkkAiE5KQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.18", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.18.tgz", + "integrity": "sha512-TrRuiNjIzrrNtQgpJVH5gultQrvBlawa+tTzIpxBfIqLpkHrTPZLtYu118EUk6jhWzgRhKYUorInjJe+sSxC+w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.18", + "@angular/core": "21.2.18", + "@angular/platform-browser": "21.2.18", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.18.tgz", + "integrity": "sha512-zltF+3HrlgtZbYg8U98LhG0dyGm1aHT3Px8//9wjqi/TUY8UlHsYKByL197QtVWDBjf/dekzXdz5c+iyVtanLQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/animations": "21.2.18", + "@angular/common": "21.2.18", + "@angular/core": "21.2.18" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "21.2.18", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.18.tgz", + "integrity": "sha512-Xix19uG1YthC8IslhWKSfPdhscVWREBGVJZW2OnRmLef8F7DvhF49S6vOywaBo+Uahe0egkmgWDNpEwxAzsgLw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.2.18", + "@angular/core": "21.2.18", + "@angular/platform-browser": "21.2.18", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", + "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^3.0.8" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8", + "listr2": "9.0.5" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", + "integrity": "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.1.tgz", + "integrity": "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.1.tgz", + "integrity": "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.1.tgz", + "integrity": "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.1.tgz", + "integrity": "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.1.tgz", + "integrity": "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.1.tgz", + "integrity": "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.113.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", + "integrity": "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.4.tgz", + "integrity": "sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.4.tgz", + "integrity": "sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.4.tgz", + "integrity": "sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.4.tgz", + "integrity": "sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.4.tgz", + "integrity": "sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.4.tgz", + "integrity": "sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.4.tgz", + "integrity": "sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz", + "integrity": "sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "21.2.19", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.19.tgz", + "integrity": "sha512-eL+UU9eizoadhDB4YEctRmmo0A5iwrSmGzeuEa6akrq8nLGVWM8zO91HTJutkPqGQjelF+UOiOShsQSZAU9SIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.19", + "@angular-devkit/schematics": "21.2.19", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", + "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/algoliasearch": { + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", + "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.14.1", + "@algolia/client-abtesting": "5.48.1", + "@algolia/client-analytics": "5.48.1", + "@algolia/client-common": "5.48.1", + "@algolia/client-insights": "5.48.1", + "@algolia/client-personalization": "5.48.1", + "@algolia/client-query-suggestions": "5.48.1", + "@algolia/client-search": "5.48.1", + "@algolia/ingestion": "1.48.1", + "@algolia/monitoring": "1.48.1", + "@algolia/recommend": "5.48.1", + "@algolia/requester-browser-xhr": "5.48.1", + "@algolia/requester-fetch": "5.48.1", + "@algolia/requester-node-http": "5.48.1" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", + "integrity": "sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lmdb": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", + "integrity": "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.1", + "@lmdb/lmdb-darwin-x64": "3.5.1", + "@lmdb/lmdb-linux-arm": "3.5.1", + "@lmdb/lmdb-linux-arm64": "3.5.1", + "@lmdb/lmdb-linux-x64": "3.5.1", + "@lmdb/lmdb-win32-arm64": "3.5.1", + "@lmdb/lmdb-win32-x64": "3.5.1" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nswag": { + "version": "14.7.1", + "resolved": "https://registry.npmjs.org/nswag/-/nswag-14.7.1.tgz", + "integrity": "sha512-V6LiNhRLY4EfaEWAvExAPSPHGUaVIuneA+a67zuhu00fcwR+7xxiBBTpyw82vuI4xMUh1Eq8Nwnc6iLqp/74+g==", + "dev": true, + "license": "MIT", + "bin": { + "nswag": "bin/nswag.js" + }, + "engines": { + "npm": ">=3.10.8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/p-map": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", + "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.4.tgz", + "integrity": "sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.113.0", + "@rolldown/pluginutils": "1.0.0-rc.4" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.4", + "@rolldown/binding-darwin-x64": "1.0.0-rc.4", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.4", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.4", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.4", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.4", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.4", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.4" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/ahk-frontend/package.json b/ahk-frontend/package.json new file mode 100644 index 0000000..39dda26 --- /dev/null +++ b/ahk-frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "ahk-frontend", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "generate-api": "nswag run nswag.json" + }, + "private": true, + "packageManager": "npm@10.9.8", + "dependencies": { + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/forms": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@angular/router": "^21.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^21.2.8", + "@angular/cli": "^21.2.8", + "@angular/compiler-cli": "^21.2.0", + "jsdom": "^28.0.0", + "nswag": "^14.7.1", + "prettier": "^3.8.1", + "typescript": "~5.9.2", + "vitest": "^4.0.8" + } +} diff --git a/ahk-frontend/proxy.conf.js b/ahk-frontend/proxy.conf.js new file mode 100644 index 0000000..2da37f8 --- /dev/null +++ b/ahk-frontend/proxy.conf.js @@ -0,0 +1,20 @@ +// Dev proxy: makes the SPA and API same-origin so the Identity cookie flows and no CORS config is needed. +// `secure: false` accepts the backend's self-signed dev certificate. +// +// The OIDC paths must be proxied too, and they are NOT under /api: +// /signin-oidc, /signout-callback-oidc — the provider redirects the browser here, and the resulting +// Identity cookie has to be set on the :4200 origin, so it must arrive through the proxy. +// /mock-oidc — the development stand-in for the BME IdP served by the backend (MockOidc/). +// +// Because `changeOrigin: true` rewrites the Host header to the backend, the backend cannot infer the +// browser's origin; the redirect_uri is therefore pinned explicitly via Authentication:Oidc:RedirectUri. +const target = 'https://localhost:7443'; + +module.exports = [ + { + context: ['/api', '/signin-oidc', '/signout-callback-oidc', '/mock-oidc'], + target, + secure: false, + changeOrigin: true, + }, +]; diff --git a/ahk-frontend/public/bme-aut-logo-white.png b/ahk-frontend/public/bme-aut-logo-white.png new file mode 100644 index 0000000..21e62ab Binary files /dev/null and b/ahk-frontend/public/bme-aut-logo-white.png differ diff --git a/ahk-frontend/public/bme-aut-logo.png b/ahk-frontend/public/bme-aut-logo.png new file mode 100644 index 0000000..4920b4b Binary files /dev/null and b/ahk-frontend/public/bme-aut-logo.png differ diff --git a/ahk-frontend/public/bme-aut-mark.png b/ahk-frontend/public/bme-aut-mark.png new file mode 100644 index 0000000..0571895 Binary files /dev/null and b/ahk-frontend/public/bme-aut-mark.png differ diff --git a/ahk-frontend/public/eduid-logo.png b/ahk-frontend/public/eduid-logo.png new file mode 100644 index 0000000..204648a Binary files /dev/null and b/ahk-frontend/public/eduid-logo.png differ diff --git a/ahk-frontend/public/favicon.ico b/ahk-frontend/public/favicon.ico new file mode 100644 index 0000000..57614f9 Binary files /dev/null and b/ahk-frontend/public/favicon.ico differ diff --git a/ahk-frontend/src/app/api/api-client.ts b/ahk-frontend/src/app/api/api-client.ts new file mode 100644 index 0000000..9693c19 --- /dev/null +++ b/ahk-frontend/src/app/api/api-client.ts @@ -0,0 +1,3484 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +/* eslint-disable */ +// ReSharper disable InconsistentNaming + +import { mergeMap as _observableMergeMap, catchError as _observableCatch } from 'rxjs/operators'; +import { Observable, throwError as _observableThrow, of as _observableOf } from 'rxjs'; +import { Injectable, Inject, Optional, InjectionToken } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpResponse, HttpResponseBase } from '@angular/common/http'; + +export const API_BASE_URL = new InjectionToken('API_BASE_URL'); + +export interface IMyAssignmentsClient { + list(): Observable; + resendInvitation(id: number): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class MyAssignmentsClient implements IMyAssignmentsClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(): Observable { + let url_ = this.baseUrl + "/api/my/assignments"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as StudentRepository[]; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + resendInvitation(id: number): Observable { + let url_ = this.baseUrl + "/api/my/assignments/{id}/resend-invitation"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processResendInvitation(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processResendInvitation(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processResendInvitation(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as StudentRepository; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status === 502) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("A server side error occurred.", status, _responseText, _headers); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IAssignmentInviteClient { + get(token: string, course: string): Observable; + accept(token: string, course: string): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class AssignmentInviteClient implements IAssignmentInviteClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + get(token: string, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/invite/{token}"; + if (token === undefined || token === null) + throw new globalThis.Error("The parameter 'token' must be defined."); + url_ = url_.replace("{token}", encodeURIComponent("" + token)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGet(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGet(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGet(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as InviteState; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + accept(token: string, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/invite/{token}/accept"; + if (token === undefined || token === null) + throw new globalThis.Error("The parameter 'token' must be defined."); + url_ = url_.replace("{token}", encodeURIComponent("" + token)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processAccept(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processAccept(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processAccept(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as InviteState; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status === 502) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("A server side error occurred.", status, _responseText, _headers); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IAssignmentsClient { + list(course: string, includeArchived?: boolean | undefined): Observable; + create(request: SaveAssignmentRequest, course: string): Observable; + get(id: number, course: string, checkTemplate?: boolean | undefined): Observable; + update(id: number, request: SaveAssignmentRequest, course: string): Observable; + delete(id: number, course: string): Observable; + checkTemplate(request: CheckTemplateRequest, course: string): Observable; + archive(id: number, course: string): Observable; + unarchive(id: number, course: string): Observable; + regenerateInvite(id: number, course: string): Observable; + listAcceptances(id: number, course: string): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class AssignmentsClient implements IAssignmentsClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(course: string, includeArchived?: boolean | undefined): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments?"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + if (includeArchived === null) + throw new globalThis.Error("The parameter 'includeArchived' cannot be null."); + else if (includeArchived !== undefined) + url_ += "includeArchived=" + encodeURIComponent("" + includeArchived) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + create(request: SaveAssignmentRequest, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCreate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCreate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCreate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 201) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result201: any = null; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto; + return _observableOf(result201); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + get(id: number, course: string, checkTemplate?: boolean | undefined): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}?"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + if (checkTemplate === null) + throw new globalThis.Error("The parameter 'checkTemplate' cannot be null."); + else if (checkTemplate !== undefined) + url_ += "checkTemplate=" + encodeURIComponent("" + checkTemplate) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGet(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGet(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGet(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDetailDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + update(id: number, request: SaveAssignmentRequest, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpdate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpdate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpdate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto; + return _observableOf(result200); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + delete(id: number, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processDelete(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processDelete(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processDelete(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status === 409) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result409: any = null; + result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result409); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + checkTemplate(request: CheckTemplateRequest, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/check-template"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCheckTemplate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCheckTemplate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCheckTemplate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as TemplateCheckDto; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + archive(id: number, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}/archive"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processArchive(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processArchive(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processArchive(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + unarchive(id: number, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}/unarchive"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUnarchive(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUnarchive(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUnarchive(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + regenerateInvite(id: number, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}/regenerate-invite"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processRegenerateInvite(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processRegenerateInvite(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processRegenerateInvite(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + listAcceptances(id: number, course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/assignments/{id}/acceptances"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processListAcceptances(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processListAcceptances(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processListAcceptances(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AssignmentAcceptanceDto[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IGradesClient { + list(course: string): Observable; + exportCsv(course: string): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class GradesClient implements IGradesClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/grades"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as FinalStudentGrade[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + exportCsv(course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/grades/csv"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processExportCsv(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processExportCsv(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processExportCsv(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as string; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface ISubmissionStatusesClient { + list(course: string): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class SubmissionStatusesClient implements ISubmissionStatusesClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(course: string): Observable { + let url_ = this.baseUrl + "/api/{course}/statuses"; + if (course === undefined || course === null) + throw new globalThis.Error("The parameter 'course' must be defined."); + url_ = url_.replace("{course}", encodeURIComponent("" + course)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as RepositoryStatus[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IAuthClient { + login(request: LoginRequest): Observable; + logout(): Observable; + register(request: RegisterRequest): Observable; + me(): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class AuthClient implements IAuthClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + login(request: LoginRequest): Observable { + let url_ = this.baseUrl + "/api/auth/login"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processLogin(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processLogin(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processLogin(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CurrentUserResponse; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as LoginFailureResponse; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + logout(): Observable { + let url_ = this.baseUrl + "/api/auth/logout"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processLogout(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processLogout(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processLogout(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as LogoutResponse; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + register(request: RegisterRequest): Observable { + let url_ = this.baseUrl + "/api/auth/register"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processRegister(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processRegister(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processRegister(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CurrentUserResponse; + return _observableOf(result200); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + me(): Observable { + let url_ = this.baseUrl + "/api/auth/me"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processMe(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processMe(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processMe(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CurrentUserResponse; + return _observableOf(result200); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IExternalAuthClient { + challenge(returnUrl?: string | null | undefined): Observable; + callback(returnUrl?: string | null | undefined): Observable; + signedOut(returnUrl?: string | null | undefined): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class ExternalAuthClient implements IExternalAuthClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + challenge(returnUrl?: string | null | undefined): Observable { + let url_ = this.baseUrl + "/api/auth/external/challenge?"; + if (returnUrl !== undefined && returnUrl !== null) + url_ += "returnUrl=" + encodeURIComponent("" + returnUrl) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/octet-stream" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processChallenge(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processChallenge(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processChallenge(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return _observableOf({ fileName: fileName, data: responseBlob as any, status: status, headers: _headers }); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + callback(returnUrl?: string | null | undefined): Observable { + let url_ = this.baseUrl + "/api/auth/external/callback?"; + if (returnUrl !== undefined && returnUrl !== null) + url_ += "returnUrl=" + encodeURIComponent("" + returnUrl) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/octet-stream" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCallback(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCallback(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCallback(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return _observableOf({ fileName: fileName, data: responseBlob as any, status: status, headers: _headers }); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + signedOut(returnUrl?: string | null | undefined): Observable { + let url_ = this.baseUrl + "/signout-callback-oidc?"; + if (returnUrl !== undefined && returnUrl !== null) + url_ += "returnUrl=" + encodeURIComponent("" + returnUrl) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/octet-stream" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processSignedOut(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processSignedOut(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processSignedOut(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return _observableOf({ fileName: fileName, data: responseBlob as any, status: status, headers: _headers }); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IProfileClient { + setGitHubUsername(request: SetGitHubUsernameRequest): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class ProfileClient implements IProfileClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + setGitHubUsername(request: SetGitHubUsernameRequest): Observable { + let url_ = this.baseUrl + "/api/profile/github"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processSetGitHubUsername(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processSetGitHubUsername(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processSetGitHubUsername(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as GitHubProfileResponse; + return _observableOf(result200); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 401) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result401: any = null; + result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + })); + } else if (status === 502) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("A server side error occurred.", status, _responseText, _headers); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface ICourseHealthAdminClient { + checkAll(): Observable; + checkCourse(courseId: number): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class CourseHealthAdminClient implements ICourseHealthAdminClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + checkAll(): Observable { + let url_ = this.baseUrl + "/api/admin/health"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCheckAll(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCheckAll(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCheckAll(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseHealthReport[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + checkCourse(courseId: number): Observable { + let url_ = this.baseUrl + "/api/admin/health/{courseId}"; + if (courseId === undefined || courseId === null) + throw new globalThis.Error("The parameter 'courseId' must be defined."); + url_ = url_.replace("{courseId}", encodeURIComponent("" + courseId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCheckCourse(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCheckCourse(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCheckCourse(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseHealthReport; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface ICoursesAdminClient { + list(): Observable; + create(request: CreateCourseRequest): Observable; + get(id: number): Observable; + update(id: number, request: UpdateCourseRequest): Observable; + delete(id: number, confirmSlug?: string | null | undefined): Observable; + getGitHubConfig(id: number): Observable; + updateGitHubConfig(id: number, request: UpdateCourseGitHubConfigRequest): Observable; + listMembers(id: number): Observable; + upsertMember(id: number, request: UpsertCourseMemberRequest): Observable; + removeMember(id: number, userId: number): Observable; + listTokens(id: number): Observable; + createToken(id: number, request: CreateWebhookTokenRequest): Observable; + revokeToken(id: number, tokenId: number): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class CoursesAdminClient implements ICoursesAdminClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(): Observable { + let url_ = this.baseUrl + "/api/admin/courses"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseDto[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + create(request: CreateCourseRequest): Observable { + let url_ = this.baseUrl + "/api/admin/courses"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCreate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCreate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCreate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 201) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result201: any = null; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseDetailDto; + return _observableOf(result201); + })); + } else if (status === 409) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result409: any = null; + result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result409); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + get(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGet(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGet(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGet(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseDetailDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + update(id: number, request: UpdateCourseRequest): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpdate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpdate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpdate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status === 409) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result409: any = null; + result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result409); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + delete(id: number, confirmSlug?: string | null | undefined): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}?"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (confirmSlug !== undefined && confirmSlug !== null) + url_ += "confirmSlug=" + encodeURIComponent("" + confirmSlug) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processDelete(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processDelete(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processDelete(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + getGitHubConfig(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/github"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGetGitHubConfig(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGetGitHubConfig(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGetGitHubConfig(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseGitHubConfigDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + updateGitHubConfig(id: number, request: UpdateCourseGitHubConfigRequest): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/github"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpdateGitHubConfig(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpdateGitHubConfig(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpdateGitHubConfig(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseGitHubConfigDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + listMembers(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/members"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processListMembers(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processListMembers(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processListMembers(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CourseMemberDto[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + upsertMember(id: number, request: UpsertCourseMemberRequest): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/members"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpsertMember(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpsertMember(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpsertMember(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + removeMember(id: number, userId: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/members/{userId}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (userId === undefined || userId === null) + throw new globalThis.Error("The parameter 'userId' must be defined."); + url_ = url_.replace("{userId}", encodeURIComponent("" + userId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processRemoveMember(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processRemoveMember(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processRemoveMember(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + listTokens(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/tokens"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processListTokens(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processListTokens(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processListTokens(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as WebhookTokenDto[]; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + createToken(id: number, request: CreateWebhookTokenRequest): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/tokens"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCreateToken(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCreateToken(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCreateToken(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 201) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result201: any = null; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as WebhookTokenDto; + return _observableOf(result201); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + revokeToken(id: number, tokenId: number): Observable { + let url_ = this.baseUrl + "/api/admin/courses/{id}/tokens/{tokenId}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (tokenId === undefined || tokenId === null) + throw new globalThis.Error("The parameter 'tokenId' must be defined."); + url_ = url_.replace("{tokenId}", encodeURIComponent("" + tokenId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processRevokeToken(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processRevokeToken(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processRevokeToken(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface IUsersAdminClient { + list(search?: string | null | undefined, courseId?: number | null | undefined, skip?: number | undefined, take?: number | undefined): Observable; + create(request: CreateUserRequest): Observable; + get(id: number): Observable; + update(id: number, request: UpdateUserRequest): Observable; + delete(id: number): Observable; + updateRoles(id: number, request: UpdateUserRolesRequest): Observable; + upsertCourse(id: number, request: UpsertUserCourseRequest): Observable; + removeCourse(id: number, courseId: number): Observable; + setPassword(id: number, request: SetPasswordRequest): Observable; +} + +@Injectable({ + providedIn: 'root' +}) +export class UsersAdminClient implements IUsersAdminClient { + private http: HttpClient; + private baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(@Inject(HttpClient) http: HttpClient, @Optional() @Inject(API_BASE_URL) baseUrl?: string) { + this.http = http; + this.baseUrl = baseUrl ?? "https://localhost:7443"; + } + + list(search?: string | null | undefined, courseId?: number | null | undefined, skip?: number | undefined, take?: number | undefined): Observable { + let url_ = this.baseUrl + "/api/admin/users?"; + if (search !== undefined && search !== null) + url_ += "search=" + encodeURIComponent("" + search) + "&"; + if (courseId !== undefined && courseId !== null) + url_ += "courseId=" + encodeURIComponent("" + courseId) + "&"; + if (skip === null) + throw new globalThis.Error("The parameter 'skip' cannot be null."); + else if (skip !== undefined) + url_ += "skip=" + encodeURIComponent("" + skip) + "&"; + if (take === null) + throw new globalThis.Error("The parameter 'take' cannot be null."); + else if (take !== undefined) + url_ += "take=" + encodeURIComponent("" + take) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processList(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processList(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processList(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserListResponse; + return _observableOf(result200); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + create(request: CreateUserRequest): Observable { + let url_ = this.baseUrl + "/api/admin/users"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processCreate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processCreate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processCreate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 201) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result201: any = null; + result201 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result201); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + get(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("get", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processGet(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processGet(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processGet(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + update(id: number, request: UpdateUserRequest): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpdate(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpdate(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpdate(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result200); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + delete(id: number): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processDelete(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processDelete(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processDelete(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + updateRoles(id: number, request: UpdateUserRolesRequest): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}/roles"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpdateRoles(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpdateRoles(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpdateRoles(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result200); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + upsertCourse(id: number, request: UpsertUserCourseRequest): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}/courses"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + }; + + return this.http.request("put", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processUpsertCourse(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processUpsertCourse(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processUpsertCourse(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + removeCourse(id: number, courseId: number): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}/courses/{courseId}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + if (courseId === undefined || courseId === null) + throw new globalThis.Error("The parameter 'courseId' must be defined."); + url_ = url_.replace("{courseId}", encodeURIComponent("" + courseId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_ : any = { + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Accept": "application/json" + }) + }; + + return this.http.request("delete", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processRemoveCourse(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processRemoveCourse(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processRemoveCourse(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 200) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as UserDto; + return _observableOf(result200); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } + + setPassword(id: number, request: SetPasswordRequest): Observable { + let url_ = this.baseUrl + "/api/admin/users/{id}/password"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_ : any = { + body: content_, + observe: "response", + responseType: "blob", + withCredentials: true, + headers: new HttpHeaders({ + "Content-Type": "application/json", + }) + }; + + return this.http.request("post", url_, options_).pipe(_observableMergeMap((response_ : any) => { + return this.processSetPassword(response_); + })).pipe(_observableCatch((response_: any) => { + if (response_ instanceof HttpResponseBase) { + try { + return this.processSetPassword(response_ as any); + } catch (e) { + return _observableThrow(e) as any as Observable; + } + } else + return _observableThrow(response_) as any as Observable; + })); + } + + protected processSetPassword(response: HttpResponseBase): Observable { + const status = response.status; + const responseBlob = + response instanceof HttpResponse ? response.body : + (response as any).error instanceof Blob ? (response as any).error : undefined; + + let _headers: any = {}; if (response.headers) { for (let key of response.headers.keys()) { _headers[key] = response.headers.get(key); }} + if (status === 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return _observableOf(null as any); + })); + } else if (status === 400) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + })); + } else if (status === 404) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + let result404: any = null; + result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + })); + } else if (status !== 200 && status !== 204) { + return blobToText(responseBlob).pipe(_observableMergeMap((_responseText: string) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + })); + } + return _observableOf(null as any); + } +} + +export interface StudentRepository { + acceptanceId?: number; + courseSlug?: string; + courseName?: string; + assignmentName?: string; + gitHubRepoName?: string; + repoUrl?: string; + acceptedAt?: Date; + access?: RepositoryAccess; + invitationUrl?: string | undefined; + invitationSentAt?: Date | undefined; +} + +export type RepositoryAccess = "Active" | "InvitationPending" | "InvitationExpired" | "Unknown"; + +export interface ProblemDetails { + type?: string | undefined; + title?: string | undefined; + status?: number | undefined; + detail?: string | undefined; + instance?: string | undefined; + + [key: string]: any; +} + +export interface InviteState { + status?: InviteStatus; + courseName?: string; + courseSlug?: string; + assignmentName?: string; + assignmentDescription?: string | undefined; + organization?: string | undefined; + repositoryName?: string | undefined; + repoUrl?: string | undefined; + gitHubUsername?: string | undefined; + invitationUrl?: string | undefined; + message?: string | undefined; +} + +export type InviteStatus = "NotFound" | "Closed" | "NeedsNeptun" | "NeedsGitHubUsername" | "ReadyToAccept" | "Accepted" | "NotConfigured"; + +export interface AssignmentDto { + id?: number; + name?: string; + description?: string | undefined; + templateRepoName?: string; + repoNamePrefix?: string | undefined; + invitePath?: string; + isArchived?: boolean; + archivedAt?: Date | undefined; + createdAt?: Date; + acceptanceCount?: number; +} + +export interface AssignmentDetailDto { + assignment?: AssignmentDto; + template?: TemplateCheckDto | undefined; +} + +export interface TemplateCheckDto { + reachable?: boolean; + isTemplate?: boolean; + htmlUrl?: string | undefined; + problem?: string | undefined; +} + +export interface CheckTemplateRequest { + templateRepoName?: string | undefined; +} + +export interface SaveAssignmentRequest { + name?: string; + description?: string | undefined; + templateRepoName?: string; + repoNamePrefix?: string | undefined; +} + +export interface AssignmentAcceptanceDto { + id?: number; + userName?: string; + displayName?: string | undefined; + neptunCode?: string | undefined; + gitHubUsername?: string; + gitHubRepoName?: string; + repoUrl?: string; + acceptedAt?: Date; + invitationPending?: boolean; +} + +export interface FinalStudentGrade { + neptun?: string; + repo?: string; + prUrl?: string | undefined; + points?: { [key: string]: number; }; +} + +export interface RepositoryStatus { + repository?: string; + neptun?: string; + branches?: string[]; + pullRequests?: PullRequestStatus[]; + workflowRuns?: WorkflowRunsStatus; +} + +export interface PullRequestStatus { + number?: number; + htmlUrl?: string | undefined; + status?: string | undefined; + assignee?: string | undefined; +} + +export interface WorkflowRunsStatus { + count?: number; + lastStatus?: string | undefined; +} + +export interface CurrentUserResponse { + userId?: number; + userName?: string; + email?: string | undefined; + displayName?: string | undefined; + neptunCode?: string | undefined; + gitHubUsername?: string | undefined; + roles?: string[]; + courses?: CourseMembershipDto[]; +} + +export interface CourseMembershipDto { + slug?: string; + name?: string; + role?: string; + viaSiteAdmin?: boolean; +} + +export interface LoginFailureResponse { + reason?: string; + error?: string; +} + +export interface LoginRequest { + userName: string; + password: string; + rememberMe?: boolean; +} + +export interface LogoutResponse { + endSessionUrl?: string | undefined; +} + +export interface RegisterRequest { + userName: string; + email: string; + password: string; + displayName?: string | undefined; +} + +export interface GitHubProfileResponse { + gitHubUsername?: string; + gitHubUserId?: number | undefined; +} + +export interface SetGitHubUsernameRequest { + gitHubUsername?: string; + courseSlug?: string | undefined; +} + +export interface CourseHealthReport { + courseId?: number; + courseSlug?: string; + courseName?: string; + checkedAt?: Date; + checks?: HealthCheckResult[]; + status?: HealthStatus; +} + +export interface HealthCheckResult { + checkId?: string; + title?: string; + status?: HealthStatus; + message?: string; + remediation?: string | undefined; + durationMs?: number; +} + +export type HealthStatus = "NotConfigured" | "Healthy" | "Warning" | "Failed"; + +export interface CourseDto { + id?: number; + slug?: string; + name?: string; + gitHubOrganization?: string | undefined; + repoNamePrefix?: string | undefined; + createdAt?: Date; + integrationEnabled?: boolean; + memberCount?: number; + studentCount?: number; + submissionCount?: number; +} + +export interface CourseDetailDto { + id?: number; + slug?: string; + name?: string; + gitHubOrganization?: string | undefined; + repoNamePrefix?: string | undefined; + createdAt?: Date; + studentCount?: number; + submissionCount?: number; + gitHubConfig?: CourseGitHubConfigDto; + members?: CourseMemberDto[]; + webhookTokens?: WebhookTokenDto[]; +} + +export interface CourseGitHubConfigDto { + gitHubAppId?: string | undefined; + hasAppPrivateKey?: boolean; + hasAccessToken?: boolean; + accessTokenHint?: string | undefined; + hasWebhookSecret?: boolean; + workflowRunThreshold?: number; + enabled?: boolean; + updatedAt?: Date | undefined; +} + +export interface CourseMemberDto { + userId?: number; + userName?: string; + displayName?: string | undefined; + email?: string | undefined; + role?: CourseRole; +} + +export type CourseRole = "Instructor" | "Admin"; + +export interface WebhookTokenDto { + id?: number; + token?: string; + secret?: string | undefined; + description?: string | undefined; + createdAt?: Date; + revokedAt?: Date | undefined; +} + +export interface CreateCourseRequest { + slug: string; + name: string; + gitHubOrganization?: string | undefined; + repoNamePrefix?: string | undefined; +} + +export interface UpdateCourseRequest { + slug: string; + name: string; + gitHubOrganization?: string | undefined; + repoNamePrefix?: string | undefined; +} + +export interface UpdateCourseGitHubConfigRequest { + gitHubAppId?: string | undefined; + gitHubAppPrivateKey?: string | undefined; + gitHubAccessToken?: string | undefined; + gitHubWebhookSecret?: string | undefined; + workflowRunThreshold?: number; + enabled?: boolean; +} + +export interface UpsertCourseMemberRequest { + userId: number; + role?: CourseRole; +} + +export interface CreateWebhookTokenRequest { + description?: string | undefined; +} + +export interface UserListResponse { + items?: UserDto[]; + total?: number; +} + +export interface UserDto { + id?: number; + userName?: string; + email?: string | undefined; + displayName?: string | undefined; + neptunCode?: string | undefined; + affiliation?: string | undefined; + roles?: string[]; + courses?: UserCourseDto[]; + isExternal?: boolean; + isLockedOut?: boolean; +} + +export interface UserCourseDto { + courseId?: number; + slug?: string; + name?: string; + role?: CourseRole; +} + +export interface CreateUserRequest { + userName: string; + email?: string | undefined; + displayName?: string | undefined; + neptunCode?: string | undefined; + password: string; +} + +export interface UpdateUserRequest { + email?: string | undefined; + displayName?: string | undefined; + neptunCode?: string | undefined; +} + +export interface UpdateUserRolesRequest { + roles?: string[]; +} + +export interface UpsertUserCourseRequest { + courseId: number; + role?: CourseRole; +} + +export interface SetPasswordRequest { + newPassword: string; +} + +export interface FileResponse { + data: Blob; + status: number; + fileName?: string; + headers?: { [name: string]: any }; +} + +export class SwaggerException extends Error { + override message: string; + status: number; + response: string; + headers: { [key: string]: any; }; + result: any; + + constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) { + super(); + + this.message = message; + this.status = status; + this.response = response; + this.headers = headers; + this.result = result; + } + + protected isSwaggerException = true; + + static isSwaggerException(obj: any): obj is SwaggerException { + return obj.isSwaggerException === true; + } +} + +function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): Observable { + if (result !== null && result !== undefined) + return _observableThrow(result); + else + return _observableThrow(new SwaggerException(message, status, response, headers, null)); +} + +function blobToText(blob: any): Observable { + return new Observable((observer: any) => { + if (!blob) { + observer.next(""); + observer.complete(); + } else { + let reader = new FileReader(); + reader.onload = event => { + observer.next((event.target as any).result); + observer.complete(); + }; + reader.readAsText(blob); + } + }); +} \ No newline at end of file diff --git a/ahk-frontend/src/app/app.config.ts b/ahk-frontend/src/app/app.config.ts new file mode 100644 index 0000000..6537587 --- /dev/null +++ b/ahk-frontend/src/app/app.config.ts @@ -0,0 +1,18 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; + +import { routes } from './app.routes'; +import { API_BASE_URL } from './api/api-client'; +import { authInterceptor } from './core/auth/auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), + // Empty base URL => generated clients issue relative /api/... requests, served same-origin through the + // dev proxy (no CORS). In production the SPA is hosted under the same origin as the API. + { provide: API_BASE_URL, useValue: '' }, + ], +}; diff --git a/ahk-frontend/src/app/app.html b/ahk-frontend/src/app/app.html new file mode 100644 index 0000000..67e7bd4 --- /dev/null +++ b/ahk-frontend/src/app/app.html @@ -0,0 +1 @@ + diff --git a/ahk-frontend/src/app/app.routes.ts b/ahk-frontend/src/app/app.routes.ts new file mode 100644 index 0000000..993bcc8 --- /dev/null +++ b/ahk-frontend/src/app/app.routes.ts @@ -0,0 +1,78 @@ +import { Routes } from '@angular/router'; + +import { adminGuard, authGuard, rootRedirectGuard } from './core/auth/auth.guard'; +import { courseGuard } from './core/course/course.guard'; +import { Shell } from './layout/shell/shell'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => import('./features/login/login').then((m) => m.Login), + }, + { + // A student's own repositories, across every course, and where signing in lands anyone who staffs none. + // Its empty state explains what to do next, which is why there is no separate "no access" screen: a user + // with no courses is a student who has not accepted an assignment yet, not an error. + path: 'my', + canActivate: [authGuard], + loadComponent: () => import('./features/my/my-assignments').then((m) => m.MyAssignments), + }, + { + // Host/admin context (no course segment). + path: 'admin', + component: Shell, + canActivate: [authGuard, adminGuard], + children: [ + { + path: 'courses', + loadComponent: () => import('./features/admin/courses/courses').then((m) => m.AdminCourses), + }, + { + path: 'courses/:id', + loadComponent: () => import('./features/admin/courses/course-editor').then((m) => m.CourseEditor), + }, + { + path: 'users', + loadComponent: () => import('./features/admin/users/users').then((m) => m.AdminUsers), + }, + { + path: 'health', + loadComponent: () => import('./features/admin/health/health').then((m) => m.AdminHealth), + }, + { + path: 'help/github', + loadComponent: () => import('./features/admin/help/github-setup').then((m) => m.GitHubSetupHelp), + }, + { path: '', pathMatch: 'full', redirectTo: 'courses' }, + ], + }, + { + // The assignment invite link. Deliberately outside the course shell and guarded only by authGuard: + // students are members of no course, and accepting is how they first appear in one at all — courseGuard + // would bounce every one of them. Declared before ':course' so the match is unambiguous. + path: ':course/invite/:token', + canActivate: [authGuard], + loadComponent: () => import('./features/invite/invite').then((m) => m.Invite), + }, + { + // Course context: /{course}/... + path: ':course', + component: Shell, + canActivate: [authGuard, courseGuard], + children: [ + { + path: 'dashboard', + loadComponent: () => import('./features/course/dashboard/dashboard').then((m) => m.CourseDashboard), + }, + { + path: 'assignments', + loadComponent: () => import('./features/course/assignments/assignments').then((m) => m.CourseAssignments), + }, + { path: '', pathMatch: 'full', redirectTo: 'dashboard' }, + ], + }, + // "/" resolves per user rather than always going to the login form: the OIDC callback lands here when it + // has no return URL, and by then the session already exists. + { path: '', pathMatch: 'full', canActivate: [rootRedirectGuard], children: [] }, + { path: '**', redirectTo: '' }, +]; diff --git a/ahk-frontend/src/app/app.scss b/ahk-frontend/src/app/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/ahk-frontend/src/app/app.spec.ts b/ahk-frontend/src/app/app.spec.ts new file mode 100644 index 0000000..204c065 --- /dev/null +++ b/ahk-frontend/src/app/app.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; + +import { App } from './app'; +import { routes } from './app.routes'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + providers: [provideRouter(routes), provideHttpClient()], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/ahk-frontend/src/app/app.ts b/ahk-frontend/src/app/app.ts new file mode 100644 index 0000000..8a88567 --- /dev/null +++ b/ahk-frontend/src/app/app.ts @@ -0,0 +1,12 @@ +import { Component, signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet], + templateUrl: './app.html', + styleUrl: './app.scss' +}) +export class App { + protected readonly title = signal('ahk-frontend'); +} diff --git a/ahk-frontend/src/app/core/api-error.ts b/ahk-frontend/src/app/core/api-error.ts new file mode 100644 index 0000000..3adc9cc --- /dev/null +++ b/ahk-frontend/src/app/core/api-error.ts @@ -0,0 +1,34 @@ +/** + * Turns a failed generated-client call into a sentence worth showing. + * + * The NSwag clients throw a `SwaggerException` carrying the raw response body, so the API's own message — + * `{ "error": … }`, `{ "errors": [ … ] }` or a ProblemDetails `title` — has to be dug out. Status 0 is the + * case that matters most in development: it means the request never reached the backend, and reporting that + * as "wrong password" or "could not save" sends people looking in the wrong place. + */ +export function readApiError(error: unknown, fallback: string): string { + const status = (error as { status?: number }).status; + + if (status === 0) { + return 'The server is not responding. Check that the backend is running, then try again.'; + } + + const body = (error as { response?: string }).response; + if (body) { + try { + const parsed = JSON.parse(body) as { error?: string; errors?: string[]; title?: string; detail?: string }; + const message = parsed.error ?? parsed.errors?.join(' ') ?? parsed.detail ?? parsed.title; + if (message) { + return message; + } + } catch { + // Not JSON — fall through to the caller's wording. + } + } + + if (status === 403) { + return 'You do not have access to do that.'; + } + + return fallback; +} diff --git a/ahk-frontend/src/app/core/auth/auth.guard.ts b/ahk-frontend/src/app/core/auth/auth.guard.ts new file mode 100644 index 0000000..3b87f07 --- /dev/null +++ b/ahk-frontend/src/app/core/auth/auth.guard.ts @@ -0,0 +1,45 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; + +import { CourseContextService } from '../course/course-context.service'; +import { AuthService } from './auth.service'; + +/** Requires an authenticated session; otherwise redirects to /login preserving the target URL. */ +export const authGuard: CanActivateFn = (_route, state) => { + const auth = inject(AuthService); + const router = inject(Router); + return auth.ensureLoaded().pipe( + map((user) => (user ? true : router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }))), + ); +}; + +/** + * Sends "/" to wherever this particular user belongs. This is also where the OIDC callback lands when it has + * no return URL to honour, so it has to work for a session that was established outside the SPA. + */ +export const rootRedirectGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + return auth.ensureLoaded().pipe(map((user) => router.createUrlTree([user ? auth.landingUrl() : '/login']))); +}; + +/** + * Requires the site-admin role. Also clears the course context: the site screens have no course, and the shell + * picks which rail to show from it. + */ +export const adminGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const courseContext = inject(CourseContextService); + const router = inject(Router); + + return auth.ensureLoaded().pipe( + map(() => { + if (!auth.isAdmin()) { + return router.createUrlTree([auth.landingUrl()]); + } + courseContext.setActiveSlug(null); + return true; + }), + ); +}; diff --git a/ahk-frontend/src/app/core/auth/auth.interceptor.ts b/ahk-frontend/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000..4e5f85c --- /dev/null +++ b/ahk-frontend/src/app/core/auth/auth.interceptor.ts @@ -0,0 +1,21 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +/** + * Redirects to the login page on an unexpected 401. The session-probe endpoint (/api/auth/me) is exempt: + * a 401 there is the normal "not logged in yet" signal handled by the auth guard, not an error to redirect on. + */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const router = inject(Router); + return next(req).pipe( + catchError((error: unknown) => { + if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.includes('/api/auth/me')) { + void router.navigate(['/login']); + } + return throwError(() => error); + }), + ); +}; diff --git a/ahk-frontend/src/app/core/auth/auth.service.ts b/ahk-frontend/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000..00c85f1 --- /dev/null +++ b/ahk-frontend/src/app/core/auth/auth.service.ts @@ -0,0 +1,117 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; + +import { AuthClient, CurrentUserResponse, LoginRequest } from '../../api/api-client'; +import { readApiError } from '../api-error'; + +/** + * Session state for the SPA. Hydrates from GET /api/auth/me (cookie-based), exposes the current user and + * derived flags as signals, and wraps the generated AuthClient for login/logout. + */ +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly authClient = inject(AuthClient); + + private readonly user = signal(null); + private readonly loaded = signal(false); + + readonly currentUser = this.user.asReadonly(); + readonly isAuthenticated = computed(() => this.user() !== null); + readonly isAdmin = computed(() => this.user()?.roles?.includes('Admin') ?? false); + + /** + * Every course this user can open. The API already folds a site admin's implicit access into this list, so + * the course switcher and the course guard both read it without a special case. + */ + readonly courses = computed(() => this.user()?.courses ?? []); + + /** Loads the session once (cached). Returns the user, or null if not authenticated. */ + ensureLoaded(): Observable { + if (this.loaded()) { + return of(this.user()); + } + return this.authClient.me().pipe( + tap((u) => this.setUser(u)), + catchError(() => { + this.setLoggedOut(); + return of(null); + }), + ); + } + + /** + * Re-fetches the session, ignoring the cache. Needed after the user changes something the session carries — + * their GitHub username, for one — so the rest of the app does not keep showing the stale value. + */ + reload(): Observable { + return this.authClient.me().pipe( + tap((u) => this.setUser(u)), + catchError(() => { + this.setLoggedOut(); + return of(null); + }), + ); + } + + /** + * Signs in. Resolves to null on success, or to the reason it failed — the API's own message for a bad + * password or a locked-out account, and a distinct one when the backend cannot be reached at all. + */ + login(userName: string, password: string): Observable { + const request: LoginRequest = { userName, password, rememberMe: true }; + return this.authClient.login(request).pipe( + tap((u) => this.setUser(u)), + map(() => null), + catchError((err: unknown) => of(readApiError(err, 'That username and password do not match an account.'))), + ); + } + + /** + * Clears the portal session. When the API returns an `endSessionUrl` the provider supports RP-initiated + * logout, so the browser is sent there to end the SSO session too; the BME IdP advertises no such endpoint + * today, so sign-out is local-only and this stays null. + */ + logout(): Observable { + return this.authClient.logout().pipe( + tap((result) => { + this.setLoggedOut(); + if (result?.endSessionUrl) { + window.location.href = result.endSessionUrl; + } + }), + map(() => undefined), + catchError(() => { + this.setLoggedOut(); + return of(undefined); + }), + ); + } + + isMemberOf(slug: string): boolean { + return this.courses().some((c) => c.slug === slug); + } + + /** + * Where signing in should land: the admin console for admins, then the first course they staff. Everyone + * else is a student, so they go to their own repositories — which doubles as the "you have nothing yet" + * screen and tells them to ask for an invite link. + */ + landingUrl(): string { + if (this.isAdmin()) { + return '/admin/courses'; + } + const first = this.courses()[0]; + return first ? `/${first.slug}/dashboard` : '/my'; + } + + private setUser(u: CurrentUserResponse): void { + this.user.set(u); + this.loaded.set(true); + } + + private setLoggedOut(): void { + this.user.set(null); + this.loaded.set(true); + } +} diff --git a/ahk-frontend/src/app/core/clipboard.ts b/ahk-frontend/src/app/core/clipboard.ts new file mode 100644 index 0000000..8641e9b --- /dev/null +++ b/ahk-frontend/src/app/core/clipboard.ts @@ -0,0 +1,35 @@ +/** + * Copies text to the clipboard, resolving to whether it worked. + * + * The async Clipboard API is the path that runs — the app is HTTPS in development and production alike — but it + * is also refused outright when the document is not focused or the browser withholds permission, and an invite + * link the instructor believes they copied is worse than one they know they did not. The textarea fallback + * covers those cases; the caller uses the result to decide what to say. + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Permission refused or the document is not focused — fall through. + } + } + + const area = document.createElement('textarea'); + area.value = text; + + // Off-screen rather than hidden: an element with display:none cannot be selected. + area.style.position = 'fixed'; + area.style.left = '-9999px'; + document.body.appendChild(area); + + try { + area.select(); + return document.execCommand('copy'); + } catch { + return false; + } finally { + area.remove(); + } +} diff --git a/ahk-frontend/src/app/core/course/course-context.service.ts b/ahk-frontend/src/app/core/course/course-context.service.ts new file mode 100644 index 0000000..fc8652b --- /dev/null +++ b/ahk-frontend/src/app/core/course/course-context.service.ts @@ -0,0 +1,24 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; + +import { AuthService } from '../auth/auth.service'; + +/** + * Tracks the course the current route is scoped to (the {course} path segment), or null on the site-admin + * screens. The active course record is derived from the courses {@link AuthService} says the user can open. + */ +@Injectable({ providedIn: 'root' }) +export class CourseContextService { + private readonly auth = inject(AuthService); + + private readonly slug = signal(null); + + readonly activeSlug = this.slug.asReadonly(); + readonly activeCourse = computed(() => { + const s = this.slug(); + return s ? (this.auth.courses().find((c) => c.slug === s) ?? null) : null; + }); + + setActiveSlug(slug: string | null): void { + this.slug.set(slug); + } +} diff --git a/ahk-frontend/src/app/core/course/course.guard.ts b/ahk-frontend/src/app/core/course/course.guard.ts new file mode 100644 index 0000000..9364da7 --- /dev/null +++ b/ahk-frontend/src/app/core/course/course.guard.ts @@ -0,0 +1,32 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; + +import { AuthService } from '../auth/auth.service'; +import { CourseContextService } from './course-context.service'; + +/** + * Guards /{course}/... routes: the course must be one the user can open. Site admins can open every course, + * which the API already reflects in the list this reads, so no special case is needed here. On success the + * course context is set so the shell can display it. Backend authorization is the real gate; this just avoids + * navigating into a course that would only return 403. + */ +export const courseGuard: CanActivateFn = (route) => { + const auth = inject(AuthService); + const courseContext = inject(CourseContextService); + const router = inject(Router); + const slug = route.paramMap.get('course'); + + return auth.ensureLoaded().pipe( + map((user) => { + if (!user) { + return router.createUrlTree(['/login']); + } + if (slug && auth.isMemberOf(slug)) { + courseContext.setActiveSlug(slug); + return true; + } + return router.createUrlTree([auth.landingUrl()]); + }), + ); +}; diff --git a/ahk-frontend/src/app/features/admin/courses/course-editor.html b/ahk-frontend/src/app/features/admin/courses/course-editor.html new file mode 100644 index 0000000..bea0244 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/course-editor.html @@ -0,0 +1,427 @@ +@if (loading()) { +

Loading course…

+} @else if (course(); as c) { +
+
+
+ Courses / {{ c.slug }} +

{{ c.name }}

+

+ {{ c.studentCount }} students, {{ c.submissionCount }} submissions. Created + {{ c.createdAt | date: 'longDate' }}. +

+
+ +
+ + @if (error(); as e) { +

{{ e }}

+ } + @if (saved(); as s) { +

{{ s }}

+ } + + +
+
+
+

Integration health

+

Checked against the settings below, and against GitHub itself for the access token.

+
+ +
+
+ @if (report(); as r) { + + } @else if (checking()) { +

Running checks…

+ } @else { +

The checks could not be run.

+ } +
+
+ + +
+
+
+
+

Course settings

+

How this course is identified, and which repositories belong to it.

+
+
+
+
+
+ + + @if (slugChanged()) { + + Changing the slug changes every URL for this course. Existing links will stop working. + + } @else { + Used in the address: /{{ c.slug }}/dashboard + } +
+
+ + +
+
+
+
+ + + Deliveries from this organization are routed to this course. +
+
+ + + Only needed when one organization hosts several courses. +
+
+
+
+ +
+
+
+ + +
+
+
+
+

GitHub integration

+

Credentials this course uses to talk to GitHub. Stored values are never shown again.

+
+ + Setup guide + +
+
+
+
+ + +
+
+ + + Runs a student may trigger per repository before the monitor steps in. +
+
+ +
+ + + + @if (c.gitHubConfig?.hasAccessToken) { + A token ending …{{ c.gitHubConfig?.accessTokenHint }} is stored. + + } @else { + Needs the read:org and repo scopes. This is the token the health check verifies. + } + +
+ +
+ + + + @if (c.gitHubConfig?.hasWebhookSecret) { + A secret is stored. + + } @else { + Validates the X-Hub-Signature-256 header on incoming deliveries. + } + +
+ +
+ + + + @if (c.gitHubConfig?.hasAppPrivateKey) { + A private key is stored. + + } @else { + Used to authenticate as the GitHub App installation for this course. + } + +
+ + +
+
+ + @if (c.gitHubConfig?.updatedAt) { + Last changed {{ c.gitHubConfig?.updatedAt | date: 'medium' }} + } +
+
+
+ + +
+
+
+

CI callback tokens

+

The evaluator workflow signs its results with one of these. Revoke a token to cut it off.

+
+
+ + @if (issuedToken(); as t) { +
+

+ Token created. Its secret is shown here, and stays available — you can copy it again from the list + below at any time. +

+
+
Token
+
{{ t.token }}
+
Secret
+
{{ t.secret }}
+
+ +
+ } + +
+ + + + + + + + + + + + @for (t of c.webhookTokens; track t.id) { + + + + + + + + } @empty { + + + + } + +
TokenDescriptionCreatedState
+ {{ t.token }} + @if (revealed().has(t.id ?? 0)) { +
{{ t.secret }}
+ } +
{{ t.description || '—' }}{{ t.createdAt | date: 'yyyy-MM-dd' }} + @if (t.revokedAt) { + Revoked {{ t.revokedAt | date: 'yyyy-MM-dd' }} + } @else { + Active + } + + + + + @if (!t.revokedAt) { + + } + +
+ No tokens yet. Evaluation results cannot be accepted until one exists. +
+
+ +
+ + +
+
+ + +
+
+
+

Staff

+

Who can open this course. Site admins can open it regardless of this list.

+
+
+ +
+ + + + + + + + + + + @for (m of c.members; track m.userId) { + + + + + + + } @empty { + + + + } + +
UserEmailRole in course
+ {{ m.userName }} + @if (m.displayName) { +
{{ m.displayName }}
+ } +
{{ m.email || '—' }} + + + +
No staff assigned yet.
+
+ +
+
+ + @if (searching()) { + Searching… + } +
+ + @if (candidates().length > 0) { +
    + @for (u of candidates(); track u.id) { +
  • + {{ u.userName }} + {{ u.displayName || u.email || '—' }} + + @if (isMember(u)) { + Already staff + } @else { + + + } +
  • + } +
+ } @else if (memberSearch.trim().length >= 2 && !searching()) { +

No user matches "{{ memberSearch }}".

+ } +
+
+ + +
+
+
+

Delete this course

+

+ Removes the course and everything assigned to it: students, submissions, events, grades, tokens and + staff assignments. This cannot be undone. +

+
+
+
+ + +
+
+
+} @else { +

That course does not exist. Back to courses

+} diff --git a/ahk-frontend/src/app/features/admin/courses/course-editor.scss b/ahk-frontend/src/app/features/admin/courses/course-editor.scss new file mode 100644 index 0000000..945e524 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/course-editor.scss @@ -0,0 +1,126 @@ +textarea { + font-size: 0.75rem; + resize: vertical; +} + +.warn-text { + color: var(--warn); +} + +/* A "Remove it" affordance that lives inside a hint sentence, so it reads as prose, not a control. */ +.linkish { + border: 0; + background: none; + padding: 0; + font: inherit; + color: var(--accent); + cursor: pointer; + text-decoration: underline; +} + +.grow-input { + flex: 1 1 16rem; +} + +.card-foot.stack { + flex-direction: column; + align-items: stretch; +} + +.add-member { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; +} + +.issued { + border-bottom: 1px solid var(--rule); +} + +/* Token row actions: keep the copy/show/revoke buttons spaced and right-aligned in the nowrap actions cell. */ +.token-actions { + display: inline-flex; + gap: 0.375rem; + justify-content: flex-end; +} + +/* The secret revealed under its token — smaller and dimmer than the token, and free to wrap. */ +.secret-reveal { + margin-top: 0.25rem; + font-size: 0.75rem; + color: var(--ink-2); + word-break: break-all; + white-space: normal; +} + +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0 0 1rem; + font-size: 0.8125rem; + + dt { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-2); + } + + dd { + margin: 0; + word-break: break-all; + } +} + +.candidates { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + border: 1px solid var(--rule); + border-radius: var(--radius); + width: 100%; + + li { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--rule); + background: var(--surface); + font-size: 0.8125rem; + + &:last-child { + border-bottom: 0; + } + } + + .spacer { + flex: 1 1 auto; + } +} + +.danger-zone { + border-color: color-mix(in srgb, var(--bad) 30%, var(--rule)); + + > header { + border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--rule)); + } + + h2 { + color: var(--bad); + } + + .card-foot { + background: var(--bad-wash); + border-top-color: color-mix(in srgb, var(--bad) 30%, var(--rule)); + } +} + +select { + width: auto; + min-width: 10rem; +} diff --git a/ahk-frontend/src/app/features/admin/courses/course-editor.ts b/ahk-frontend/src/app/features/admin/courses/course-editor.ts new file mode 100644 index 0000000..7fc8816 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/course-editor.ts @@ -0,0 +1,358 @@ +import { DatePipe } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { + CourseDetailDto, + CourseHealthAdminClient, + CourseHealthReport, + CourseMemberDto, + CourseRole, + CoursesAdminClient, + UpdateCourseGitHubConfigRequest, + UpdateCourseRequest, + UserDto, + UsersAdminClient, + WebhookTokenDto, +} from '../../../api/api-client'; +import { HealthChain } from '../../../shared/health-chain/health-chain'; + +/** + * Everything one course holds, on one page: what it is, how it talks to GitHub, which tokens accept its + * evaluation results, and who staffs it. + * + * Stored credentials are never sent to the browser, so their inputs start empty and mean "leave as is". Only a + * field the admin actually typed into is submitted — that is what lets an unchanged form be saved safely. + */ +@Component({ + selector: 'app-course-editor', + imports: [FormsModule, RouterLink, DatePipe, HealthChain], + templateUrl: './course-editor.html', + styleUrl: './course-editor.scss', +}) +export class CourseEditor implements OnInit { + private readonly client = inject(CoursesAdminClient); + private readonly usersClient = inject(UsersAdminClient); + private readonly healthClient = inject(CourseHealthAdminClient); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly course = signal(null); + protected readonly report = signal(null); + protected readonly loading = signal(true); + protected readonly checking = signal(false); + protected readonly error = signal(null); + protected readonly saved = signal(null); + + private courseId = 0; + + // ---- Settings form ---- + protected slug = ''; + protected name = ''; + protected organization = ''; + protected repoPrefix = ''; + protected readonly savingSettings = signal(false); + + /** Renaming the slug moves every URL the course is reachable at, so the form says so before it is saved. */ + protected readonly slugChanged = computed(() => this.course() !== null && this.slug !== this.course()?.slug); + + // ---- GitHub integration form ---- + protected appId = ''; + protected appPrivateKey = ''; + protected accessToken = ''; + protected webhookSecret = ''; + protected workflowRunThreshold = 5; + protected integrationEnabled = true; + protected readonly savingIntegration = signal(false); + protected readonly clearing = signal>({}); + + // ---- Tokens ---- + protected newTokenDescription = ''; + protected readonly issuedToken = signal(null); + protected readonly creatingToken = signal(false); + /** Token id most recently copied, for transient "Copied" feedback. */ + protected readonly copiedTokenId = signal(null); + /** Token ids whose secret is currently revealed inline. */ + protected readonly revealed = signal>(new Set()); + + // ---- Staff ---- + protected memberSearch = ''; + protected readonly candidates = signal([]); + protected readonly searching = signal(false); + + // ---- Delete ---- + protected confirmSlug = ''; + protected readonly deleting = signal(false); + + ngOnInit(): void { + this.courseId = Number(this.route.snapshot.paramMap.get('id')); + this.load(); + this.runHealthCheck(); + } + + private load(): void { + this.loading.set(true); + this.client.get(this.courseId).subscribe({ + next: (course) => { + this.course.set(course); + this.slug = course.slug ?? ''; + this.name = course.name ?? ''; + this.organization = course.gitHubOrganization ?? ''; + this.repoPrefix = course.repoNamePrefix ?? ''; + this.appId = course.gitHubConfig?.gitHubAppId ?? ''; + this.workflowRunThreshold = course.gitHubConfig?.workflowRunThreshold ?? 5; + this.integrationEnabled = course.gitHubConfig?.enabled ?? true; + this.loading.set(false); + }, + error: () => { + this.error.set('That course could not be loaded.'); + this.loading.set(false); + }, + }); + } + + protected runHealthCheck(): void { + this.checking.set(true); + this.healthClient.checkCourse(this.courseId).subscribe({ + next: (report) => { + this.report.set(report); + this.checking.set(false); + }, + error: () => this.checking.set(false), + }); + } + + // ---- Settings ---- + + protected saveSettings(): void { + this.clearMessages(); + this.savingSettings.set(true); + + const request: UpdateCourseRequest = { + slug: this.slug.trim(), + name: this.name.trim(), + gitHubOrganization: this.organization.trim() || undefined, + repoNamePrefix: this.repoPrefix.trim() || undefined, + }; + + this.client.update(this.courseId, request).subscribe({ + next: () => { + this.savingSettings.set(false); + this.saved.set('Course settings saved.'); + this.load(); + this.runHealthCheck(); + }, + error: (err: unknown) => { + this.savingSettings.set(false); + this.error.set( + (err as { status?: number }).status === 409 + ? `The slug "${request.slug}" belongs to another course. Pick a different one.` + : 'The settings could not be saved.', + ); + }, + }); + } + + // ---- GitHub integration ---- + + /** Marks a stored credential for removal; the input is then disabled until the change is saved or undone. */ + protected toggleClear(field: string): void { + const next = { ...this.clearing() }; + next[field] = !next[field]; + this.clearing.set(next); + } + + protected isClearing(field: string): boolean { + return this.clearing()[field] === true; + } + + protected saveIntegration(): void { + this.clearMessages(); + this.savingIntegration.set(true); + + const request: UpdateCourseGitHubConfigRequest = { + gitHubAppId: this.appId.trim() || undefined, + gitHubAppPrivateKey: this.secretValue('appPrivateKey', this.appPrivateKey), + gitHubAccessToken: this.secretValue('accessToken', this.accessToken), + gitHubWebhookSecret: this.secretValue('webhookSecret', this.webhookSecret), + workflowRunThreshold: this.workflowRunThreshold, + enabled: this.integrationEnabled, + }; + + this.client.updateGitHubConfig(this.courseId, request).subscribe({ + next: () => { + this.savingIntegration.set(false); + this.appPrivateKey = ''; + this.accessToken = ''; + this.webhookSecret = ''; + this.clearing.set({}); + this.saved.set('GitHub integration saved.'); + this.load(); + this.runHealthCheck(); + }, + error: () => { + this.savingIntegration.set(false); + this.error.set('The GitHub integration could not be saved.'); + }, + }); + } + + /** + * Translates a credential input into what the API expects: undefined keeps the stored value, an empty string + * clears it, anything else replaces it. + */ + private secretValue(field: string, typed: string): string | undefined { + if (this.isClearing(field)) { + return ''; + } + return typed.trim() || undefined; + } + + // ---- CI callback tokens ---- + + protected createToken(): void { + this.clearMessages(); + this.creatingToken.set(true); + + this.client.createToken(this.courseId, { description: this.newTokenDescription.trim() || undefined }).subscribe({ + next: (token) => { + this.creatingToken.set(false); + this.newTokenDescription = ''; + this.issuedToken.set(token); + this.load(); + this.runHealthCheck(); + }, + error: () => { + this.creatingToken.set(false); + this.error.set('The token could not be created.'); + }, + }); + } + + protected revokeToken(token: WebhookTokenDto): void { + this.clearMessages(); + this.client.revokeToken(this.courseId, token.id ?? 0).subscribe({ + next: () => { + this.saved.set(`Token ${token.token} revoked. Evaluations signed with it will be rejected.`); + this.load(); + this.runHealthCheck(); + }, + error: () => this.error.set('The token could not be revoked.'), + }); + } + + protected dismissIssuedToken(): void { + this.issuedToken.set(null); + } + + /** Copies a token's secret to the clipboard, with brief per-row "Copied" feedback. */ + protected copySecret(token: WebhookTokenDto): void { + const secret = token.secret; + if (!secret) { + return; + } + navigator.clipboard.writeText(secret).then( + () => { + this.copiedTokenId.set(token.id ?? null); + setTimeout(() => { + if (this.copiedTokenId() === token.id) { + this.copiedTokenId.set(null); + } + }, 1500); + }, + () => this.error.set('The secret could not be copied to the clipboard.'), + ); + } + + /** Reveals or hides a token's secret inline, so it can be read as well as copied. */ + protected toggleReveal(token: WebhookTokenDto): void { + const id = token.id ?? 0; + const next = new Set(this.revealed()); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + this.revealed.set(next); + } + + // ---- Staff ---- + + protected searchUsers(): void { + const term = this.memberSearch.trim(); + if (term.length < 2) { + this.candidates.set([]); + return; + } + + this.searching.set(true); + this.usersClient.list(term, undefined, 0, 10).subscribe({ + next: (page) => { + this.candidates.set(page.items ?? []); + this.searching.set(false); + }, + error: () => this.searching.set(false), + }); + } + + protected addMember(user: UserDto, role: CourseRole): void { + this.clearMessages(); + this.client.upsertMember(this.courseId, { userId: user.id ?? 0, role }).subscribe({ + next: () => { + this.memberSearch = ''; + this.candidates.set([]); + this.saved.set(`${user.userName} was added to this course.`); + this.load(); + }, + error: () => this.error.set('That user could not be added.'), + }); + } + + protected changeMemberRole(member: CourseMemberDto, role: string): void { + this.clearMessages(); + this.client.upsertMember(this.courseId, { userId: member.userId ?? 0, role: role as CourseRole }).subscribe({ + next: () => this.load(), + error: () => this.error.set('That role could not be changed.'), + }); + } + + protected removeMember(member: CourseMemberDto): void { + this.clearMessages(); + this.client.removeMember(this.courseId, member.userId ?? 0).subscribe({ + next: () => { + this.saved.set(`${member.userName} was removed from this course.`); + this.load(); + }, + error: () => this.error.set('That member could not be removed.'), + }); + } + + /** Members already on the course are filtered out of the picker, so adding twice is not offered. */ + protected isMember(user: UserDto): boolean { + return (this.course()?.members ?? []).some((m) => m.userId === user.id); + } + + // ---- Delete ---- + + protected deleteCourse(): void { + this.clearMessages(); + this.deleting.set(true); + + this.client.delete(this.courseId, this.confirmSlug.trim()).subscribe({ + next: () => { + this.deleting.set(false); + void this.router.navigate(['/admin/courses']); + }, + error: () => { + this.deleting.set(false); + this.error.set('The course was not deleted. The slug must match exactly.'); + }, + }); + } + + private clearMessages(): void { + this.error.set(null); + this.saved.set(null); + } +} diff --git a/ahk-frontend/src/app/features/admin/courses/courses.html b/ahk-frontend/src/app/features/admin/courses/courses.html new file mode 100644 index 0000000..445059b --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/courses.html @@ -0,0 +1,149 @@ +
+
+
+ Site administration +

Courses

+

+ Every course runs its own GitHub organization, credentials and callback token. This is the register of + them, and the state of each one's integration. +

+
+
+ + +
+
+ + @if (error(); as e) { +

{{ e }}

+ } + + @if (!loading() && failingCount() > 0) { +

+ {{ failingCount() }} {{ failingCount() === 1 ? 'course is' : 'courses are' }} failing a health check. + Open the course to see which link is broken. +

+ } + + @if (adding()) { +
+
+
+

Add course

+

The slug becomes the course's URL and cannot contain spaces or capitals.

+
+
+
+
+
+
+ + + Lowercase letters, digits and hyphens. Used as /viaubc01/… +
+
+ + +
+
+ + + Optional now; you can set it with the rest of the integration. +
+
+
+
+ + +
+
+
+ } + +
+ @if (loading()) { +

Loading courses…

+ } @else { +
+ + + + + + + + + + + + + + + @for (c of courses(); track c.id) { + + + + + + + + + + + } @empty { + + + + } + +
CourseGitHubStudentsSubmissionsStaffIntegrationCreated
+ {{ c.slug }} +
{{ c.name }}
+
+ {{ c.gitHubOrganization || '—' }} + @if (c.repoNamePrefix) { +
prefix {{ c.repoNamePrefix }}
+ } +
{{ c.studentCount }}{{ c.submissionCount }}{{ c.memberCount }} + @if (reportFor(c); as report) { + {{ verdict(report.status) }} + @if (problems(report); as p) { +
{{ p }}
+ } + } @else { + Not checked + } +
{{ c.createdAt | date: 'yyyy-MM-dd' }} + Open + Edit +
+ No courses yet. Add one to start receiving submissions. +
+
+ } +
+
diff --git a/ahk-frontend/src/app/features/admin/courses/courses.scss b/ahk-frontend/src/app/features/admin/courses/courses.scss new file mode 100644 index 0000000..2888c7f --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/courses.scss @@ -0,0 +1,13 @@ +.strong { + font-weight: 600; +} + +td .muted { + font-size: 0.75rem; +} + +/* The verdict plus the names of the failing checks; the full chain lives on the health page. */ +.health-cell { + min-width: 12rem; + max-width: 20rem; +} diff --git a/ahk-frontend/src/app/features/admin/courses/courses.ts b/ahk-frontend/src/app/features/admin/courses/courses.ts new file mode 100644 index 0000000..73c049a --- /dev/null +++ b/ahk-frontend/src/app/features/admin/courses/courses.ts @@ -0,0 +1,163 @@ +import { DatePipe } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { forkJoin } from 'rxjs'; + +import { + CourseDto, + CourseHealthAdminClient, + CourseHealthReport, + CoursesAdminClient, + CreateCourseRequest, + HealthStatus, +} from '../../../api/api-client'; + +/** + * The site's course register. Each row carries the course's identity, its size, and a one-line verdict on its + * integration, so an admin can tell a working course from a half-configured one without opening it. The full + * chain of checks is one click away, on the health page and in the course editor. + */ +@Component({ + selector: 'app-admin-courses', + imports: [FormsModule, RouterLink, DatePipe], + templateUrl: './courses.html', + styleUrl: './courses.scss', +}) +export class AdminCourses implements OnInit { + private readonly client = inject(CoursesAdminClient); + private readonly healthClient = inject(CourseHealthAdminClient); + private readonly router = inject(Router); + + protected readonly courses = signal([]); + protected readonly health = signal>(new Map()); + protected readonly loading = signal(false); + protected readonly checking = signal(false); + protected readonly error = signal(null); + + protected readonly adding = signal(false); + protected readonly saving = signal(false); + protected slug = ''; + protected name = ''; + protected organization = ''; + + /** Courses whose integration is failing — the number the page leads with. */ + protected readonly failingCount = computed( + () => [...this.health().values()].filter((r) => r.status === 'Failed').length, + ); + + ngOnInit(): void { + this.reload(); + } + + protected reload(): void { + this.loading.set(true); + this.error.set(null); + + // The health run is the slow half (it calls GitHub), but the two arrive together so the table never + // reflows under the reader. + forkJoin({ courses: this.client.list(), reports: this.healthClient.checkAll() }).subscribe({ + next: ({ courses, reports }) => { + this.courses.set(courses); + this.health.set(new Map(reports.map((r) => [r.courseId ?? 0, r]))); + this.loading.set(false); + }, + error: () => { + this.error.set('Could not load the courses. Reload the page to try again.'); + this.loading.set(false); + }, + }); + } + + protected recheck(): void { + this.checking.set(true); + this.healthClient.checkAll().subscribe({ + next: (reports) => { + this.health.set(new Map(reports.map((r) => [r.courseId ?? 0, r]))); + this.checking.set(false); + }, + error: () => { + this.error.set('The health check could not be run.'); + this.checking.set(false); + }, + }); + } + + protected reportFor(course: CourseDto): CourseHealthReport | undefined { + return this.health().get(course.id ?? 0); + } + + protected tone(status: HealthStatus | undefined): string { + switch (status) { + case 'Healthy': + return 'ok'; + case 'Warning': + return 'warn'; + case 'Failed': + return 'bad'; + default: + return ''; + } + } + + protected verdict(status: HealthStatus | undefined): string { + switch (status) { + case 'Healthy': + return 'Passing'; + case 'Warning': + return 'Needs attention'; + case 'Failed': + return 'Failing'; + default: + return 'Not set up'; + } + } + + /** Names the checks that are not passing, so the row says what to go and fix. */ + protected problems(report: CourseHealthReport): string { + return (report.checks ?? []) + .filter((c) => c.status !== 'Healthy') + .map((c) => c.title) + .join(', '); + } + + protected startAdding(): void { + this.adding.set(true); + this.error.set(null); + } + + protected cancelAdding(): void { + this.adding.set(false); + this.slug = ''; + this.name = ''; + this.organization = ''; + } + + /** Creates the course and opens its editor — the next thing to do is always fill in the integration. */ + protected create(): void { + this.error.set(null); + this.saving.set(true); + + const request: CreateCourseRequest = { + slug: this.slug.trim(), + name: this.name.trim(), + gitHubOrganization: this.organization.trim() || undefined, + }; + + this.client.create(request).subscribe({ + next: (course) => { + this.saving.set(false); + this.cancelAdding(); + void this.router.navigate(['/admin/courses', course.id]); + }, + error: (err: unknown) => { + this.saving.set(false); + this.error.set( + (err as { status?: number }).status === 409 + ? `The slug "${request.slug}" is already taken. Pick another one.` + : 'The course could not be created. Check the slug and name, then try again.', + ); + }, + }); + } +} diff --git a/ahk-frontend/src/app/features/admin/health/health.html b/ahk-frontend/src/app/features/admin/health/health.html new file mode 100644 index 0000000..a86ee32 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/health/health.html @@ -0,0 +1,77 @@ +
+
+
+ Site administration +

Health

+

+ Every course's integration, checked now. The GitHub access token is verified against GitHub itself; the + rest is read from what is stored. +

+
+
+ +
+
+ + @if (error(); as e) { +

{{ e }}

+ } + + @if (!loading() && reports().length > 0) { +
+
+ {{ counts().failed }} + Failing +
+
+ {{ counts().warning }} + Need attention +
+
+ {{ counts().notConfigured }} + Not set up +
+
+ {{ counts().healthy }} + Passing +
+ @if (checkedAt(); as at) { + Checked {{ at | date: 'medium' }} + } +
+ } + + @if (loading() && reports().length === 0) { +

Running checks against every course…

+ } + + @for (r of sorted(); track r.courseId) { +
+
+ +
+

+ {{ r.courseSlug }} + {{ r.courseName }} +

+

{{ summaryFor(r) }}

+
+ Fix in settings +
+
+ +
+
+ } @empty { + @if (!loading()) { +

There are no courses to check yet.

+ } + } +
diff --git a/ahk-frontend/src/app/features/admin/health/health.scss b/ahk-frontend/src/app/features/admin/health/health.scss new file mode 100644 index 0000000..6c46c2f --- /dev/null +++ b/ahk-frontend/src/app/features/admin/health/health.scss @@ -0,0 +1,67 @@ +.tally { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.tile { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 7rem; + padding: 0.625rem 0.875rem; + background: var(--surface); + border: 1px solid var(--rule); + border-left: 3px solid var(--rule-strong); + border-radius: var(--radius); + + &.ok { + border-left-color: var(--ok); + } + + &.warn { + border-left-color: var(--warn); + } + + &.bad { + border-left-color: var(--bad); + } + + .n { + font-family: var(--font-mono); + font-size: 1.375rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + } + + .l { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-2); + } +} + +.stamp { + font-size: 0.75rem; + color: var(--ink-2); + margin-left: auto; +} + +h2 { + display: flex; + align-items: baseline; + gap: 0.625rem; + flex-wrap: wrap; +} + +.course-name { + font-family: var(--font-ui); + font-size: 0.8125rem; + font-weight: 400; + color: var(--ink-2); +} diff --git a/ahk-frontend/src/app/features/admin/health/health.ts b/ahk-frontend/src/app/features/admin/health/health.ts new file mode 100644 index 0000000..9917113 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/health/health.ts @@ -0,0 +1,100 @@ +import { DatePipe } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { CourseHealthAdminClient, CourseHealthReport, HealthStatus } from '../../../api/api-client'; +import { HealthChain } from '../../../shared/health-chain/health-chain'; + +/** + * Health of every course in one place: which integrations work, which are half-configured, and what to do + * about the ones that are not. Failing courses sort to the top, because that is the only reason to open this + * page twice. + */ +@Component({ + selector: 'app-admin-health', + imports: [RouterLink, DatePipe, HealthChain], + templateUrl: './health.html', + styleUrl: './health.scss', +}) +export class AdminHealth implements OnInit { + private readonly client = inject(CourseHealthAdminClient); + + protected readonly reports = signal([]); + protected readonly loading = signal(false); + protected readonly error = signal(null); + protected readonly checkedAt = signal(null); + + /** Worst first, then alphabetical — the reading order matches the order to act in. */ + private static readonly severity: Record = { + Failed: 0, + Warning: 1, + NotConfigured: 2, + Healthy: 3, + }; + + protected readonly sorted = computed(() => + [...this.reports()].sort((a, b) => { + const bySeverity = + AdminHealth.severity[a.status ?? 'NotConfigured'] - AdminHealth.severity[b.status ?? 'NotConfigured']; + return bySeverity !== 0 ? bySeverity : (a.courseSlug ?? '').localeCompare(b.courseSlug ?? ''); + }), + ); + + protected readonly counts = computed(() => { + const all = this.reports(); + return { + total: all.length, + failed: all.filter((r) => r.status === 'Failed').length, + warning: all.filter((r) => r.status === 'Warning').length, + notConfigured: all.filter((r) => r.status === 'NotConfigured').length, + healthy: all.filter((r) => r.status === 'Healthy').length, + }; + }); + + ngOnInit(): void { + this.run(); + } + + protected run(): void { + this.loading.set(true); + this.error.set(null); + + this.client.checkAll().subscribe({ + next: (reports) => { + this.reports.set(reports); + this.checkedAt.set(new Date()); + this.loading.set(false); + }, + error: () => { + this.error.set('The checks could not be run. Reload the page to try again.'); + this.loading.set(false); + }, + }); + } + + protected tone(status: HealthStatus | undefined): string { + switch (status) { + case 'Healthy': + return 'ok'; + case 'Warning': + return 'warn'; + case 'Failed': + return 'bad'; + default: + return ''; + } + } + + protected summaryFor(report: CourseHealthReport): string { + switch (report.status) { + case 'Healthy': + return 'Everything checks out.'; + case 'Failed': + return 'Part of this integration is broken.'; + case 'Warning': + return 'Works, but something needs attention.'; + default: + return 'Not set up yet.'; + } + } +} diff --git a/ahk-frontend/src/app/features/admin/help/github-setup.html b/ahk-frontend/src/app/features/admin/help/github-setup.html new file mode 100644 index 0000000..729d1e4 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/help/github-setup.html @@ -0,0 +1,305 @@ +
+
+
+ Site administration / Help +

Setting up the GitHub App

+

+ Every course acts on GitHub through a GitHub App installed on that course's organization. + This page walks through registering and installing one, and shows where each value the + GitHub integration form asks for comes from. +

+
+
+ +
+
+
+

Why an App, and one per organization

+
+
+
+

+ Each course points at its own GitHub organization, administered by different people. A GitHub App is + owned by the organization rather than a person: its permissions are declared up front and visible to the + organization's owners, and it can be uninstalled at any time without touching any other course. +

+

+ The App both receives webhooks (pushes, pull requests, comments, workflow runs) and creates student + repositories from an assignment's template. One App serves both — if the organization already has an App + for github-monitor, extend that one rather than registering a second. +

+
+
+ +
+
+
+

1 · Register the App

+

Do this once per organization, as an organization owner.

+
+ @if (org(); as o) { + + Open on GitHub ↗ + + } +
+
+
    +
  1. + Go to the organization's Settings → Developer settings → GitHub Apps → New GitHub App. +
  2. +
  3. GitHub App name — something identifiable, e.g. AHK viaubc01.
  4. +
  5. Homepage URLhttps://ahk.aut.bme.hu.
  6. +
  7. + Webhook — tick Active, set the + Webhook URL to https://ahk.aut.bme.hu/api/integrations/github, + and generate a long random Webhook secret (you will paste this into the portal in + step 3, so keep it). +

    + The portal's webhook receiver is not ported yet. Until it is, point the webhook at + the course's existing github-monitor Function URL instead, and keep using + that. Everything else on this page already applies. +

    +
  8. +
  9. + Permissions — set exactly what the table below lists, and + nothing more. +
  10. +
  11. + Subscribe to events (webhook side only) — Pull request, + Pull request review, Issue comment, Push, Create, + Repository, Workflow run. +
  12. +
  13. Where can this GitHub App be installed?Only on this account.
  14. +
  15. + Create it. Then, on the App's page: +
      +
    • note the App ID;
    • +
    • + choose Generate a private key — GitHub downloads a .pem + file. +
    • +
    +
  16. +
+

+ The private key can only be downloaded once, at the moment you generate it. GitHub keeps + the public half and shows you the key only this one time — save the .pem before + leaving the page. If you lose it, generate a new one (the old one keeps working until you delete it). +

+
+
+ +
+
+
+

2 · Install it on the organization

+
+
+
+

On the App's page choose Install App and pick the organization.

+

+ Repository access must be “All repositories”. Repositories created for students do not + exist when the App is installed, so under Only select repositories they fall outside the + installation — the portal creates the repository and then gets a 404 from the + very next call. The health check warns about this before it happens. +

+
+
+ +
+
+
+

3 · Permissions

+

Set these under Repository permissions. Organization permissions are not needed.

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PermissionLevelWhy
AdministrationRead & write + Creating a repository from a template, adding a student as a collaborator, managing invitations, + and setting a repository's Actions permissions all sit behind this one. + This is the grant to add if the App already exists for github-monitor. +
MetadataRead-onlyMandatory for every App. Also backs the check that a template repository exists.
ContentsRead-onlyReading the template repository. github-monitor needs Read & write here for its own work.
Pull requestsRead & writegithub-monitor only: comments, merges, assignees.
IssuesRead & writegithub-monitor only: issue comments carry the /ahk ok chatops.
ActionsRead-onlygithub-monitor only: counting workflow runs against the course threshold.
+
+

+ Administration: write is broad — it also permits changing repository settings and + deleting repositories. There is no narrower permission that covers creating repositories and adding + collaborators, so this is the floor, not a choice. +

+
+
+ +
+
+
+

4 · Where each value goes in the portal

+

Back in Courses → course → GitHub integration, fill these three fields.

+
+
+
+
+
GitHub App ID
+
The App ID from the App's page (a number, e.g. 123456).
+ +
App private key
+
+ Paste the entire contents of the .pem file, the + -----BEGIN line included. The bare base64 body that + github-monitor holds is also accepted, so a course being migrated can paste + what it already has. +
+ +
Webhook secret
+
The random string you generated for the App's webhook in step 1.
+
+

+ Stored credentials are never shown again. Leaving a field empty means + leave the stored value alone; use the field's Clear toggle to remove one. That + is what makes saving an untouched form safe. +

+
+
+ +
+
+
+

The access token

+

A separate personal access token — not part of the App above.

+
+ + Create a token ↗ + +
+
+

+ The Access token field on the integration form takes a GitHub personal access + token, created on your own GitHub account — it is independent of the course's GitHub App. Today + the portal uses it for one thing only: the health check calls + GET /user and GET /orgs/{org} with it to + confirm the portal can reach GitHub and see the course's organization. Leaving it blank simply leaves + that one check unconfigured; it does not affect the App. +

+

Create one under GitHub → Settings → Developer settings → Personal access tokens:

+
    +
  • + a classic token (ghp_…) with the + read:org and repo scopes, or +
  • +
  • + a fine-grained token (github_pat_…) scoped to the course's + organization with read access. +
  • +
+

+ Paste it into the Access token field. As with the App secrets, leaving it empty keeps + the stored value, and each save can Clear it. +

+
+
+ +
+
+
+

Not from GitHub: CI callback tokens

+
+
+
+

+ The CI callback tokens card next to GitHub integration is a separate + concern — it has nothing to do with the App above. Those tokens are generated + here in the portal, not on GitHub. The evaluator workflow uses one to sign its result + submissions back to the portal (an HMAC in the X-Ahk-Token header), so the + portal can trust that a set of grades really came from that course's CI. +

+

+ Do not confuse the webhook secret (a GitHub value, documented above) with a + CI callback token (issued by the portal). They protect traffic going in opposite + directions. +

+
+
+ +
+
+
+

When something's wrong

+
+
+
+

+ Site administration → Health runs the per-course checks and points at + whichever of the steps above failed. The most common failures: +

+
    +
  • + 401 from the token endpoint — wrong App ID, or the private key was revoked or pasted + incompletely. Generate a new key and store it again. +
  • +
  • + 404 on the installation — the App is registered but not installed on that + organization, or the course's organization name is wrong. +
  • +
  • + “administration: write” not granted — GitHub does not apply added permissions until an + owner approves them on the organization's installation page. Changing the App alone is not enough. +
  • +
  • + “limited to selected repositories” — change the installation's repository access to + All repositories (step 2). +
  • +
  • + Access token rejected — a 401 means the personal access + token is invalid, revoked or expired: issue a new one and save it. A 403 or + “cannot read the organization” means it is missing the read:org and + repo scopes, or has no access to that organization. +
  • +
+
+
+
diff --git a/ahk-frontend/src/app/features/admin/help/github-setup.scss b/ahk-frontend/src/app/features/admin/help/github-setup.scss new file mode 100644 index 0000000..87dedd4 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/help/github-setup.scss @@ -0,0 +1,77 @@ +/* The global .notice is display:flex to seat an optional leading icon. Our callouts carry rich inline content + (bold, mono spans) and no icon, so flex would split each run into its own column — force normal block flow. */ +.notice { + display: block; +} + +/* Readable running text for the guide. Component-scoped, so it does not leak into other cards' bodies. */ +.prose { + font-size: 0.875rem; + line-height: 1.55; + + p { + margin: 0 0 0.75rem; + } + + ol, + ul { + margin: 0 0 0.75rem; + padding-left: 1.25rem; + + li { + margin-bottom: 0.4rem; + } + + ul { + margin: 0.4rem 0 0; + } + } + + > :last-child { + margin-bottom: 0; + } + + .notice { + margin: 0.5rem 0; + } +} + +/* Guidance under the permissions table and the CI-token aside. */ +.hint { + font-size: 0.8125rem; + color: var(--ink-2); + margin: 0.75rem 0 0; +} + +/* GitHub value → portal field mapping. Mirrors the .kv in the course editor so the two read alike. */ +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 1rem; + margin: 0 0 1rem; + font-size: 0.8125rem; + + dt { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-2); + padding-top: 0.15rem; + } + + dd { + margin: 0; + } +} + +@media (max-width: 40rem) { + .kv { + grid-template-columns: 1fr; + gap: 0.15rem; + + dd { + margin-bottom: 0.6rem; + } + } +} diff --git a/ahk-frontend/src/app/features/admin/help/github-setup.ts b/ahk-frontend/src/app/features/admin/help/github-setup.ts new file mode 100644 index 0000000..287896f --- /dev/null +++ b/ahk-frontend/src/app/features/admin/help/github-setup.ts @@ -0,0 +1,28 @@ +import { Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { map } from 'rxjs'; + +/** + * A read-only guide to registering the per-course GitHub App and finding the values the GitHub integration + * form asks for. It is static prose — the authoritative copy lives in ahk-backend/docs/github-app.md and the + * two must be kept in step. + * + * When opened from a course's integration card the organization is passed as ?org=, which lets the page render + * a direct link into that organization's Developer settings. Without it the guide still stands on its own. + */ +@Component({ + selector: 'app-github-setup-help', + imports: [RouterLink], + templateUrl: './github-setup.html', + styleUrl: './github-setup.scss', +}) +export class GitHubSetupHelp { + private readonly route = inject(ActivatedRoute); + + /** The organization to deep-link into, when the page was opened from a specific course. */ + protected readonly org = toSignal( + this.route.queryParamMap.pipe(map((p) => p.get('org')?.trim() || null)), + { initialValue: null }, + ); +} diff --git a/ahk-frontend/src/app/features/admin/users/users.html b/ahk-frontend/src/app/features/admin/users/users.html new file mode 100644 index 0000000..23975d2 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/users/users.html @@ -0,0 +1,269 @@ +
+
+
+ Site administration +

Users

+

+ Accounts that can sign in. BME accounts appear here after their first sign-on; local accounts are + created below. +

+
+
+ +
+
+ + @if (error(); as e) { +

{{ e }}

+ } + @if (saved(); as s) { +

{{ s }}

+ } + + @if (adding()) { +
+
+
+

Add local account

+

For people without a BME account, or for service logins.

+
+
+
+
+
+
+ + +
+
+ + +
+
+ + + Optional. Must be unique; links an eduID login to this account. +
+
+
+
+ + +
+
+ +
+ + +
+ + At least 6 characters, shown in the clear so you can copy it. Tell the person to change it after + signing in. + +
+
+
+
+ + +
+
+
+ } + +
+
+ + + + + @if (total() > 0) { + {{ showingRange().from }}–{{ showingRange().to }} of {{ total() }} + } + +
+ + @if (loading()) { +

Loading users…

+ } @else { +
+ + + + + + + + + + + + @for (u of users(); track u.id) { + + + + + + + + + @if (expanded() === u.id) { + + + + } + } @empty { + + + + } + +
UserSign-inSite roleCourses
+ {{ u.userName }} + @if (u.displayName) { +
{{ u.displayName }}
+ } + @if (u.email) { +
{{ u.email }}
+ } +
+ @if (u.isExternal) { + BME account + } @else { + Password + } + @if (u.isLockedOut) { +
Locked out
+ } + @if (u.neptunCode) { +
{{ u.neptunCode }}
+ } +
+ + + @if (u.courses?.length) { +
+ @for (c of u.courses; track c.courseId) { + + {{ c.slug }} + @if (c.role === 'Admin') { + · admin + } + + } +
+ } @else { + None + } +
+ +
+
+
+

Course assignments

+ @if (u.courses?.length) { +
    + @for (c of u.courses; track c.courseId) { +
  • + {{ c.slug }} + {{ c.name }} + + + +
  • + } +
+ } @else { +

Not assigned to any course.

+ } + + @if (assignableCourses(u).length > 0) { +
+ + + +
+ } +
+ + +
+
No account matches this search.
+
+ + @if (total() > users().length) { +
+ + +
+ } + } +
+
diff --git a/ahk-frontend/src/app/features/admin/users/users.scss b/ahk-frontend/src/app/features/admin/users/users.scss new file mode 100644 index 0000000..206261b --- /dev/null +++ b/ahk-frontend/src/app/features/admin/users/users.scss @@ -0,0 +1,100 @@ +.search { + flex: 1 1 18rem; + max-width: 26rem; +} + +.filter, +select { + width: auto; + min-width: 9rem; +} + +td .muted { + font-size: 0.75rem; +} + +.course-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + max-width: 22rem; +} + +/* Managing one user opens a drawer under their row, so the list keeps its place. */ +tr.drawer td { + background: var(--surface-2); + padding: 0; +} + +.drawer-inner { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 1.5rem; + padding: 1.125rem; + border-top: 1px solid var(--rule); +} + +@media (max-width: 60rem) { + .drawer-inner { + grid-template-columns: 1fr; + } +} + +h3 { + margin-bottom: 0.625rem; + color: var(--ink-2); + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 0.6875rem; +} + +.assigned { + list-style: none; + margin: 0 0 0.875rem; + padding: 0; + + li { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; + padding: 0.375rem 0; + border-bottom: 1px solid var(--rule); + font-size: 0.8125rem; + } + + .spacer { + flex: 1 1 auto; + } +} + +.add-course { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.account .btn { + margin-bottom: 0.5rem; +} + +.small { + font-size: 0.75rem; + margin: 0; +} + +.password-row { + display: flex; + align-items: stretch; + gap: 0.5rem; + + input { + flex: 1 1 auto; + } + + .btn { + flex: 0 0 auto; + white-space: nowrap; + } +} diff --git a/ahk-frontend/src/app/features/admin/users/users.ts b/ahk-frontend/src/app/features/admin/users/users.ts new file mode 100644 index 0000000..540c252 --- /dev/null +++ b/ahk-frontend/src/app/features/admin/users/users.ts @@ -0,0 +1,296 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Subject, debounceTime, distinctUntilChanged, switchMap } from 'rxjs'; + +import { + CourseDto, + CourseRole, + CoursesAdminClient, + CreateUserRequest, + UserDto, + UsersAdminClient, +} from '../../../api/api-client'; +import { readApiError } from '../../../core/api-error'; + +const SITE_ADMIN = 'Admin'; + +/** + * The register of accounts: who exists, what they can do site-wide, and which courses they are assigned to. + * Roles and course assignments are edited in place — they are the two things an admin changes often, and a + * separate edit screen for each would double the clicks. + */ +@Component({ + selector: 'app-admin-users', + imports: [FormsModule], + templateUrl: './users.html', + styleUrl: './users.scss', +}) +export class AdminUsers implements OnInit { + private readonly client = inject(UsersAdminClient); + private readonly coursesClient = inject(CoursesAdminClient); + + protected readonly users = signal([]); + protected readonly total = signal(0); + protected readonly courses = signal([]); + protected readonly loading = signal(false); + protected readonly error = signal(null); + protected readonly saved = signal(null); + + protected search = ''; + protected courseFilter = ''; + private readonly pageSize = 50; + protected readonly skip = signal(0); + + private readonly searchTerm = new Subject(); + + /** The user row currently expanded for course assignment. */ + protected readonly expanded = signal(null); + + protected readonly adding = signal(false); + protected readonly savingNew = signal(false); + protected newUserName = ''; + protected newEmail = ''; + protected newDisplayName = ''; + protected newNeptun = ''; + protected newPassword = ''; + + protected readonly showingRange = computed(() => { + const from = this.users().length === 0 ? 0 : this.skip() + 1; + return { from, to: this.skip() + this.users().length }; + }); + + ngOnInit(): void { + this.coursesClient.list().subscribe({ + next: (list) => this.courses.set(list), + error: () => this.error.set('The course list could not be loaded, so course assignment is unavailable.'), + }); + + // Typing a search re-queries the server rather than filtering a page, so a name outside the first 50 rows + // is still findable. + this.searchTerm + .pipe( + debounceTime(250), + distinctUntilChanged(), + switchMap(() => { + this.loading.set(true); + return this.query(); + }), + ) + .subscribe({ + next: (page) => { + this.users.set(page.items ?? []); + this.total.set(page.total ?? 0); + this.loading.set(false); + }, + error: () => { + this.error.set('The users could not be loaded.'); + this.loading.set(false); + }, + }); + + this.reload(); + } + + private query() { + return this.client.list( + this.search.trim() || undefined, + this.courseFilter ? Number(this.courseFilter) : undefined, + this.skip(), + this.pageSize, + ); + } + + protected reload(): void { + this.loading.set(true); + this.query().subscribe({ + next: (page) => { + this.users.set(page.items ?? []); + this.total.set(page.total ?? 0); + this.loading.set(false); + }, + error: () => { + this.error.set('The users could not be loaded.'); + this.loading.set(false); + }, + }); + } + + protected onSearchChange(): void { + this.skip.set(0); + this.searchTerm.next(`${this.search}|${this.courseFilter}`); + } + + protected page(delta: number): void { + this.skip.set(Math.max(0, this.skip() + delta * this.pageSize)); + this.reload(); + } + + // ---- Site role ---- + + protected isSiteAdmin(user: UserDto): boolean { + return (user.roles ?? []).includes(SITE_ADMIN); + } + + protected toggleSiteAdmin(user: UserDto, makeAdmin: boolean): void { + this.clearMessages(); + const roles = makeAdmin ? [SITE_ADMIN] : []; + + this.client.updateRoles(user.id ?? 0, { roles }).subscribe({ + next: (updated) => { + this.replace(updated); + this.saved.set( + makeAdmin + ? `${updated.userName} is now a site admin.` + : `${updated.userName} is no longer a site admin.`, + ); + }, + error: (err: unknown) => { + // The API refuses to let an admin strip their own role; surface its reason rather than a generic one. + this.error.set(readApiError(err, 'That role could not be changed.')); + this.reload(); + }, + }); + } + + // ---- Course assignment ---- + + protected toggleExpanded(user: UserDto): void { + this.expanded.set(this.expanded() === user.id ? null : (user.id ?? null)); + } + + protected assignableCourses(user: UserDto): CourseDto[] { + const assigned = new Set((user.courses ?? []).map((c) => c.courseId)); + return this.courses().filter((c) => !assigned.has(c.id)); + } + + protected assign(user: UserDto, courseId: string, role: CourseRole): void { + if (!courseId) { + return; + } + this.clearMessages(); + this.client.upsertCourse(user.id ?? 0, { courseId: Number(courseId), role }).subscribe({ + next: (updated) => { + this.replace(updated); + this.saved.set(`${updated.userName}'s course assignments were updated.`); + }, + error: () => this.error.set('That course could not be assigned.'), + }); + } + + protected changeCourseRole(user: UserDto, courseId: number, role: string): void { + this.clearMessages(); + this.client.upsertCourse(user.id ?? 0, { courseId, role: role as CourseRole }).subscribe({ + next: (updated) => this.replace(updated), + error: () => this.error.set('That role could not be changed.'), + }); + } + + protected unassign(user: UserDto, courseId: number): void { + this.clearMessages(); + this.client.removeCourse(user.id ?? 0, courseId).subscribe({ + next: (updated) => this.replace(updated), + error: () => this.error.set('That course could not be removed.'), + }); + } + + // ---- Create ---- + + protected createUser(): void { + this.clearMessages(); + this.savingNew.set(true); + + const request: CreateUserRequest = { + userName: this.newUserName.trim(), + email: this.newEmail.trim() || undefined, + displayName: this.newDisplayName.trim() || undefined, + neptunCode: this.newNeptun.trim() || undefined, + password: this.newPassword, + }; + + this.client.create(request).subscribe({ + next: (user) => { + this.savingNew.set(false); + this.cancelAdding(); + this.saved.set(`${user.userName} was created.`); + this.reload(); + }, + error: (err: unknown) => { + this.savingNew.set(false); + this.error.set(readApiError(err, 'That account could not be created.')); + }, + }); + } + + /** + * Fill the password field with a fresh 16-character secret drawn from the CSPRNG. The character set spans + * upper/lower letters, digits and symbols; rejection sampling keeps the distribution uniform (a plain + * `% length` would bias toward the first characters). We seed one of each class up front so the result + * always satisfies a mixed-complexity policy, then shuffle so the class positions are not predictable. + */ + protected generatePassword(): void { + const classes = [ + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'abcdefghijklmnopqrstuvwxyz', + '0123456789', + '!@#$%^&*()-_=+[]{};:,.?', + ]; + const all = classes.join(''); + const length = 16; + + // Uniform integer in [0, bound) via rejection sampling — a plain `% bound` would bias toward small values. + const randomInt = (bound: number): number => { + const max = Math.floor(256 / bound) * bound; + const buf = new Uint8Array(1); + let byte: number; + do { + crypto.getRandomValues(buf); + byte = buf[0]; + } while (byte >= max); + return byte % bound; + }; + + const pick = (set: string): string => set[randomInt(set.length)]; + + const chars = classes.map((set) => pick(set)); + while (chars.length < length) { + chars.push(pick(all)); + } + + // Fisher–Yates with CSPRNG-drawn indices so the guaranteed one-per-class characters are not stuck at the front. + for (let i = chars.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + + this.newPassword = chars.join(''); + } + + protected cancelAdding(): void { + this.adding.set(false); + this.newUserName = ''; + this.newEmail = ''; + this.newDisplayName = ''; + this.newNeptun = ''; + this.newPassword = ''; + } + + protected deleteUser(user: UserDto): void { + this.clearMessages(); + this.client.delete(user.id ?? 0).subscribe({ + next: () => { + this.saved.set(`${user.userName} was deleted.`); + this.reload(); + }, + error: (err: unknown) => this.error.set(readApiError(err, 'That account could not be deleted.')), + }); + } + + private replace(user: UserDto): void { + this.users.set(this.users().map((u) => (u.id === user.id ? user : u))); + } + + private clearMessages(): void { + this.error.set(null); + this.saved.set(null); + } +} diff --git a/ahk-frontend/src/app/features/course/assignments/assignments.html b/ahk-frontend/src/app/features/course/assignments/assignments.html new file mode 100644 index 0000000..2c3b9b8 --- /dev/null +++ b/ahk-frontend/src/app/features/course/assignments/assignments.html @@ -0,0 +1,273 @@ +
+
+
+ Course +

Assignments

+

+ Each assignment is a template repository plus a link. Share the link with your students and they get + their own private copy, named after their Neptun code, with write access. +

+
+
+ + +
+
+ + @if (error(); as e) { +

{{ e }}

+ } + @if (saved(); as s) { +

{{ s }}

+ } + @if (templateWarning(); as w) { +

+ Saved, but the template repository has a problem — students will not be able to accept until it is fixed: + {{ w }} +

+ } + + @if (creating() || editingId() !== null) { +
+
+
+

{{ editingId() === null ? 'Add assignment' : 'Edit assignment' }}

+

+ The template repository must live in this course's organization and be marked as a template on + GitHub. +

+
+
+ +
+
+
+
+ + + Shown to students on the accept screen. +
+
+ + +
+ + +
+ + A bare name is taken to be in this course's organization, and must be marked as a template on + GitHub. + +
+
+ + @if (template(); as t) { + @if (t.problem) { +

{{ t.problem }}

+ } @else { +

+ The template is reachable and marked as a template repository. + @if (t.htmlUrl) { + Open it on GitHub + } +

+ } + } + +
+
+ + + Optional. Shown under the assignment name on the accept screen. +
+
+ + + + Cloned student repositories are named <prefix>-<neptun>. + Leave blank to use the template repository's name. + +
+
+
+ +
+ + +
+
+
+ } + + @if (loading()) { +

Loading assignments…

+ } @else if (assignments().length === 0) { +
+

+ No assignments yet. Add one against a template repository, then share its invite link with your + students. +

+
+ } @else { + @for (a of assignments(); track a.id) { +
+
+
+

+ {{ a.name }} + @if (a.isArchived) { + Archived + } +

+

{{ a.templateRepoName }}

+
+
+ +
+ @if (a.description) { +

{{ a.description }}

+ } + + @if (a.isArchived) { +

+ This assignment no longer accepts new students. The ones who already accepted keep their + repositories and their link. +

+ } + + +
+ +
+ + +
+ + Anyone signed in who has this link can create their repository, so treat it as the key to the + assignment. Reissuing it retires the old one. + +
+ +

+ Created {{ a.createdAt | date: 'yyyy-MM-dd' }} · + +

+ + @if (expandedId() === a.id) { + @if (loadingAcceptances()) { +

Loading…

+ } @else { +
+ + + + + + + + + + + + + @for (acceptance of acceptances(); track acceptance.id) { + + + + + + + + + } @empty { + + + + } + +
StudentNeptunGitHubRepositoryAcceptedAccess
{{ acceptance.displayName || acceptance.userName }}{{ acceptance.neptunCode || '—' }}{{ acceptance.gitHubUsername }} + {{ + acceptance.gitHubRepoName + }} + {{ acceptance.acceptedAt | date: 'yyyy-MM-dd HH:mm' }} + @if (acceptance.invitationPending) { + Invitation waiting + } @else { + Ready + } +
Nobody has accepted this assignment yet.
+
+ } + } +
+ +
+ + + @if (a.isArchived) { + + } @else { + + } + + + + + @if (a.acceptanceCount === 0) { + + } +
+
+ } + } +
diff --git a/ahk-frontend/src/app/features/course/assignments/assignments.scss b/ahk-frontend/src/app/features/course/assignments/assignments.scss new file mode 100644 index 0000000..d5af30e --- /dev/null +++ b/ahk-frontend/src/app/features/course/assignments/assignments.scss @@ -0,0 +1,114 @@ +.card + .card { + margin-top: 1.25rem; +} + +h2 { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 1.125rem; +} + +/* An archived assignment stays legible but visibly steps back — it is history, not a current handout. */ +.assignment.archived { + header h2, + .card-body { + opacity: 0.72; + } +} + +/* The invite link block: the one thing an instructor comes to this screen to get. */ +.invite { + margin-top: 1rem; + + > label { + display: block; + margin-bottom: 0.375rem; + font-family: var(--font-ui); + font-size: 0.6875rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-2); + } + + .hint { + display: block; + margin-top: 0.375rem; + font-family: var(--font-ui); + font-size: 0.6875rem; + color: var(--ink-2); + } +} + +.invite-row { + display: flex; + gap: 0.5rem; + + input { + flex: 1 1 auto; + min-width: 0; + font-size: 0.8125rem; + } + + .btn { + flex: none; + min-width: 5.5rem; + justify-content: center; + } +} + +.meta { + margin: 1rem 0 0; + font-size: 0.75rem; +} + +/* The template field with its Validate button attached, so the action reads as part of the field. */ +.input-with-action { + display: flex; + gap: 0.5rem; + align-items: stretch; + + input { + flex: 1 1 auto; + min-width: 0; + } + + .btn { + flex: none; + justify-content: center; + } +} + +/* A crimson-outlined button — clearly a control, not the transparent "quiet" it replaced, but subordinate to + the solid crimson Save so the two never compete. */ +.btn.accent { + border-color: var(--accent); + background: var(--surface); + color: var(--accent); + font-weight: 600; + + &:hover:not(:disabled) { + background: var(--accent-wash); + border-color: var(--accent-ink); + color: var(--accent-ink); + } +} + +/* Prose-weight toggle for the acceptance list — a button here would compete with the card's real actions. */ +.linkish { + border: 0; + background: none; + padding: 0; + font: inherit; + color: var(--link); + cursor: pointer; + text-decoration: underline; + + &:hover { + color: var(--link-hover); + } +} + +.table-wrap { + margin-top: 0.875rem; +} diff --git a/ahk-frontend/src/app/features/course/assignments/assignments.ts b/ahk-frontend/src/app/features/course/assignments/assignments.ts new file mode 100644 index 0000000..d06498b --- /dev/null +++ b/ahk-frontend/src/app/features/course/assignments/assignments.ts @@ -0,0 +1,289 @@ +import { DatePipe } from '@angular/common'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { + AssignmentAcceptanceDto, + AssignmentDto, + AssignmentsClient, + SaveAssignmentRequest, + TemplateCheckDto, +} from '../../../api/api-client'; +import { readApiError } from '../../../core/api-error'; +import { copyToClipboard } from '../../../core/clipboard'; +import { CourseContextService } from '../../../core/course/course-context.service'; + +/** + * Assignment administration for a course — what used to be set up in GitHub Classroom. + * + * The invite link is the point of this screen, so copying it is a first-class action rather than something + * the instructor has to select out of a table cell. + */ +@Component({ + selector: 'app-course-assignments', + imports: [FormsModule, DatePipe], + templateUrl: './assignments.html', + styleUrl: './assignments.scss', +}) +export class CourseAssignments implements OnInit { + private readonly client = inject(AssignmentsClient); + private readonly courseContext = inject(CourseContextService); + + protected readonly assignments = signal([]); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly saved = signal(null); + protected readonly showArchived = signal(false); + + /** Which invite link was copied last, so only that row shows the confirmation. */ + protected readonly copiedId = signal(null); + + // ---- Editor ---- + protected readonly editingId = signal(null); + protected readonly creating = signal(false); + protected readonly savingForm = signal(false); + protected name = ''; + protected description = ''; + protected templateRepoName = ''; + protected repoNamePrefix = ''; + + /** Advisory check of the template repository; costs a GitHub call, so it is only run on demand. */ + protected readonly template = signal(null); + protected readonly checkingTemplate = signal(false); + /** After a save, the template problem (if any) for the just-saved assignment, shown above the list. */ + protected readonly templateWarning = signal(null); + + // ---- Acceptance roster ---- + protected readonly expandedId = signal(null); + protected readonly acceptances = signal([]); + protected readonly loadingAcceptances = signal(false); + + private get course(): string { + return this.courseContext.activeSlug() ?? ''; + } + + ngOnInit(): void { + this.load(); + } + + protected load(): void { + this.loading.set(true); + this.client.list(this.course, this.showArchived()).subscribe({ + next: (assignments) => { + this.assignments.set(assignments); + this.loading.set(false); + }, + error: (err: unknown) => { + this.error.set(readApiError(err, 'The assignments could not be loaded.')); + this.loading.set(false); + }, + }); + } + + protected toggleArchived(): void { + this.showArchived.update((v) => !v); + this.load(); + } + + // ---- Editor ---- + + protected startCreate(): void { + this.clearMessages(); + this.creating.set(true); + this.editingId.set(null); + this.template.set(null); + this.name = ''; + this.description = ''; + this.templateRepoName = ''; + this.repoNamePrefix = ''; + } + + protected startEdit(assignment: AssignmentDto): void { + this.clearMessages(); + this.creating.set(false); + this.editingId.set(assignment.id ?? null); + this.template.set(null); + this.name = assignment.name ?? ''; + this.description = assignment.description ?? ''; + this.templateRepoName = assignment.templateRepoName ?? ''; + this.repoNamePrefix = assignment.repoNamePrefix ?? ''; + } + + protected cancelEdit(): void { + this.creating.set(false); + this.editingId.set(null); + this.template.set(null); + } + + protected save(): void { + this.clearMessages(); + this.savingForm.set(true); + + const request: SaveAssignmentRequest = { + name: this.name.trim(), + description: this.description.trim() || undefined, + templateRepoName: this.templateRepoName.trim(), + repoNamePrefix: this.repoNamePrefix.trim() || undefined, + }; + + const id = this.editingId(); + const call = id === null ? this.client.create(request, this.course) : this.client.update(id, request, this.course); + + call.subscribe({ + next: (assignment) => { + this.savingForm.set(false); + this.saved.set( + id === null + ? `"${assignment.name}" was created. Copy its invite link and share it with your students.` + : `"${assignment.name}" was saved.`, + ); + this.cancelEdit(); + this.load(); + // Re-verify the template now that it is saved, so a tester who never opens the check still sees a + // problem before students hit it at accept time. Advisory: it never affects whether the save succeeded. + this.verifySavedTemplate(assignment.name ?? '', request.templateRepoName ?? ''); + }, + error: (err: unknown) => { + this.savingForm.set(false); + this.error.set(readApiError(err, 'The assignment could not be saved.')); + }, + }); + } + + /** + * Asks GitHub whether the template repository exists and is marked as a template. Advisory only — an + * assignment may legitimately be drafted before its template does. Works in both create and edit mode + * because it checks the name currently in the field, not a stored assignment. + */ + protected checkTemplate(): void { + const repo = this.templateRepoName.trim(); + if (!repo) { + return; + } + + this.checkingTemplate.set(true); + this.client.checkTemplate({ templateRepoName: repo }, this.course).subscribe({ + next: (result) => { + this.template.set(result); + this.checkingTemplate.set(false); + }, + error: () => this.checkingTemplate.set(false), + }); + } + + /** Runs the template check for a just-saved assignment and, if there is a problem, surfaces it above the list. */ + private verifySavedTemplate(assignmentName: string, templateRepoName: string): void { + this.templateWarning.set(null); + if (!templateRepoName) { + return; + } + + this.client.checkTemplate({ templateRepoName }, this.course).subscribe({ + next: (result) => { + if (result.problem) { + this.templateWarning.set(`"${assignmentName}": ${result.problem}`); + } + }, + // A failed advisory check must never look like a failed save; stay silent. + error: () => {}, + }); + } + + // ---- Invite link ---- + + /** + * The API returns the invite link as a path, not an absolute URL — behind the dev proxy it would otherwise + * be built from a rewritten Host header and point at the backend's port. The browser's own origin is by + * definition the one the student will use. + */ + protected inviteUrl(assignment: AssignmentDto): string { + return `${window.location.origin}${assignment.invitePath ?? ''}`; + } + + protected async copyInvite(assignment: AssignmentDto): Promise { + this.clearMessages(); + + const copied = await copyToClipboard(this.inviteUrl(assignment)); + if (copied) { + this.copiedId.set(assignment.id ?? null); + setTimeout(() => this.copiedId.set(null), 2500); + } else { + this.error.set('The link could not be copied automatically — select it and copy it by hand.'); + } + } + + /** Retires the current link. Anyone holding the old one gets "this invite link does not match an assignment". */ + protected regenerateInvite(assignment: AssignmentDto): void { + this.clearMessages(); + this.client.regenerateInvite(assignment.id ?? 0, this.course).subscribe({ + next: () => { + this.saved.set( + `"${assignment.name}" has a new invite link. The previous one no longer works — share the new one.`, + ); + this.load(); + }, + error: (err: unknown) => this.error.set(readApiError(err, 'A new invite link could not be issued.')), + }); + } + + // ---- Lifecycle ---- + + protected setArchived(assignment: AssignmentDto, archived: boolean): void { + this.clearMessages(); + + const id = assignment.id ?? 0; + const call = archived ? this.client.archive(id, this.course) : this.client.unarchive(id, this.course); + + call.subscribe({ + next: () => { + this.saved.set( + archived + ? `"${assignment.name}" was archived. Its invite link no longer accepts new students; the ones who already accepted keep their repositories.` + : `"${assignment.name}" was reopened and accepts students again.`, + ); + this.load(); + }, + error: (err: unknown) => this.error.set(readApiError(err, 'That could not be changed.')), + }); + } + + protected remove(assignment: AssignmentDto): void { + this.clearMessages(); + this.client.delete(assignment.id ?? 0, this.course).subscribe({ + next: () => { + this.saved.set(`"${assignment.name}" was deleted.`); + this.load(); + }, + error: (err: unknown) => this.error.set(readApiError(err, 'That assignment could not be deleted.')), + }); + } + + // ---- Acceptances ---- + + protected toggleAcceptances(assignment: AssignmentDto): void { + const id = assignment.id ?? 0; + + if (this.expandedId() === id) { + this.expandedId.set(null); + return; + } + + this.expandedId.set(id); + this.acceptances.set([]); + this.loadingAcceptances.set(true); + + this.client.listAcceptances(id, this.course).subscribe({ + next: (acceptances) => { + this.acceptances.set(acceptances); + this.loadingAcceptances.set(false); + }, + error: () => this.loadingAcceptances.set(false), + }); + } + + private clearMessages(): void { + this.error.set(null); + this.saved.set(null); + this.templateWarning.set(null); + } +} diff --git a/ahk-frontend/src/app/features/course/dashboard/dashboard.html b/ahk-frontend/src/app/features/course/dashboard/dashboard.html new file mode 100644 index 0000000..b5e7513 --- /dev/null +++ b/ahk-frontend/src/app/features/course/dashboard/dashboard.html @@ -0,0 +1,157 @@ +
+
+
+ {{ courseContext.activeSlug() }} +

{{ courseContext.activeCourse()?.name || 'Submissions' }}

+

Every student repository in this course, its state on GitHub, and its points.

+
+
+ + +
+
+ + @if (error(); as e) { +

{{ e }}

+ } + + @if (!loading() && statuses().length > 0) { +
+
+ {{ summary().total }} + Submissions +
+
+ {{ summary().graded }} + Graded +
+
+ {{ summary().openPrs }} + With a pull request +
+
+ {{ summary().failing }} + Last run failed +
+
+ } + +
+
+ + + + + @if (filtered()) { + {{ rows().length }} of {{ summary().total }} + + } +
+ + @if (loading()) { +

Loading submissions…

+ } @else { +
+ + + + + + + + + @for (ex of exerciseNames(); track ex) { + + } + @if (exerciseNames().length > 0) { + + } + + + + @for (s of rows(); track s.repository) { + + + + + + + @for (ex of exerciseNames(); track ex) { + + } + @if (exerciseNames().length > 0) { + + } + + } @empty { + + + + } + +
BranchesPull request{{ ex }}
{{ s.neptun || '—' }} + + {{ s.repository }} + + {{ s.branches?.join(', ') || '—' }} + @if (s.pullRequests?.length) { + @for (pr of s.pullRequests; track pr.number) { +
+ #{{ pr.number }} + {{ pr.status }} + @if (pr.assignee) { + {{ pr.assignee }} + } +
+ } + } @else { + + } +
+ {{ s.workflowRuns?.count ?? 0 }} + @if (s.workflowRuns?.lastStatus; as last) { + {{ last }} + } + + @if (pointsFor(s.repository, ex) !== null) { + {{ pointsFor(s.repository, ex) | number: '1.0-2' }} + } @else { + + } + + @if (totalFor(s.repository) !== null) { + {{ totalFor(s.repository) | number: '1.0-2' }} + } @else { + + } +
+ @if (filtered()) { + Nothing matches these filters. + + } @else { + No submissions in this course yet. They appear once students push to their repositories. + } +
+
+ } +
+
diff --git a/ahk-frontend/src/app/features/course/dashboard/dashboard.scss b/ahk-frontend/src/app/features/course/dashboard/dashboard.scss new file mode 100644 index 0000000..23d737d --- /dev/null +++ b/ahk-frontend/src/app/features/course/dashboard/dashboard.scss @@ -0,0 +1,71 @@ +.tally { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.tile { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 8rem; + padding: 0.625rem 0.875rem; + background: var(--surface); + border: 1px solid var(--rule); + border-left: 3px solid var(--rule-strong); + border-radius: var(--radius); + + &.bad { + border-left-color: var(--bad); + } + + .n { + font-family: var(--font-mono); + font-size: 1.375rem; + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + } + + .l { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-2); + } +} + +.search { + flex: 1 1 16rem; + max-width: 24rem; +} + +/* Column headers double as sort controls; they keep the header's own type so the row still reads as a header. */ +.sort { + border: 0; + background: none; + padding: 0; + font: inherit; + color: inherit; + letter-spacing: inherit; + text-transform: inherit; + cursor: pointer; + + &:hover { + color: var(--accent); + } +} + +.pr { + display: flex; + align-items: center; + gap: 0.375rem; + flex-wrap: wrap; +} + +td.total { + font-weight: 600; + border-left: 1px solid var(--rule); +} diff --git a/ahk-frontend/src/app/features/course/dashboard/dashboard.ts b/ahk-frontend/src/app/features/course/dashboard/dashboard.ts new file mode 100644 index 0000000..e95f31f --- /dev/null +++ b/ahk-frontend/src/app/features/course/dashboard/dashboard.ts @@ -0,0 +1,168 @@ +import { DecimalPipe } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { forkJoin } from 'rxjs'; + +import { FinalStudentGrade, GradesClient, RepositoryStatus, SubmissionStatusesClient } from '../../../api/api-client'; +import { CourseContextService } from '../../../core/course/course-context.service'; + +type SortKey = 'neptun' | 'repository' | 'runs' | 'total'; + +/** + * The instructor's view of a course: every submission, its state on GitHub, and its points. Site admins reach + * the same screen for any course, so nothing here assumes an explicit membership. + * + * Search, filtering and sorting all happen client-side — a course is a few hundred rows at most, and a + * round-trip per keystroke would be slower than the reader. + */ +@Component({ + selector: 'app-course-dashboard', + imports: [DecimalPipe, FormsModule], + templateUrl: './dashboard.html', + styleUrl: './dashboard.scss', +}) +export class CourseDashboard implements OnInit { + private readonly statusesClient = inject(SubmissionStatusesClient); + private readonly gradesClient = inject(GradesClient); + protected readonly courseContext = inject(CourseContextService); + + protected readonly statuses = signal([]); + protected readonly grades = signal>(new Map()); + protected readonly exerciseNames = signal([]); + protected readonly loading = signal(false); + protected readonly error = signal(null); + + protected search = ''; + protected readonly searchTerm = signal(''); + protected readonly onlyUngraded = signal(false); + protected readonly onlyFailing = signal(false); + protected readonly sortKey = signal('neptun'); + protected readonly sortAsc = signal(true); + + protected readonly rows = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const grades = this.grades(); + + let rows = this.statuses().filter((s) => { + if (term && !`${s.neptun ?? ''} ${s.repository ?? ''}`.toLowerCase().includes(term)) { + return false; + } + if (this.onlyUngraded() && grades.has(s.repository ?? '')) { + return false; + } + if (this.onlyFailing() && s.workflowRuns?.lastStatus !== 'failure') { + return false; + } + return true; + }); + + const key = this.sortKey(); + const direction = this.sortAsc() ? 1 : -1; + rows = [...rows].sort((a, b) => direction * this.compare(a, b, key)); + return rows; + }); + + protected readonly summary = computed(() => { + const all = this.statuses(); + const grades = this.grades(); + return { + total: all.length, + graded: all.filter((s) => grades.has(s.repository ?? '')).length, + openPrs: all.filter((s) => (s.pullRequests?.length ?? 0) > 0).length, + failing: all.filter((s) => s.workflowRuns?.lastStatus === 'failure').length, + }; + }); + + ngOnInit(): void { + this.reload(); + } + + protected reload(): void { + const slug = this.courseContext.activeSlug(); + if (!slug) { + return; + } + + this.loading.set(true); + this.error.set(null); + + forkJoin({ statuses: this.statusesClient.list(slug), grades: this.gradesClient.list(slug) }).subscribe({ + next: ({ statuses, grades }) => { + this.statuses.set(statuses); + this.grades.set(new Map(grades.map((g) => [g.repo ?? '', g]))); + + // Union of exercise names across students drives the table columns, like the CSV export. + const names = new Set(); + for (const g of grades) { + Object.keys(g.points ?? {}).forEach((n) => names.add(n)); + } + this.exerciseNames.set([...names].sort()); + this.loading.set(false); + }, + error: () => { + this.error.set('This course’s submissions could not be loaded.'); + this.loading.set(false); + }, + }); + } + + protected onSearchChange(): void { + this.searchTerm.set(this.search); + } + + protected sortBy(key: SortKey): void { + if (this.sortKey() === key) { + this.sortAsc.set(!this.sortAsc()); + } else { + this.sortKey.set(key); + this.sortAsc.set(true); + } + } + + protected pointsFor(repo: string | undefined, exercise: string): number | null { + return this.grades().get(repo ?? '')?.points?.[exercise] ?? null; + } + + protected totalFor(repo: string | undefined): number | null { + const points = this.grades().get(repo ?? '')?.points; + if (!points) { + return null; + } + return Object.values(points).reduce((sum, p) => sum + p, 0); + } + + protected prUrlFor(status: RepositoryStatus): string | undefined { + return status.pullRequests?.[0]?.htmlUrl ?? undefined; + } + + protected downloadCsv(): void { + const slug = this.courseContext.activeSlug(); + if (slug) { + window.location.href = `/api/${slug}/grades/csv`; + } + } + + protected clearFilters(): void { + this.search = ''; + this.searchTerm.set(''); + this.onlyUngraded.set(false); + this.onlyFailing.set(false); + } + + protected readonly filtered = computed( + () => this.searchTerm().trim().length > 0 || this.onlyUngraded() || this.onlyFailing(), + ); + + private compare(a: RepositoryStatus, b: RepositoryStatus, key: SortKey): number { + switch (key) { + case 'repository': + return (a.repository ?? '').localeCompare(b.repository ?? ''); + case 'runs': + return (a.workflowRuns?.count ?? 0) - (b.workflowRuns?.count ?? 0); + case 'total': + return (this.totalFor(a.repository) ?? -1) - (this.totalFor(b.repository) ?? -1); + default: + return (a.neptun ?? '').localeCompare(b.neptun ?? ''); + } + } +} diff --git a/ahk-frontend/src/app/features/invite/invite.html b/ahk-frontend/src/app/features/invite/invite.html new file mode 100644 index 0000000..5f06c2a --- /dev/null +++ b/ahk-frontend/src/app/features/invite/invite.html @@ -0,0 +1,182 @@ +
+
+ +

AHK — Automated homework evaluation

+
+ + @if (loading()) { +
+
+

Opening the invitation…

+
+
+ } @else if (state(); as s) { +
+
+
+ {{ s.courseName }} +

{{ s.assignmentName }}

+
+
+ +
+ @switch (s.status) { + + @case ('ReadyToAccept') { +

Accept the assignment — {{ s.assignmentName }}

+ + @if (s.assignmentDescription) { +

{{ s.assignmentDescription }}

+ } + +

+ Once you accept this assignment, you will be granted access to the + {{ s.repositoryName }} + repository in the + {{ s.organization }} + organization on GitHub. +

+ +

+ It will be shared with your GitHub account, + {{ s.gitHubUsername }}. +

+ + @if (error(); as e) { +

{{ e }}

+ } + +
+ +
+ } + + @case ('Accepted') { +

You're ready to go!

+

You accepted the assignment, {{ s.assignmentName }}

+ + @if (s.invitationUrl) { + +

+ GitHub has invited you to your repository. Accept the invitation and the repository is + yours — the link below takes you straight to it. +

+

{{ s.repositoryName }}

+ +
+ +
+ } @else { +

Your assignment repository has been created:

+

+ {{ s.repoUrl }} +

+

We've configured the repository associated with this assignment.

+ + @if (s.message) { +

{{ s.message }}

+ } + +
+ + + @if (redirectingIn(); as seconds) { + + } +
+ } + } + + + @case ('NeedsGitHubUsername') { +

First, tell us your GitHub username

+

+ This site works by giving you your own repository on GitHub, so it needs to know which + account is yours. We check the name against GitHub before saving it, and you will only be + asked this once. +

+ +
+
+ + + + The name in your profile URL — github.com/username. Not your email + address. + +
+ + @if (error(); as e) { +

{{ e }}

+ } + +
+ +
+
+ } + + + @case ('NeedsNeptun') { +

{{ s.message }}

+

+ Repositories are named after your Neptun code, which comes from your university account. Sign + out and sign back in with eduID, and this page will work. +

+ } + + @case ('Closed') { +

{{ s.message }}

+

+ If you think you should still be able to accept it, ask your instructor — they can reopen the + assignment. +

+ } + + @case ('NotConfigured') { +

{{ s.message }}

+ } + + @default { +

+ {{ s.message || 'This invite link does not match an assignment.' }} +

+

Ask your instructor for a current link — invite links can be reissued, which retires the old one.

+ } + } +
+
+ } @else { +
+
+

{{ error() || 'This invite link could not be opened.' }}

+
+
+ } +
diff --git a/ahk-frontend/src/app/features/invite/invite.scss b/ahk-frontend/src/app/features/invite/invite.scss new file mode 100644 index 0000000..932848c --- /dev/null +++ b/ahk-frontend/src/app/features/invite/invite.scss @@ -0,0 +1,56 @@ +/* Same standalone, centred frame as the login screen: a student following an invite link has no course + context yet, so there is no rail and no switcher to sit inside. */ +.invite { + max-width: 32rem; + margin: 0 auto; + padding: 3rem 1.25rem 4rem; +} + +.mark { + display: flex; + flex-direction: column; + gap: 0.875rem; + margin-bottom: 1.5rem; + + .logo { + display: block; + width: 100%; + max-width: 17rem; + height: auto; + } +} + +.product { + font-size: 1rem; +} + +h2 { + font-size: 1.25rem; +} + +.lede { + font-size: 0.9375rem; +} + +/* The repository is the thing the student came for — give it room and set it in the machine face. */ +.repo { + margin: 0.75rem 0; + padding: 0.625rem 0.75rem; + border: 1px solid var(--rule); + border-radius: var(--radius); + background: var(--paper); + font-size: 0.8125rem; + overflow-wrap: anywhere; + + a { + color: var(--link); + + &:hover { + color: var(--link-hover); + } + } +} + +.btn-row { + margin-top: 1.25rem; +} diff --git a/ahk-frontend/src/app/features/invite/invite.ts b/ahk-frontend/src/app/features/invite/invite.ts new file mode 100644 index 0000000..f444e91 --- /dev/null +++ b/ahk-frontend/src/app/features/invite/invite.ts @@ -0,0 +1,148 @@ +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { AssignmentInviteClient, InviteState, ProfileClient } from '../../api/api-client'; +import { readApiError } from '../../core/api-error'; +import { AuthService } from '../../core/auth/auth.service'; + +/** + * What a student sees when they follow an assignment's invite link — the replacement for GitHub Classroom's + * accept page. + * + * One component for the whole flow, driven entirely by the `status` the API returns rather than by local + * step-counting: the server decides what is still missing, and re-decides it on accept, so a reload or a + * back-button never lands the student in a state the server disagrees with. + */ +@Component({ + selector: 'app-invite', + imports: [FormsModule], + templateUrl: './invite.html', + styleUrl: './invite.scss', +}) +export class Invite implements OnInit, OnDestroy { + private readonly inviteClient = inject(AssignmentInviteClient); + private readonly profileClient = inject(ProfileClient); + private readonly auth = inject(AuthService); + private readonly route = inject(ActivatedRoute); + + protected readonly state = signal(null); + protected readonly loading = signal(true); + protected readonly accepting = signal(false); + protected readonly error = signal(null); + + /** Counts down the redirect to GitHub, so the confirmation is readable before the page moves on. */ + protected readonly redirectingIn = signal(null); + + protected gitHubUsername = ''; + protected readonly savingUsername = signal(false); + + private course = ''; + private token = ''; + private redirectTimer?: ReturnType; + + ngOnInit(): void { + this.course = this.route.snapshot.paramMap.get('course') ?? ''; + this.token = this.route.snapshot.paramMap.get('token') ?? ''; + this.load(); + } + + ngOnDestroy(): void { + this.clearRedirect(); + } + + private load(): void { + this.loading.set(true); + this.inviteClient.get(this.token, this.course).subscribe({ + next: (state) => { + this.state.set(state); + this.loading.set(false); + }, + error: (err: unknown) => { + this.error.set(readApiError(err, 'This invite link could not be opened.')); + this.loading.set(false); + }, + }); + } + + /** Verified server-side against the GitHub API, so a typo comes back as a message rather than a broken repo. */ + protected saveGitHubUsername(): void { + const login = this.gitHubUsername.trim(); + if (!login) { + return; + } + + this.error.set(null); + this.savingUsername.set(true); + + this.profileClient.setGitHubUsername({ gitHubUsername: login, courseSlug: this.course }).subscribe({ + next: () => { + this.savingUsername.set(false); + this.gitHubUsername = ''; + + // The session carries the username too, and the student may go to /my straight afterwards. + this.auth.reload().subscribe(); + this.load(); + }, + error: (err: unknown) => { + this.savingUsername.set(false); + this.error.set(readApiError(err, 'That username could not be checked. Try again in a moment.')); + }, + }); + } + + protected accept(): void { + this.error.set(null); + this.accepting.set(true); + + this.inviteClient.accept(this.token, this.course).subscribe({ + next: (state) => { + this.accepting.set(false); + this.state.set(state); + + // Only send them onward when the repository is actually open to them. With an invitation still + // outstanding the repository 404s, so the page keeps them here and points at the invitation instead. + if (state.status === 'Accepted' && state.repoUrl && !state.invitationUrl) { + this.startRedirect(state.repoUrl); + } + }, + error: (err: unknown) => { + this.accepting.set(false); + this.error.set(readApiError(err, 'The repository could not be set up. Tell your instructor.')); + }, + }); + } + + protected openRepository(): void { + const url = this.state()?.invitationUrl ?? this.state()?.repoUrl; + if (url) { + this.clearRedirect(); + window.location.href = url; + } + } + + protected cancelRedirect(): void { + this.clearRedirect(); + } + + private startRedirect(url: string): void { + this.redirectingIn.set(4); + this.redirectTimer = setInterval(() => { + const left = (this.redirectingIn() ?? 0) - 1; + if (left <= 0) { + this.clearRedirect(); + window.location.href = url; + return; + } + this.redirectingIn.set(left); + }, 1000); + } + + private clearRedirect(): void { + if (this.redirectTimer) { + clearInterval(this.redirectTimer); + this.redirectTimer = undefined; + } + this.redirectingIn.set(null); + } +} diff --git a/ahk-frontend/src/app/features/login/login.html b/ahk-frontend/src/app/features/login/login.html new file mode 100644 index 0000000..f815801 --- /dev/null +++ b/ahk-frontend/src/app/features/login/login.html @@ -0,0 +1,64 @@ +
+
+ +

AHK — Automated homework evaluation

+
+ +
+
+

Sign in

+

Use your eduID account — the same login you use across the university.

+ + + + + @if (!showLocal()) { + + } @else { +
local account
+ +
+
+ + +
+
+ + +
+ + @if (error(); as e) { +

{{ e }}

+ } + + +
+ } +
+
+
diff --git a/ahk-frontend/src/app/features/login/login.scss b/ahk-frontend/src/app/features/login/login.scss new file mode 100644 index 0000000..eaf0da1 --- /dev/null +++ b/ahk-frontend/src/app/features/login/login.scss @@ -0,0 +1,132 @@ +/* eduID's own blue, kept local to this screen — it is a foreign accent, used only on the federated login + button and nowhere else in the AUT palette. */ +:host { + --eduid: #0068ad; + --eduid-deep: #024f83; +} + +.login { + max-width: 23rem; + margin: 0 auto; + padding: 4rem 1.25rem; +} + +.mark { + display: flex; + flex-direction: column; + gap: 0.875rem; + margin-bottom: 1.5rem; + + .logo { + display: block; + width: 100%; + max-width: 17rem; + height: auto; + } +} + +.product { + font-size: 1rem; +} + +h2 { + font-size: 1.125rem; +} + +.lede { + margin-bottom: 1.25rem; + font-size: 0.8125rem; +} + +/* eduID login button. The official prescription is a saturated blue block; this is the sanctioned lighter + variant — white surface, eduID-blue border, the real eduID logo, and the action word in eduID blue — so it + sits with the crimson AUT chrome while staying unmistakably eduID. */ +.eduid-btn { + display: flex; + align-items: stretch; + width: 100%; + padding: 0; + border: 1px solid var(--eduid); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; + overflow: hidden; + + .eduid-logo { + flex: none; + height: 1.5rem; + width: auto; + align-self: center; + margin: 0.625rem 0.875rem; + } + + .eduid-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding: 0.625rem 1rem; + border-left: 1px solid color-mix(in srgb, var(--eduid) 30%, var(--rule)); + font-family: var(--font-ui); + font-size: 0.9375rem; + font-weight: bold; + letter-spacing: 0.02em; + color: var(--eduid); + } + + &:hover { + background: color-mix(in srgb, var(--eduid) 6%, var(--surface)); + border-color: var(--eduid-deep); + } + + &:hover .eduid-label { + color: var(--eduid-deep); + } +} + +/* Quiet, prose-style link to reveal the local form — it must not compete with the eduID button. */ +.linkish { + display: inline-block; + margin-top: 1rem; + border: 0; + background: none; + padding: 0; + font: inherit; + font-size: 0.8125rem; + color: var(--link); + cursor: pointer; + text-decoration: underline; + + &:hover { + color: var(--link-hover); + } +} + +.divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.25rem 0; + color: var(--ink-3); + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.12em; + text-transform: uppercase; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--rule); + } +} + +.btn.wide { + width: 100%; + justify-content: center; +} + +.notice { + margin-bottom: 0.875rem; +} diff --git a/ahk-frontend/src/app/features/login/login.ts b/ahk-frontend/src/app/features/login/login.ts new file mode 100644 index 0000000..fd16c59 --- /dev/null +++ b/ahk-frontend/src/app/features/login/login.ts @@ -0,0 +1,57 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AuthService } from '../../core/auth/auth.service'; + +/** + * Sign-in screen. eduID (the institutional federated login) is the primary and expected path, so it leads. + * Local username/password is for the handful of administrator-issued accounts, so it stays collapsed behind a + * link and only appears when asked for. + */ +@Component({ + selector: 'app-login', + imports: [FormsModule], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + protected userName = ''; + protected password = ''; + protected readonly error = signal(null); + protected readonly busy = signal(false); + + /** The local username/password form is hidden until the user says they have no eduID account. */ + protected readonly showLocal = signal(false); + + protected loginWithEduId(): void { + // Full-page navigation so the browser follows the OIDC redirect chain; proxied to the backend in dev. + const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? ''; + window.location.href = `/api/auth/external/challenge?returnUrl=${encodeURIComponent(returnUrl)}`; + } + + protected revealLocal(): void { + this.showLocal.set(true); + } + + protected submit(): void { + this.error.set(null); + this.busy.set(true); + this.auth.login(this.userName, this.password).subscribe((failure) => { + this.busy.set(false); + if (failure) { + this.error.set(failure); + } else { + this.router.navigateByUrl(this.landingUrl()); + } + }); + } + + private landingUrl(): string { + return this.route.snapshot.queryParamMap.get('returnUrl') ?? this.auth.landingUrl(); + } +} diff --git a/ahk-frontend/src/app/features/my/my-assignments.html b/ahk-frontend/src/app/features/my/my-assignments.html new file mode 100644 index 0000000..b67472c --- /dev/null +++ b/ahk-frontend/src/app/features/my/my-assignments.html @@ -0,0 +1,124 @@ +
+
+ + + @if (user(); as u) { + {{ u.displayName || u.userName }} + } + +
+ +
+
+ Your work +

Assignments

+
+
+ + @if (error(); as e) { +

{{ e }}

+ } + @if (note(); as n) { +

{{ n }}

+ } + + @if (loading()) { +

Loading your repositories…

+ } @else if (groups().length === 0) { +
+
+

Nothing here yet

+

+ You have not accepted any assignments. Your instructor hands out an invite link for each one — + follow it and your repository is created here. +

+ @if (user(); as u) { + @if (!u.gitHubUsername) { +

+ You have not told us your GitHub username yet either. The first invite link you open will ask + for it. +

+ } + } +
+
+ } @else { + @for (group of groups(); track group.slug) { +
+
+
+ {{ group.slug }} +

{{ group.name }}

+
+
+ +
+ @for (repository of group.repositories; track repository.acceptanceId) { +
+
+

{{ repository.assignmentName }}

+ + @switch (repository.access) { + @case ('Active') { + Ready + } + @case ('InvitationPending') { + Invitation waiting + } + @case ('InvitationExpired') { + Invitation expired + } + @default { + Unknown + } + } +
+ +

+ {{ + repository.gitHubRepoName + }} +

+ +

Accepted {{ repository.acceptedAt | date: 'medium' }}

+ + + @if (repository.access !== 'Active') { +

+ @if (repository.access === 'InvitationExpired') { + Your GitHub invitation ran out before you accepted it. Send a new one and open it from + GitHub. + } @else { + GitHub has invited you to this repository. Until you accept the invitation, the + repository will not open for you. + } +

+ +
+ @if (repository.invitationUrl) { + + Open the invitation + + } + +
+ } +
+ } +
+
+ } + } +
diff --git a/ahk-frontend/src/app/features/my/my-assignments.scss b/ahk-frontend/src/app/features/my/my-assignments.scss new file mode 100644 index 0000000..9ed652a --- /dev/null +++ b/ahk-frontend/src/app/features/my/my-assignments.scss @@ -0,0 +1,89 @@ +/* Standalone page like the invite screen: a student is a member of no course, so the shell's course rail and + switcher would have nothing to put in them. */ +.my { + max-width: 48rem; + margin: 0 auto; + padding: 1.5rem 1.25rem 4rem; +} + +.topline { + display: flex; + align-items: center; + gap: 0.75rem; + padding-bottom: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--rule); + + .logo { + height: 1.75rem; + width: auto; + } + + .spacer { + flex: 1; + } + + .who { + font-size: 0.75rem; + color: var(--ink-2); + } +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.125rem; +} + +h3 { + margin: 0; + font-size: 1rem; +} + +.card + .card { + margin-top: 1.25rem; +} + +/* Repositories stack inside a course card, separated by a rule rather than nested cards — the course is the + container, and a card inside a card reads as a different kind of thing. */ +.repo + .repo { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid var(--rule); +} + +.repo-head { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.5rem; +} + +.path { + margin: 0 0 0.25rem; + font-size: 0.8125rem; + overflow-wrap: anywhere; + + a { + color: var(--link); + + &:hover { + color: var(--link-hover); + } + } +} + +.meta { + margin: 0; + font-size: 0.75rem; +} + +.notice { + margin-top: 0.75rem; +} + +.btn-row { + margin-top: 0.75rem; +} diff --git a/ahk-frontend/src/app/features/my/my-assignments.ts b/ahk-frontend/src/app/features/my/my-assignments.ts new file mode 100644 index 0000000..a47e1f0 --- /dev/null +++ b/ahk-frontend/src/app/features/my/my-assignments.ts @@ -0,0 +1,103 @@ +import { DatePipe } from '@angular/common'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; + +import { MyAssignmentsClient, StudentRepository } from '../../api/api-client'; +import { readApiError } from '../../core/api-error'; +import { AuthService } from '../../core/auth/auth.service'; + +/** One course's worth of repositories, which is how the page is grouped. */ +interface CourseGroup { + slug: string; + name: string; + repositories: StudentRepository[]; +} + +/** + * A student's own page: every repository they hold, across every course. + * + * This is also where a student lands when they sign in without an invite link, so it doubles as the + * "you have nothing yet" screen — the empty state has to explain what to do, not just report a void. + */ +@Component({ + selector: 'app-my-assignments', + imports: [DatePipe], + templateUrl: './my-assignments.html', + styleUrl: './my-assignments.scss', +}) +export class MyAssignments implements OnInit { + private readonly client = inject(MyAssignmentsClient); + private readonly auth = inject(AuthService); + + protected readonly repositories = signal([]); + protected readonly loading = signal(true); + protected readonly error = signal(null); + protected readonly resending = signal(null); + protected readonly note = signal(null); + + protected readonly user = this.auth.currentUser; + + /** Grouped by course, preserving the API's newest-first order within each. */ + protected readonly groups = computed(() => { + const byCourse = new Map(); + + for (const repository of this.repositories()) { + const slug = repository.courseSlug ?? ''; + const group = byCourse.get(slug) ?? { slug, name: repository.courseName ?? slug, repositories: [] }; + group.repositories.push(repository); + byCourse.set(slug, group); + } + + return [...byCourse.values()]; + }); + + ngOnInit(): void { + this.load(); + } + + protected load(): void { + this.loading.set(true); + this.client.list().subscribe({ + next: (repositories) => { + this.repositories.set(repositories); + this.loading.set(false); + }, + error: (err: unknown) => { + this.error.set(readApiError(err, 'Your assignments could not be loaded.')); + this.loading.set(false); + }, + }); + } + + /** + * GitHub cannot extend an invitation, so this withdraws the stale one and issues a fresh one. The student + * still has to click through it on GitHub — the button only puts a live invitation back in their inbox. + */ + protected resend(repository: StudentRepository): void { + const id = repository.acceptanceId ?? 0; + + this.error.set(null); + this.note.set(null); + this.resending.set(id); + + this.client.resendInvitation(id).subscribe({ + next: (updated) => { + this.resending.set(null); + this.repositories.update((list) => list.map((r) => (r.acceptanceId === id ? updated : r))); + + this.note.set( + updated.access === 'Active' + ? 'You already have access to that repository — no invitation was needed.' + : 'A new invitation is on its way. Open it from the link next to the repository, or from your GitHub notifications.', + ); + }, + error: (err: unknown) => { + this.resending.set(null); + this.error.set(readApiError(err, 'The invitation could not be sent. Try again in a few minutes.')); + }, + }); + } + + protected logout(): void { + this.auth.logout().subscribe(); + } +} diff --git a/ahk-frontend/src/app/layout/shell/shell.html b/ahk-frontend/src/app/layout/shell/shell.html new file mode 100644 index 0000000..57226cc --- /dev/null +++ b/ahk-frontend/src/app/layout/shell/shell.html @@ -0,0 +1,73 @@ + + +
+ + + AHK + + +
+ + +
+ + + + @if (user(); as u) { + + {{ u.displayName || u.userName }} + @if (isAdmin()) { + Site admin + } + + } + +
+ +
+ + +
+ +
+
diff --git a/ahk-frontend/src/app/layout/shell/shell.scss b/ahk-frontend/src/app/layout/shell/shell.scss new file mode 100644 index 0000000..3ea018a --- /dev/null +++ b/ahk-frontend/src/app/layout/shell/shell.scss @@ -0,0 +1,212 @@ +:host { + display: block; + min-height: 100vh; +} + +.skip { + position: absolute; + left: -9999px; +} + +.skip:focus { + left: 0.5rem; + top: 0.5rem; + z-index: 10; + background: var(--surface); + padding: 0.5rem 0.75rem; + border-radius: var(--radius); +} + +/* Dark band carrying the department logo, as on aut.bme.hu. Its own tokens, because + these are the one place the palette runs inverted. */ +:host { + --topbar: #1a1a1a; + --topbar-ink: #d8d6d2; + --topbar-line: #3a3a3a; + --topbar-field: #2b2b2b; +} + +.topbar { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; + padding: 0.625rem 1.25rem; + background: var(--topbar); + color: var(--topbar-ink); + border-bottom: 3px solid var(--brand-dark); +} + +.brand { + display: flex; + align-items: center; + gap: 0.75rem; + color: #fff; + + &:hover { + text-decoration: none; + } + + .logo { + display: block; + height: 26px; + width: auto; + } + + /* Hairline rule separating the department mark from the product name. */ + .product { + padding-left: 0.75rem; + border-left: 1px solid var(--topbar-line); + font-family: var(--font-display); + font-size: 1.125rem; + letter-spacing: 0.08em; + color: #fff; + } +} + +.switcher { + display: flex; + align-items: center; + gap: 0.5rem; + + label { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--topbar-ink); + } + + select { + width: auto; + max-width: 18rem; + background: var(--topbar-field); + border-color: var(--topbar-line); + color: #fff; + } +} + +.spacer { + flex: 1 1 auto; +} + +.who { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + + .name { + color: var(--topbar-ink); + } +} + +.btn.on-ink { + color: var(--topbar-ink); + + &:hover { + background: var(--topbar-field); + border-color: var(--topbar-line); + color: #fff; + } +} + +.frame { + display: flex; + align-items: stretch; + min-height: calc(100vh - 3.125rem); +} + +.rail { + flex: none; + width: 13rem; + padding: 1.25rem 0.75rem; + border-right: 1px solid var(--rule); + background: var(--surface); + + /* aut.bme.hu marks the current section with a 3px crimson edge on the nav item. + The rail is vertical, so the edge is on the left; it moves to the bottom at the + narrow breakpoint below, where the rail turns horizontal and matches #mainNavBar. */ + a { + display: block; + padding: 0.375rem 0.625rem; + border-left: 3px solid transparent; + color: var(--ink-2); + font-size: 0.8125rem; + + &:hover { + background: var(--surface-2); + color: var(--brand); + text-decoration: none; + } + + &.active { + border-left-color: var(--brand-dark); + background: var(--accent-wash); + color: var(--brand); + font-weight: bold; + } + } +} + +.rail-title { + display: block; + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-3); + padding: 0 0.625rem; + margin-bottom: 0.5rem; + + /* Every group after the first needs air above it. */ + & ~ .rail-title { + margin-top: 1.5rem; + } +} + +.rail-note { + margin: 0.5rem 0.625rem 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--warn); +} + +.content { + flex: 1 1 auto; + padding: 1.5rem; + min-width: 0; +} + +@media (max-width: 52rem) { + .frame { + flex-direction: column; + } + + .rail { + width: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.625rem 1rem; + border-right: 0; + border-bottom: 1px solid var(--rule); + + /* Horizontal now, so the crimson edge moves to the bottom — aut.bme.hu's + #mainNavBar treatment exactly. */ + a { + border-left: 0; + border-bottom: 3px solid transparent; + + &.active { + border-bottom-color: var(--brand-dark); + } + } + } + + .rail-note { + flex: 1 1 100%; + margin: 0.25rem 0 0; + } +} diff --git a/ahk-frontend/src/app/layout/shell/shell.ts b/ahk-frontend/src/app/layout/shell/shell.ts new file mode 100644 index 0000000..1c368eb --- /dev/null +++ b/ahk-frontend/src/app/layout/shell/shell.ts @@ -0,0 +1,42 @@ +import { Component, computed, inject } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +import { AuthService } from '../../core/auth/auth.service'; +import { CourseContextService } from '../../core/course/course-context.service'; + +/** + * Authenticated app frame: a topbar carrying identity and the course switcher, and a rail whose contents + * depend on where you are — course screens inside a course, site screens under /admin. Admins can reach every + * course from the switcher, so the same frame serves both jobs without a separate "admin mode". + */ +@Component({ + selector: 'app-shell', + imports: [RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './shell.html', + styleUrl: './shell.scss', +}) +export class Shell { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + protected readonly courseContext = inject(CourseContextService); + + protected readonly user = this.auth.currentUser; + protected readonly isAdmin = this.auth.isAdmin; + protected readonly courses = this.auth.courses; + + protected readonly activeSlug = this.courseContext.activeSlug; + protected readonly activeCourse = this.courseContext.activeCourse; + + /** True on the site-admin screens, which have no course context. */ + protected readonly inAdmin = computed(() => this.activeSlug() === null); + + protected switchCourse(slug: string): void { + if (slug) { + void this.router.navigate([slug, 'dashboard']); + } + } + + protected logout(): void { + this.auth.logout().subscribe(() => this.router.navigate(['/login'])); + } +} diff --git a/ahk-frontend/src/app/shared/health-chain/health-chain.html b/ahk-frontend/src/app/shared/health-chain/health-chain.html new file mode 100644 index 0000000..950cd17 --- /dev/null +++ b/ahk-frontend/src/app/shared/health-chain/health-chain.html @@ -0,0 +1,35 @@ +
+ @for (c of checks(); track c.checkId) { + + } @empty { +

No checks are registered.

+ } +
+ +@if (detailed()) { +
+ @for (c of checks(); track c.checkId) { +
+ + {{ c.title }} +
+
+ {{ c.message }} + @if (c.remediation && c.status !== 'Healthy') { + {{ c.remediation }} + } +
+ } +
+} diff --git a/ahk-frontend/src/app/shared/health-chain/health-chain.scss b/ahk-frontend/src/app/shared/health-chain/health-chain.scss new file mode 100644 index 0000000..50075ea --- /dev/null +++ b/ahk-frontend/src/app/shared/health-chain/health-chain.scss @@ -0,0 +1,110 @@ +/* The one loud element in the product: the integration drawn as a chain of chevrons, each link tinted by its + check. Links overlap by the width of their notch so they read as one continuous run. */ + +.chain { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: 0; + font-family: var(--font-ui); +} + +.link { + --notch: 0.6rem; + + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.0625rem; + min-width: 8.5rem; + padding: 0.375rem 0.75rem 0.375rem calc(0.75rem + var(--notch)); + margin-right: calc(var(--notch) * -1 + 2px); + background: var(--idle-wash); + color: var(--ink-2); + clip-path: polygon(0 0, calc(100% - var(--notch)) 0, 100% 50%, calc(100% - var(--notch)) 100%, 0 100%, var(--notch) 50%); +} + +/* The run has a flat head, so the first link does not point into empty space. */ +.link:first-child { + padding-left: 0.75rem; + clip-path: polygon(0 0, calc(100% - var(--notch)) 0, 100% 50%, calc(100% - var(--notch)) 100%, 0 100%); +} + +.link.ok { + background: var(--ok-wash); + color: var(--ok); +} + +.link.warn { + background: var(--warn-wash); + color: var(--warn); +} + +.link.bad { + background: var(--bad-wash); + color: var(--bad); +} + +.link-title { + font-size: 0.6875rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.75; +} + +.link-state { + font-size: 0.75rem; + font-weight: 600; +} + +/* Below ~40rem the chevrons would be unreadable; stack them as plain bars instead. */ +@media (max-width: 40rem) { + .link, + .link:first-child { + flex: 1 1 100%; + margin-right: 0; + margin-bottom: 2px; + padding-left: 0.75rem; + clip-path: none; + border-left: 3px solid currentcolor; + } +} + +.detail { + display: grid; + grid-template-columns: minmax(9rem, auto) 1fr; + gap: 0.4375rem 1rem; + margin: 1rem 0 0; + font-size: 0.8125rem; +} + +.detail dt { + display: flex; + align-items: center; + gap: 0.4375rem; + font-family: var(--font-ui); + color: var(--ink-2); +} + +.detail dd { + margin: 0; +} + +.detail .fix { + display: block; + color: var(--ink-2); + font-size: 0.75rem; + margin-top: 0.125rem; +} + +@media (max-width: 34rem) { + .detail { + grid-template-columns: 1fr; + gap: 0.125rem; + } + + .detail dd { + margin-bottom: 0.625rem; + } +} diff --git a/ahk-frontend/src/app/shared/health-chain/health-chain.ts b/ahk-frontend/src/app/shared/health-chain/health-chain.ts new file mode 100644 index 0000000..89a5b80 --- /dev/null +++ b/ahk-frontend/src/app/shared/health-chain/health-chain.ts @@ -0,0 +1,45 @@ +import { Component, input } from '@angular/core'; + +import { HealthCheckResult, HealthStatus } from '../../api/api-client'; + +/** + * A course's health drawn as the pipeline it describes: webhook in, credentials, callback out. Each link is + * tinted by its check, so a glance says *which* part of the integration is broken rather than only that + * something is. Set `detailed` to print each check's message and next step underneath. + */ +@Component({ + selector: 'app-health-chain', + templateUrl: './health-chain.html', + styleUrl: './health-chain.scss', +}) +export class HealthChain { + readonly checks = input.required(); + readonly detailed = input(false); + + /** Maps a status onto the shared status classes (ok / warn / bad / idle). */ + protected tone(status: HealthStatus | undefined): string { + switch (status) { + case 'Healthy': + return 'ok'; + case 'Warning': + return 'warn'; + case 'Failed': + return 'bad'; + default: + return 'idle'; + } + } + + protected label(status: HealthStatus | undefined): string { + switch (status) { + case 'Healthy': + return 'Passing'; + case 'Warning': + return 'Needs attention'; + case 'Failed': + return 'Failing'; + default: + return 'Not set up'; + } + } +} diff --git a/ahk-frontend/src/index.html b/ahk-frontend/src/index.html new file mode 100644 index 0000000..472948d --- /dev/null +++ b/ahk-frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + AHK — Automated homework evaluation + + + + + + + + diff --git a/ahk-frontend/src/main.ts b/ahk-frontend/src/main.ts new file mode 100644 index 0000000..5df75f9 --- /dev/null +++ b/ahk-frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); diff --git a/ahk-frontend/src/styles.scss b/ahk-frontend/src/styles.scss new file mode 100644 index 0000000..8bb04e6 --- /dev/null +++ b/ahk-frontend/src/styles.scss @@ -0,0 +1,554 @@ +/* ============================================================================= + AHK portal — global design system, in the BME AUT visual identity. + Component stylesheets are scoped by Angular, so everything shared lives here: + tokens, base elements, and the handful of classes the screens compose from + (page, card, field, btn, table, badge). + + The palette and typography come from aut.bme.hu, so the portal reads as a + department system rather than a standalone tool. Provenance of every value is + recorded in brand/README.md, next to the logo masters. + + Type does three jobs and uses three faces: Georgia for headings (the AUT + house style), Verdana for labels and controls, and monospace for machine + identifiers — slugs, organizations, repositories, Neptun codes, tokens — + because comparing those character by character is the actual work. + ============================================================================= */ + +:root { + /* --- BME AUT identity -------------------------------------------------- + Three different reds, deliberately. Collapsing them loses information: + --brand marks headings and primary actions, --link is for navigation, and + --bad means something is broken. aut.bme.hu makes the same distinction. */ + --brand: #a4001e; /* aut.bme.hu heading + accent crimson */ + --brand-deep: #900028; /* the logo's own crimson (PANTONE 202) */ + --brand-dark: #88000f; /* aut.bme.hu active-navigation marker */ + + /* Kept as --accent so every component that already reads it follows along. */ + --accent: var(--brand); + --accent-ink: #7d0017; + --accent-wash: #f6e6e9; + + /* Navy, not crimson: aut.bme.hu sets a[href] { color:#074371 }. */ + --link: #074371; + --link-hover: #052f50; + + /* Warm neutrals from aut.bme.hu's grid views. */ + --parchment: #dbd9c0; + --parchment-2: #f7f5dc; + + /* Ink + paper */ + --ink: #1a1a1a; + --ink-2: #4a4a4a; + --ink-3: #767676; /* not AUT's #999, which is 2.8:1 on white and fails WCAG AA */ + --paper: #dedddb; /* AUT's #ddd, warmed to sit with the parchment */ + --surface: #ffffff; + --surface-2: #f7f6f2; + --rule: #d5d3cc; + --rule-strong: #bcb9ae; + + /* Status. These carry the meaning in this product, so they are the loud ones. + --bad is AUT's own error red, browner and darker than --brand, so a failing + badge cannot be mistaken for brand chrome. */ + --ok: #1f7a4d; + --ok-wash: #e2f2e8; + --warn: #a86414; + --warn-wash: #f8eddc; + --bad: #801b1b; + --bad-wash: #ffcfcf; + --bad-rule: #e5a3a3; + --idle: #767676; + --idle-wash: #ececea; + + --font-display: Georgia, 'Times New Roman', Times, serif; + --font-ui: Verdana, Arial, Helvetica, sans-serif; + --font-mono: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono', Consolas, monospace; + + --radius: 3px; + --shadow: 0 1px 2px rgb(26 26 26 / 6%), 0 1px 12px rgb(26 26 26 / 4%); + + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--font-ui); + + /* Verdana runs wide, so the base drops a step to keep the dense tables legible. + aut.bme.hu does the same thing with font-size: 70%. */ + font-size: 0.8125rem; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* Crimson serif at normal weight — the single rule that makes a page read as AUT. */ +h1, +h2, +h3 { + font-family: var(--font-display); + font-weight: normal; + color: var(--brand); + margin: 0; +} + +h1 { + font-size: 1.5rem; +} + +h2 { + font-size: 1.125rem; +} + +h3 { + font-size: 0.9375rem; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +/* A link that is a heading keeps the heading's crimson (AUT: h3 a[href] { color:#a4001e }). */ +h1 a, +h2 a, +h3 a { + color: var(--brand); +} + +:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +/* ---- Page scaffolding ------------------------------------------------- */ + +.page { + max-width: 74rem; +} + +.page-head { + display: flex; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +.page-head .grow { + flex: 1 1 16rem; +} + +/* Small tracked label above a heading or beside a value. */ +.eyebrow { + display: block; + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-3); + margin-bottom: 0.25rem; +} + +.lede { + color: var(--ink-2); + margin: 0.375rem 0 0; + max-width: 56ch; +} + +.muted { + color: var(--ink-2); +} + +.mono { + font-family: var(--font-mono); +} + +/* ---- Card ------------------------------------------------------------- */ + +.card { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 1.25rem; +} + +.card > header { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.875rem 1.125rem; + border-bottom: 1px solid var(--rule); +} + +.card > header .grow { + flex: 1 1 auto; +} + +.card > header p { + margin: 0.125rem 0 0; + color: var(--ink-2); + font-size: 0.8125rem; +} + +.card-body { + padding: 1.125rem; +} + +.card-body > :first-child { + margin-top: 0; +} + +.card-body > :last-child { + margin-bottom: 0; +} + +.card-foot { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.875rem 1.125rem; + border-top: 1px solid var(--rule); + background: var(--surface-2); +} + +/* ---- Forms ------------------------------------------------------------ */ + +.field { + display: flex; + flex-direction: column; + gap: 0.3125rem; + margin-bottom: 0.875rem; +} + +.field > label { + font-family: var(--font-ui); + font-size: 0.625rem; + font-weight: bold; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-2); +} + +.field .hint { + font-size: 0.75rem; + color: var(--ink-2); +} + +.field-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 0 1rem; +} + +/* Monospace, because these hold identifiers — slugs, organizations, tokens. */ +input[type='text'], +input[type='password'], +input[type='email'], +input[type='number'], +input[type='search'], +select, +textarea { + font: inherit; + font-family: var(--font-mono); + font-size: 0.8125rem; + padding: 0.4375rem 0.5625rem; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--rule-strong); + border-radius: var(--radius); + width: 100%; + min-width: 0; +} + +input:focus, +select:focus, +textarea:focus { + border-color: var(--accent); + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +input::placeholder { + color: var(--ink-3); +} + +input:disabled, +select:disabled { + background: var(--surface-2); + color: var(--ink-2); +} + +input.ng-invalid.ng-touched { + border-color: var(--bad); +} + +.check { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; +} + +.check input { + width: auto; + accent-color: var(--accent); +} + +/* ---- Buttons ---------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-family: var(--font-ui); + font-size: 0.6875rem; + letter-spacing: 0.02em; + padding: 0.4375rem 0.75rem; + border: 1px solid var(--rule-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + cursor: pointer; + white-space: nowrap; + text-decoration: none; +} + +.btn:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--ink-3); + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.btn.primary:hover:not(:disabled) { + background: var(--accent-ink); + border-color: var(--accent-ink); +} + +.btn.danger { + color: var(--bad); + border-color: color-mix(in srgb, var(--bad) 35%, var(--rule)); +} + +.btn.danger:hover:not(:disabled) { + background: var(--bad-wash); + border-color: var(--bad); +} + +.btn.quiet { + border-color: transparent; + background: transparent; + color: var(--ink-2); +} + +.btn.quiet:hover:not(:disabled) { + background: var(--surface-2); + border-color: var(--rule); + color: var(--ink); +} + +.btn-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +/* ---- Tables ----------------------------------------------------------- */ + +.table-wrap { + overflow-x: auto; +} + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +/* Parchment header band — the most recognisable AUT table cue (.gridViewHeader). */ +table.data th { + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink); + font-weight: bold; + text-align: left; + padding: 0.5rem 0.75rem; + background: var(--parchment); + border-bottom: 1px solid var(--rule-strong); + white-space: nowrap; +} + +table.data td { + padding: 0.5625rem 0.75rem; + border-bottom: 1px solid var(--rule); + vertical-align: top; +} + +table.data tbody tr:last-child td { + border-bottom: 0; +} + +table.data tbody tr:hover td { + background: var(--surface-2); +} + +table.data th.num, +table.data td.num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table.data td.actions { + text-align: right; + white-space: nowrap; +} + +.empty { + padding: 2rem 1.125rem; + text-align: center; + color: var(--ink-2); +} + +/* ---- Badges + status -------------------------------------------------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-family: var(--font-ui); + font-size: 0.625rem; + letter-spacing: 0.04em; + padding: 0.0625rem 0.375rem; + border: 1px solid transparent; + border-radius: 2px; + background: var(--idle-wash); + color: var(--ink-2); + white-space: nowrap; +} + +.badge.ok { + background: var(--ok-wash); + color: var(--ok); +} + +.badge.warn { + background: var(--warn-wash); + color: var(--warn); +} + +.badge.bad { + background: var(--bad-wash); + border-color: var(--bad-rule); + color: var(--bad); +} + +.badge.accent { + background: var(--accent-wash); + color: var(--accent-ink); +} + +/* Status dot, for inline use in lists. */ +.dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--idle); + flex: none; +} + +.dot.ok { + background: var(--ok); +} + +.dot.warn { + background: var(--warn); +} + +.dot.bad { + background: var(--bad); +} + +/* ---- Notices ---------------------------------------------------------- */ + +.notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + border: 1px solid var(--rule-strong); + border-left-width: 3px; + border-radius: var(--radius); + font-size: 0.8125rem; + margin-bottom: 1rem; +} + +/* Matches aut.bme.hu's .errorBox: dark red on a pale wash, with its own rule colour. */ +.notice.error { + border-color: var(--bad-rule); + border-left-color: var(--bad); + background: var(--bad-wash); + color: var(--bad); + font-weight: bold; +} + +.notice.success { + border-color: var(--ok); + background: var(--ok-wash); + color: var(--ok); +} + +.notice.warn { + border-color: var(--warn); + background: var(--warn-wash); + color: var(--warn); +} + +.notice.info { + border-color: var(--accent); + background: var(--accent-wash); + color: var(--accent-ink); +} + +/* ---- Reduced motion --------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/ahk-frontend/tsconfig.app.json b/ahk-frontend/tsconfig.app.json new file mode 100644 index 0000000..264f459 --- /dev/null +++ b/ahk-frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ] +} diff --git a/ahk-frontend/tsconfig.json b/ahk-frontend/tsconfig.json new file mode 100644 index 0000000..2ab7442 --- /dev/null +++ b/ahk-frontend/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/ahk-frontend/tsconfig.spec.json b/ahk-frontend/tsconfig.spec.json new file mode 100644 index 0000000..d383706 --- /dev/null +++ b/ahk-frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.spec.ts" + ] +} diff --git a/deploy.local.json.example b/deploy.local.json.example new file mode 100644 index 0000000..aa90182 --- /dev/null +++ b/deploy.local.json.example @@ -0,0 +1,6 @@ +{ + "_comment": "Copy this file to deploy.local.json (gitignored) and fill in real values. Never commit deploy.local.json.", + "deploymentPath": "", + "databaseConnectionString": "", + "oidcClientSecret": "" +} diff --git a/publish-results-pr/README.md b/publish-results-pr/README.md index fdf6cbd..1c7a4bb 100644 --- a/publish-results-pr/README.md +++ b/publish-results-pr/README.md @@ -26,7 +26,11 @@ The extension of image files (with leading dot). When specified, the images are #### `AHK_APPURL` -The URL of the _grade management_ application's webhook accepting the results for storing in a database. If not specified, publishing the data to the webhook is disabled. +The URL of the webhook accepting the results for storing in a database — today `https://ahk.aut.bme.hu/api/integrations/evaluation-result` (see `ahk-backend/docs/ci-callback.md`). + +⚠️ **Unlike the two settings below, omitting this does not disable publishing.** When the input is absent the application falls back to a built-in default URL, so a workflow that leaves it out silently posts to whatever that default happens to be. Setting it to an empty string *does* disable publishing, because publishing requires all three of URL, token and secret to be non-empty. + +⚠️ The URL is part of the request signature, so it must match the receiving endpoint byte for byte — `https`, no trailing slash. Case is the only forgiving part. #### `AHK_APPTOKEN` @@ -52,7 +56,7 @@ jobs: run: do-eval.sh - name: Publish results - uses: docker://ghcr.io/akosdudas/ahk-publish-results-pr:v1 + uses: docker://ghcr.io/bmeaut/ahk-publish-results-pr:v1 with: AHK_RESULTFILE: "result.txt" AHK_IMAGEEXT: ".png" diff --git a/scripts/deploy-local.cmd b/scripts/deploy-local.cmd new file mode 100644 index 0000000..10ad6e6 --- /dev/null +++ b/scripts/deploy-local.cmd @@ -0,0 +1,6 @@ +@echo off +rem Convenience launcher for deploy-local.sh from a Windows terminal (or double-click), regardless of +rem the current working directory. Any arguments (e.g. --force-full) are passed through. +pushd "%~dp0\.." +wsl bash scripts/deploy-local.sh %* +popd diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh new file mode 100644 index 0000000..21abfd3 --- /dev/null +++ b/scripts/deploy-local.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Local (in-network) deploy to Mezga, run from WSL. Alternative to .github/workflows/ahk-web-deploy.yaml +# for when you're already inside the BME network and don't want to pay the VPN round-trip. +# +# Usage (from a WSL shell, or via scripts\deploy-local.cmd on Windows): +# wsl bash scripts/deploy-local.sh [--force-full] +# +# --force-full ignores the deployment manifest stored on the share and re-copies everything. Use this +# after any manual/out-of-band change on the server, since the manifest only reflects "what a deploy +# script last wrote", not "what is actually on disk right now". +# +# The manifest this script reads/writes (.deploy-manifest.sha256 at the share root) is byte-compatible +# with the one ahk-web-deploy.yaml produces: same sha256sum invocation, same relative-path convention. +# Either deploy path can pick up where the other left off. +set -euo pipefail + +# ---- Config ---- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKEND_PROJECT="ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj" +FRONTEND_DIR="ahk-frontend" +OFFLINE_PAGE="ahk-backend/Ahk.Web.Server/app_offline.htm" +PUBLISH_DIR="publish-local" +MOUNT_POINT="/mnt/ahk-deploy-local" +MANIFEST_NAME=".deploy-manifest.sha256" +SECRETS_FILE="deploy.local.json" + +FORCE_FULL=false +if [ "${1:-}" = "--force-full" ]; then + FORCE_FULL=true +fi + +cd "$REPO_ROOT" + +# ---- Preflight ---- +missing=() +command -v dotnet.exe >/dev/null 2>&1 || missing+=("dotnet.exe (the .NET SDK, reached via Windows-PATH interop)") +command -v npm >/dev/null 2>&1 || missing+=("npm (Node, reached via Windows-PATH interop)") +command -v jq >/dev/null 2>&1 || missing+=("jq (install with: sudo apt-get install -y jq)") +command -v rsync >/dev/null 2>&1 || missing+=("rsync") +command -v curl >/dev/null 2>&1 || missing+=("curl") + +if [ "${#missing[@]}" -gt 0 ]; then + echo "Missing required tool(s):" + for m in "${missing[@]}"; do echo " - $m"; done + exit 1 +fi + +if [ ! -f "$SECRETS_FILE" ]; then + echo "Missing $SECRETS_FILE. Copy deploy.local.json.example to $SECRETS_FILE and fill in your values." + exit 1 +fi + +DEPLOYMENT_PATH=$(jq -r '.deploymentPath' "$SECRETS_FILE") +DATABASE_CONNECTIONSTRING=$(jq -r '.databaseConnectionString' "$SECRETS_FILE") +OIDC_CLIENTSECRET=$(jq -r '.oidcClientSecret' "$SECRETS_FILE") + +if [ -z "$DEPLOYMENT_PATH" ] || [ "$DEPLOYMENT_PATH" = "null" ]; then + echo "deploy.local.json is missing deploymentPath." + exit 1 +fi + +# ---- Git state visibility (informational only — a local deploy of WIP is a legitimate use case) ---- +echo "Deploying branch $(git rev-parse --abbrev-ref HEAD), commit $(git rev-parse --short HEAD)" +if [ -n "$(git status --porcelain)" ]; then + echo "NOTE: working tree has uncommitted changes — deploying them along with everything else." +fi + +# ---- Build ---- +echo "== Publishing backend (self-contained win-x64 via Mezga profile) ==" +rm -rf "$PUBLISH_DIR" +dotnet.exe publish "$BACKEND_PROJECT" -p:PublishProfile=Mezga -o "$PUBLISH_DIR" + +echo "== Building frontend (production) ==" +(cd "$FRONTEND_DIR" && npm ci && npx ng build --configuration production) + +echo "== Copying SPA into backend wwwroot ==" +mkdir -p "$PUBLISH_DIR/wwwroot" +cp -r "$FRONTEND_DIR/dist/ahk-frontend/browser/." "$PUBLISH_DIR/wwwroot/" + +# ---- Inject production config (never committed) ---- +echo "== Injecting connection string and OIDC client secret ==" +jq --arg cs "$DATABASE_CONNECTIONSTRING" --arg secret "$OIDC_CLIENTSECRET" \ + '.ConnectionStrings.Default = $cs | .Authentication.Oidc.ClientSecret = $secret' \ + "$PUBLISH_DIR/appsettings.json" > "$PUBLISH_DIR/appsettings.json.tmp" +mv "$PUBLISH_DIR/appsettings.json.tmp" "$PUBLISH_DIR/appsettings.json" + +# Last content-mutating step before the diff — identical invocation to the GitHub workflow, so the +# manifest either produces is byte-for-byte comparable. +echo "== Computing local deployment manifest ==" +(cd "$PUBLISH_DIR" && find . -type f -printf '%P\0' | sort -z | xargs -0 sha256sum) > new.manifest + +# ---- Mount the share, using the current Windows session's own access ---- +# drvfs proxies through the Windows kernel's SMB redirector, so whatever credentials Windows already +# has cached for this UNC path (from Explorer or a prior `net use`) are reused automatically — no +# separate Linux-side username/password, unlike the GitHub runner's mount -t cifs. +sudo mkdir -p "$MOUNT_POINT" +if ! mountpoint -q "$MOUNT_POINT"; then + echo "== Mounting $DEPLOYMENT_PATH ==" + if ! sudo mount -t drvfs "$DEPLOYMENT_PATH" "$MOUNT_POINT" -o "uid=$(id -u),gid=$(id -g)"; then + echo "::error::Could not mount $DEPLOYMENT_PATH." + echo "Windows needs to already have access to this share. From Windows, either:" + echo " - browse to $DEPLOYMENT_PATH in Explorer once and accept 'remember my credentials', or" + echo " - run: net use $DEPLOYMENT_PATH" + echo "then re-run this script." + exit 1 + fi +fi + +cleanup() { + sudo umount "$MOUNT_POINT" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# ---- Fetch previous deployment manifest ---- +if [ "$FORCE_FULL" = "true" ]; then + echo "--force-full requested — treating the stored manifest as empty (full re-copy)." + : > old.manifest +elif [ -f "$MOUNT_POINT/$MANIFEST_NAME" ]; then + cp "$MOUNT_POINT/$MANIFEST_NAME" old.manifest +else + echo "No manifest found on the share yet — treating this as a first-time full deploy." + : > old.manifest +fi + +# ---- Diff manifests (pure local text processing — no network cost) ---- +sort old.manifest -o old.sorted +sort new.manifest -o new.sorted + +# Changed or added: whole lines (hash+path) present in the new build but not the old one. +comm -13 old.sorted new.sorted | cut -c 67- > changed.list + +# Removed: paths that existed before and are entirely absent from the new build — correctly prunes +# Angular's abandoned content-hashed chunk files, unlike a size comparison. +cut -c 67- old.manifest | sort -u > old.paths +cut -c 67- new.manifest | sort -u > new.paths +comm -23 old.paths new.paths > removed.list + +changed=$(wc -l < changed.list) +removed=$(wc -l < removed.list) +echo "Changed/added: $changed, removed: $removed" + +if [ "$changed" -eq 0 ] && [ "$removed" -eq 0 ]; then + echo "Nothing changed — skipping the deploy." +else + echo "== Stopping site (app_offline.htm) ==" + cp "$OFFLINE_PAGE" "$MOUNT_POINT/app_offline.htm" + sleep 5 + + if [ -s changed.list ]; then + echo "== Copying changed files ==" + ok=false + for i in $(seq 1 10); do + if rsync -R --files-from=changed.list --ignore-times --whole-file --inplace \ + --no-perms --no-owner --no-group --no-times "$PUBLISH_DIR/" "$MOUNT_POINT/"; then + ok=true + break + fi + echo "rsync attempt $i failed (likely a locked file); retrying..." + sleep 2 + done + if [ "$ok" != "true" ]; then + echo "::error::Copying changed files failed after retries" + exit 1 + fi + fi + + if [ -s removed.list ]; then + echo "== Removing stale files ==" + while IFS= read -r path; do + rm -f "$MOUNT_POINT/$path" + done < removed.list + fi + + echo "== Waking site (removing app_offline.htm) ==" + rm -f "$MOUNT_POINT/app_offline.htm" + + echo "== Warming up and verifying ==" + curl --fail --show-error --retry 5 --retry-delay 3 https://ahk.aut.bme.hu/ + + # Last content-mutating step, only reached after everything above succeeded — a failed run leaves the + # old manifest in place, so the next run still sees these files as "changed" and retries them. + echo "== Publishing new deployment manifest ==" + cp new.manifest "$MOUNT_POINT/$MANIFEST_NAME" +fi + +echo "Done." diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..7b1ebe2 --- /dev/null +++ b/todo.md @@ -0,0 +1,125 @@ +# AHK portal — review findings + +Whole-app review (ahk-backend + ahk-frontend, cross-checked against the four legacy apps and the +docs). Review only — nothing in this list has been fixed. Grouped by category, each item names the +file(s) involved. Severity is my judgment of impact, not a promise. + +--- + +## 1. Missing / not-yet-ported functionality + +These are gaps versus the legacy system's functional scope, beyond what CLAUDE.md already summarizes +as "the write-side entry points." + +1. **No GitHub webhook receiver.** Nothing in `Ahk.Web.Server` maps `/api/integrations/github` (the + path `docs/github-app.md` reserves for it). Every rule `github-monitor` enforces today — branch + protection, single-open-PR, reviewer-must-be-assignee, comment-edit/delete tracking, the 5-run + Actions cap — has **zero equivalent** in the portal. `CourseGitHubConfig.WorkflowRunThreshold` is + stored and editable in the admin UI but **nothing reads it** — grep confirms no consumer. A course + migrated to the portal today silently loses all of this enforcement. +2. **No `/ahk ok` chatops**, and therefore: +3. **No way to enter or override a grade through the portal at all**, not even manually. `IGradeService` + (`Ahk.Web.Services/Grading/GradeService.cs`) is fully implemented — `SetGradeAsync`, + `ConfirmAutoGradeAsync`, `RecordEvaluationResultAsync` — but grep across `Ahk.Web.Server` finds + **no controller calling any of them**. Today's admin/instructor UI can only *read* grades + (`GradesController`), never write one. Worth deciding whether a stopgap manual-entry admin endpoint + is wanted before the chatops port lands, since courses may move to the portal before that ships. +4. **No HMAC-verified CI callback** (the `publish-results-pr` → grade-management webhook). CI callback + *tokens* are fully manageable (`CoursesAdminController` CRUD, `WebhookTokenService`, + `CiCallbackTokenHealthCheck`), but nothing accepts a signed payload and calls + `RecordEvaluationResultAsync`. So even the automated-evaluation path has no landing point yet. +5. **Root `README.md` never mentions the portal.** It describes only the four legacy apps; a newcomer + reading it has no idea `ahk-backend`/`ahk-frontend` exist. (See also §5.) + +## 2. Correctness / code-quality findings + +1. **`POST /api/auth/register` is a live, unauthenticated, unguarded endpoint** — + [AuthController.cs:99-111](ahk-backend/Ahk.Web.Server/Auth/AuthController.cs#L99-L111). Anyone can + create a full local account with any username/password, no admin approval, no email verification, + no Neptun code. The SPA never calls it (`login.ts`'s own comment: "local username/password is for + the handful of administrator-issued accounts") — grep across the frontend confirms only + `UsersAdminController.Create` (admin-gated) is used for account creation. This directly contradicts + the documented access model and is reachable by anyone who can reach the API. Recommend removing the + endpoint or gating it behind `[Authorize(Roles = Admin)]`. +2. **`GitHubUsername` is existence-checked, not ownership-verified, and not unique** — + [ProfileController.cs](ahk-backend/Ahk.Web.Server/Auth/ProfileController.cs) calls + `GET /users/{login}` to confirm the login exists, but nothing proves the caller actually controls + that GitHub account (no OAuth/device-flow proof), and `ApplicationUser.GitHubUsername`/ + `GitHubUserId` have no unique index (confirmed by grepping `ApplicationDbContext.cs` — only + `MaxLength`, no `HasIndex`). Concretely: user B can claim a string that happens to be real user + Alice's GitHub login. When B accepts an assignment, `AssignmentInviteService.AcceptAsync` calls + `AddCollaboratorAsync(..., login: user.GitHubUsername!, ...)`, which invites/adds **Alice's real + GitHub account** as a collaborator on B's own private homework repo — an unwanted invite sent to a + stranger, and a spoofing vector worth deciding whether to close (verify via OAuth, or at minimum add + a unique index so only one site account can claim a given GitHub login). +3. **TOCTOU race in repository creation** — + [AssignmentInviteService.cs:164-172](ahk-backend/Ahk.Web.Services/Assignments/AssignmentInviteService.cs#L164-L172). + `AcceptAsync` checks `GetRepositoryAsync` for an existing repo, then calls + `GenerateFromTemplateAsync` if none is found. Two concurrent accept requests (double-click, two + tabs) can both observe "not found" and both call `generate`; GitHub's second call would 422 ("name + already exists"), which is an unhandled `GitHubOperationException` → 500 for the loser. The DB-level + protection (unique index on `(AssignmentId, UserId)`) only catches the *second SaveChanges*, not this + earlier GitHub-side race. Low likelihood, but worth a comment or a catch-and-retry. +4. **`SaveAssignmentRequest.TemplateRepoName` isn't validated for the `owner/name` shape** — + [AssignmentsController.cs:177-184](ahk-backend/Ahk.Web.Server/Courses/AssignmentsController.cs#L177-L184) + only checks non-empty. `IAssignmentService.SplitRepoName` (`AssignmentService.cs:53-61`) silently + treats a slash-less value as `(owner: "", name: fullName)` rather than rejecting it. A typo'd + template name (e.g. missing the org prefix) is accepted at Create time and only fails later, either + when a student accepts (GitHub call with an empty owner) or when the instructor opts into + `checkTemplate=true`. Consider validating the shape at Create/Update instead. +5. **No `UseExceptionHandler`/`UseHsts` in `Program.cs`** — grep of `ConfigurePipelineAsync` shows + neither is registered for any environment. Worth confirming deliberately (e.g. relying on IIS/ANCM + defaults in production) rather than by omission, since an unhandled exception's exact response shape + in Production hasn't been verified either way in this review. + +## 3. Test coverage gaps + +1. **`ExternalAuthController`'s Neptun-matching logic has no test at all.** Grepping + `Ahk.Web.Server.Tests` for `ExternalAuthController` finds only compiled binaries, no source + reference. This is the controller changed this session to link eduID logins by Neptun code instead + of email — exactly the kind of logic where a regression would silently duplicate or misattribute + user accounts, and it's currently unverified by any automated test. `ExternalClaimsMapperTests` + covers claim projection only, not the lookup/link/create branching in `Callback`. +2. No test exercises `CoursesAdminController.Delete`'s explicit multi-table cascade + (`ExecuteDeleteAsync` for grade points → grades → events → submissions → acceptances → assignments) + against a course that actually has rows in all of those tables. The logic reads correctly by + inspection, but CLAUDE.md flags this exact area as fragile ("a new course-scoped entity whose FK is + NoAction must be added to that list, or the delete fails") — a regression test would catch the next + entity that's added without updating the list. + +## 4. Documentation issues + +1. **Dangling reference to "the architecture plan."** Both `CLAUDE.md` and `ahk-backend/README.md` + reference "the architecture plan" for design rationale (OIDC choices, `MapIdentityApi` rejection, + etc.), but no such file exists anywhere in the repo — confirmed by searching for the phrase across + the whole tree. Either the document exists somewhere outside this repo and should be linked, or the + references should point at wherever the rationale actually now lives (much of it is duplicated + inline in CLAUDE.md already). +2. **Root `README.md` describes only the four legacy apps** and does not mention `ahk-backend`/ + `ahk-frontend` or that a migration is underway — see §1 item 5. A one-paragraph pointer to + `ahk-backend/README.md` would close this. +3. `ahk-backend/README.md`'s "Run" section has no mention of the new `ahk-web-deploy.yaml` production + deployment path (SSTP VPN / CIFS / Mezga) added this session — it's only in the root `CLAUDE.md`. + Minor, but a reader of just the backend README would not find it. + +## 5. Deployment / operational open items (from this session's work, not yet fully closed) + +1. **Local dev databases still hold the old 5-migration history.** The `Migrations/` directory was + reset to a single `InitialCreate` this session (for a clean production rollout). Any existing dev + LocalDB will conflict on `dotnet ef database update` (its `__EFMigrationsHistory` references + migration ids that no longer exist). Needs a drop/recreate before next use — already called out + verbally, tracking here so it isn't lost. +2. **The CIFS mount fix (credentials file instead of inline `-o password=`) has not yet been confirmed + against a real deploy run.** It was applied to fix a `mount error(13)` diagnosed from symptoms + (likely a comma in the password truncating the inline option), but the next live workflow run is the + first real confirmation. +3. **`VPN_CA_CERT` support was added then the user reported it unnecessary** (the endpoint's cert + validates fine); the plan says to drop the block entirely, but worth double-checking the final + workflow file has no leftover dead branch for it. +4. **`ahk-web-deploy.yaml` has never completed an end-to-end green run** (build → test → VPN → mount → + mirror → web.config-driven app start → warm-up) in one pass, only fixed incrementally per failure. + Worth one full run start-to-finish as a final confidence check before calling the pipeline done. + +--- + +*Compiled from a manual review; no source files were modified as part of this pass.*