From 34841fa22201672cfa6299b1dba8b7c88c47c671 Mon Sep 17 00:00:00 2001 From: Lilac Date: Thu, 15 Oct 2026 23:45:11 -0400 Subject: [PATCH] rate limiting and database saving works --- flake.nix | 9 +++++--- hooks.json | 6 ++++-- pigeon.sh | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/flake.nix b/flake.nix index 94d82ed..c92680a 100644 --- a/flake.nix +++ b/flake.nix @@ -81,16 +81,19 @@ }; hooks = pkgs.writeText "pigeon-hooks.json" - (builtins.replaceStrings [ "@pigeon@" ] [ "${pigeon}/bin/pigeon" ] - (builtins.readFile ./hooks.json)); + (builtins.replaceStrings [ "@nq@" "@pigeon@" ] + [ "${pkgs.nq}/bin/nq" "${pigeon}/bin/pigeon" ] + (builtins.readFile ./hooks.json)); pigeon-webhook = pkgs.writeShellApplication { name = "pigeon-webhook"; - runtimeInputs = with pkgs; [ webhook ]; + runtimeInputs = with pkgs; [ coreutils nq sqlite webhook ]; text = '' : "''${PIGEON_GITLAB_SECRET:?}" : "''${PIGEON_GITLAB_TOKEN:?}" : "''${PIGEON_PROJECTS:?}" + + export NQDIR=./queue exec webhook -hooks ${hooks} -template -port 3000 -verbose -debug ''; }; diff --git a/hooks.json b/hooks.json index 648009c..e0320c7 100644 --- a/hooks.json +++ b/hooks.json @@ -1,14 +1,16 @@ [ { "id": "release", - "execute-command": "@pigeon@", + "execute-command": "@nq@", "include-command-output-in-response": false, "pass-arguments-to-command": [ + { "source": "string", "name": "@pigeon@" }, { "source": "payload", "name": "project.id" }, { "source": "payload", "name": "project.namespace" }, { "source": "payload", "name": "project.name" }, { "source": "payload", "name": "commit.id" }, - { "source": "payload", "name": "tag" } + { "source": "payload", "name": "tag" }, + { "source": "payload", "name": "created_at" } ], "trigger-rule": { "and": [ diff --git a/pigeon.sh b/pigeon.sh index eee7fd1..d58051e 100755 --- a/pigeon.sh +++ b/pigeon.sh @@ -3,9 +3,56 @@ NAMESPACE="$2" PROJECT="$3" COMMIT="$4" TAG="$5" +CREATED_AT="$6" : "${PIGEON_GITLAB_TOKEN:?}" : "${PIGEON_PROJECTS:?}" +: "${PIGEON_DB:?}" +LIMIT="${PIGEON_RATE_LIMIT:-3}" # graded attempts per student per project per hour + +PIGEON_DB="$(realpath -m "$PIGEON_DB")" +db() { sqlite3 -bail -cmd '.timeout 10000' "$PIGEON_DB" "$@"; } +# quote a value as a SQL string literal +q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/''/g")"; } + +report() { + curl -s --show-error --out-null --request PUT \ + --header "PRIVATE-TOKEN: $PIGEON_GITLAB_TOKEN" \ + --header 'Content-Type: application/json' \ + --data "{\"description\": \"$1\"}" \ + --url "https://gitlab.com/api/v4/projects/$ID/releases/$TAG" +} + +mkdir -p "$(dirname "$PIGEON_DB")" +db << 'EOF' +CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY, + student TEXT NOT NULL, + project TEXT NOT NULL, + commit_sha TEXT NOT NULL, + time TEXT NOT NULL DEFAULT (datetime('now')), + score INTEGER CHECK (score >= 0) +); +EOF + +# GitLab's "2026-10-16 02:27:46 UTC" -> SQLite's "2026-10-16 02:27:46". +# If it's missing or unparseable, fall back to the current time. +CREATED="$(date -u -d "$CREATED_AT" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || true)" + +# Rate limit and record the submission in one atomic statement. +# No row comes back if the student is over the limit. +ROW="$(db "INSERT INTO results (student, project, commit_sha, time) + SELECT $(q "$NAMESPACE"), $(q "$PROJECT"), $(q "$COMMIT"), + COALESCE(NULLIF($(q "$CREATED"), ''), datetime('now')) + WHERE (SELECT COUNT(*) FROM results + WHERE student = $(q "$NAMESPACE") AND project = $(q "$PROJECT") + AND time > datetime('now', '-1 hour')) < $LIMIT + RETURNING id;")" + +if [ -z "$ROW" ]; then + report "Rate limit reached: at most $LIMIT submissions per hour. Try again later." + exit 0 +fi DIRECTORY="$(mktemp -d /tmp/pigeon.XXXXXXXXXX)" trap 'rm -rf "$DIRECTORY"' EXIT @@ -42,18 +89,20 @@ EOF firecracker --no-api --level Error --config-file vm.json RESULT="" +SCORE=0 TESTS="$PIGEON_PROJECTS/$PROJECT/tests" for TEST in "$TESTS"/*.sh ; do debugfs -R "cat /$(basename "$TEST" .sh)" results.img >scratch 2>/dev/null - if cmp scratch "$TESTS/$(basename "$TEST" .sh).expected" ; then + if cmp -s scratch "$TESTS/$(basename "$TEST" .sh).expected" ; then RESULT="${RESULT}PASS\n" + SCORE=$((SCORE + 1)) else RESULT="${RESULT}FAIL: $(cat "$TESTS/$(basename "$TEST" .sh).hint")\n" fi done -curl -s --show-error --out-null --request PUT \ - --header "PRIVATE-TOKEN: $PIGEON_GITLAB_TOKEN" \ - --header 'Content-Type: application/json' \ - --data "{\"description\": \"$RESULT\"}" \ - --url "https://gitlab.com/api/v4/projects/$ID/releases/$TAG" +# Record before reporting, so a GitLab outage doesn't lose the result. +db "UPDATE results SET score = $SCORE WHERE id = $ROW;" || + echo "pigeon: failed to record score for row $ROW in $PIGEON_DB" >&2 + +report "$RESULT"