#!/bin/sh
# script/gitea-requests: build the image and run gitea 1.27.3 behind it,
# with every setting of smallwebwaf at its default but SWWAF_UPSTREAM_URL,
# which names gitea's container. Then send through it the gitea requests
# that reading gitea's source found the Core Rule Set refuses, and a short
# sample of gitea's ordinary use: signing in, pages of files and commits,
# a diff, the API, a push, clone and fetch over HTTP, and two container
# image pushes. Last, print each request smallwebwaf logged, with what it
# did with it and the ids of the Core Rule Set's rules that matched it.
# Every request comes from one client, docker's gateway, so more than
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
# the requests after would show as banned. The containers, the network, the images and the files the script makes
# are removed however it ends; gitea's own image stays, as the base images
# of a build do. It needs network access, and a docker daemon that pushes
# to a registry on localhost over plain HTTP, as docker does by default.
# script/check does not run this.
set -eu

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"

# gitea 1.27.3, 2026-08-29
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"

# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
IMAGE="$NAME-image"
NETWORK="$NAME"
SMALLWEBWAF="$NAME-smallwebwaf"
GITEA="$NAME-gitea"

# gitea's one user, who owns the repository and the container images.
PASSWORD="owner-password"

BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"

# The files the script makes, and smallwebwaf's address once it runs,
# localhost and the port docker publishes it on: a host name, since the
# Core Rule Set takes a Host that is an IP address for a warning (920350).
WORK=""
address=""

cleanup() {
    docker rm --force --volumes "$SMALLWEBWAF" "$GITEA" >/dev/null 2>&1 || true
    docker network rm "$NETWORK" >/dev/null 2>&1 || true
    docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
    if [ -n "$address" ]; then
        docker rmi --force "$address/owner/python3-app:1" "$address/owner/app:1" \
            >/dev/null 2>&1 || true
    fi
    rm -rf "$WORK"
}

fail() {
    echo "gitea-requests: $*" >&2
    report
    exit 1
}

# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
    failure="$1"
    shift
    tries=0
    until "$@"; do
        tries=$((tries + 1))
        [ "$tries" -lt 60 ] || fail "$failure"
        sleep 1
    done
}

healthy() {
    status="$(docker inspect --format '{{.State.Health.Status}}' "$SMALLWEBWAF")"
    [ "$status" = healthy ]
}

# logged <text>...: a line of smallwebwaf's output holds every text, in
# any order.
logged() {
    lines="$(docker logs "$SMALLWEBWAF" 2>&1)"
    for text in "$@"; do
        lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
    done
}

# send <label> <path> <curl option>...: send a request for path to
# smallwebwaf, with label as its X-Request-ID, which the report gives,
# and keep the body of the answer in $WORK/answer. The Core Rule Set
# reads that header too, so a label names no file the cases name.
send() {
    label="$1"
    path="$2"
    shift 2
    curl --silent --show-error --max-time 30 --output "$WORK/answer" \
        --header "X-Request-ID: $label" "$@" "http://$address$path"
}

# page <label> <path> <curl option>...: send the request as a browser
# does, with the cookies gitea has set. Its Referer is the repository's
# page, which links to the others, and never a page the Core Rule Set
# refused: its address would have the next request refused too.
page() {
    label="$1"
    path="$2"
    shift 2
    send "$label" "$path" --compressed --user-agent "$BROWSER" \
        --header "Accept: text/html,application/xhtml+xml,*/*;q=0.8" \
        --header "Accept-Language: en-US,en;q=0.5" \
        --cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
        --referer "http://$address/owner/repo" "$@"
}

# api <label> <path> <curl option>...: send the request as a client of
# gitea's API does, as the owner.
api() {
    label="$1"
    path="$2"
    shift 2
    send "$label" "$path" --user "owner:$PASSWORD" \
        --header "Accept: application/json" "$@"
}

# repo_git <git argument>...: run git as the owner, without the host's
# own git settings, so that nothing it does is kept outside $WORK.
repo_git() {
    GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
        -c user.name=owner -c user.email=owner@example.invalid "$@"
}

# lines: forty lines of text, enough for a diff to hide some of them.
lines() {
    i=1
    while [ "$i" -le 40 ]; do
        echo "line $i"
        i=$((i + 1))
    done
}

# make_repository: create the repository owner/repo, holding a file of
# each name the cases name, and push it, which is the push over HTTP of
# the sample. The second commit adds a line at the end of Runtime.java,
# so that its diff hides the lines above.
make_repository() {
    status="$(api create-repository /api/v1/user/repos \
        --header "Content-Type: application/json" --data '{"name":"repo"}' \
        --write-out '%{http_code}')"
    [ "$status" = 201 ] || fail "creating the repository was answered $status"

    dir="$WORK/repo"
    repo_git init --quiet --initial-branch=main "$dir"
    for file in \
        src/java.base/share/classes/java/lang/Runtime.java \
        java.lang.String.md \
        META-INF/services/org.apache.commons.logging.LogFactory \
        document.write.js \
        process.env.d.ts \
        test/fixtures/__proto__.json \
        install-script.sh \
        src/backend/catalog/information_schema.sql \
        .../notes.txt \
        README.md; do
        mkdir -p "$dir/$(dirname "$file")"
        lines >"$dir/$file"
        repo_git -C "$dir" add "$file"
    done
    repo_git -C "$dir" commit --quiet --message "Add the files"
    echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
    repo_git -C "$dir" commit --quiet --all --message "Add a line"

    repo_git -C "$dir" -c "http.extraHeader=X-Request-ID: git-push" push --quiet \
        "http://owner:$PASSWORD@$address/owner/repo.git" main ||
        fail "the push of the repository failed"
}

# browse: sign in, then open pages as a person does. The cases are those
# of https://git.eeqj.de/sneak/smallwebwaf/issues/30.
browse() {
    # Case: gitea sends a visitor who is not signed in to the sign-in
    # page, with the page asked for in redirect_to, when that page needs
    # signing in, as a file's edit page does.
    page issue-30-case /owner/repo/_edit/main/document.write.js --location

    page sign-in /user/login
    status="$(page sign-in /user/login --header "Origin: http://$address" \
        --data-urlencode user_name=owner --data-urlencode "password=$PASSWORD" \
        --write-out '%{http_code}')"
    [ "$status" = 303 ] || fail "signing in was answered $status"

    page browse /
    page browse /owner/repo
    page browse /owner/repo/src/branch/main/README.md
    page browse /owner/repo/commits/branch/main
    page browse "/owner/repo/commit/$(repo_git -C "$WORK/repo" rev-parse HEAD)"

    # Case: expanding the lines the diff hides, by the link the diff's
    # page gives for it.
    excerpt="$(sed -n 's/.*data-fetch-url="\([^"]*blob_excerpt[^"]*\)".*/\1/p' \
        "$WORK/answer" | head -n 1 | sed 's/&amp;/\&/g')"
    [ -n "$excerpt" ] || fail "the diff's page has no link to its hidden lines"
    page issue-30-case "$excerpt"

    # Cases: the commit graph of one file, and the pages of files whose
    # path the Core Rule Set takes for an attack.
    page issue-30-case "/owner/repo/graph?file=src/java.base/share/classes/java/lang/Runtime.java"
    page issue-30-case /owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
    page issue-30-case /owner/repo/src/branch/main/java.lang.String.md
    page issue-30-case /owner/repo/src/branch/main/process.env.d.ts
    page issue-30-case /owner/repo/src/branch/main/.../notes.txt
}

# call_api: the API's calls the cases name: the commits of a file, for a
# file of each name the Core Rule Set takes for an attack in the query
# parameter path, and the file-contents call, which takes its list of
# files as JSON in the query parameter body.
call_api() {
    for file in \
        src/java.base/share/classes/java/lang/Runtime.java \
        java.lang.String.md \
        document.write.js \
        process.env.d.ts \
        test/fixtures/__proto__.json \
        install-script.sh \
        src/backend/catalog/information_schema.sql; do
        api issue-30-case /api/v1/repos/owner/repo/commits --get --data-urlencode "path=$file"
    done
    api issue-30-case /api/v1/repos/owner/repo/file-contents --get \
        --data-urlencode 'body={"files":["src/java.base/share/classes/java/lang/Runtime.java"]}'
}

# push_images: push a container image to owner/python3-app, then the same
# image to owner/app. For the second, the case, docker asks gitea to take
# its layer from owner/python3-app, naming it in the query parameter
# from. docker sends no X-Request-ID, so its requests have the ids
# smallwebwaf gives them. The image is one small file, imported rather
# than built.
push_images() {
    echo "$NAME" >"$WORK/layer.txt"
    tar -cf - -C "$WORK" layer.txt |
        docker import - "$address/owner/python3-app:1" >/dev/null
    docker tag "$address/owner/python3-app:1" "$address/owner/app:1"

    echo "$PASSWORD" | DOCKER_CONFIG="$WORK/docker" docker login --username owner \
        --password-stdin "$address" >/dev/null 2>&1 || fail "docker login failed"
    for image in "$address/owner/python3-app:1" "$address/owner/app:1"; do
        DOCKER_CONFIG="$WORK/docker" docker push --quiet "$image" >/dev/null ||
            echo "gitea-requests: the push of $image failed" >&2
    done
}

# clone_and_fetch: the clone and fetch over HTTP of the sample.
clone_and_fetch() {
    repo_git -c "http.extraHeader=X-Request-ID: git-clone" clone --quiet \
        "http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
        fail "the clone failed"
    repo_git -C "$WORK/clone" -c "http.extraHeader=X-Request-ID: git-fetch" fetch --quiet ||
        fail "the fetch failed"
}

# field <name> <line>: the text of the field name in line, a line of the
# request log, or nothing when it has none.
field() {
    printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
}

# report: print a line for each request smallwebwaf logged, but its
# health checks: what it did with the request, the ids of the Core Rule
# Set's rules that matched it, its X-Request-ID, and the request.
# waf_blocked is a refusal by the Core Rule Set, and forward a request
# passed on to gitea, whatever gitea answered.
report() {
    docker logs "$SMALLWEBWAF" 2>/dev/null | grep '"type":"request"' |
        grep -v '"action":"admin"' | sed 's/\\u0026/\&/g' >"$WORK/requests"
    while IFS= read -r line; do
        ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
        query="$(field query "$line")"
        printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
            "$(field request_id "$line")" "$(field method "$line")" \
            "$(field path "$line")" "${query:+?$query}"
    done <"$WORK/requests"
    echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
        "$(grep -c '"action":"waf_blocked"' "$WORK/requests") refused by the Core Rule Set"
}

main() {
    cd "$ROOT"
    trap cleanup EXIT
    trap 'exit 1' HUP INT TERM
    WORK="$(mktemp -d)"

    docker build --no-cache -t "$IMAGE" .

    # smallwebwaf first, so that gitea can be given the address it is
    # reached at, and gitea's image pulled before, so that gitea starts
    # within the minute smallwebwaf's health check waits for it.
    docker pull --quiet "$GITEA_IMAGE" >/dev/null
    docker network create "$NETWORK" >/dev/null
    docker run --detach --name "$SMALLWEBWAF" --network "$NETWORK" \
        --publish 127.0.0.1::8080 \
        --env SWWAF_UPSTREAM_URL="http://$GITEA:3000" \
        "$IMAGE" >/dev/null
    published="$(docker port "$SMALLWEBWAF" 8080/tcp)"
    address="localhost:${published##*:}"

    docker run --detach --name "$GITEA" --network "$NETWORK" \
        --env GITEA__security__INSTALL_LOCK=true \
        --env GITEA__server__ROOT_URL="http://$address/" \
        "$GITEA_IMAGE" >/dev/null
    # smallwebwaf's health check passes once gitea takes connections.
    wait_for "the health check did not pass" healthy
    docker exec --user git "$GITEA" gitea admin user create --admin \
        --username owner --password "$PASSWORD" --email owner@example.invalid \
        --must-change-password=false >/dev/null

    make_repository
    browse
    call_api
    push_images
    clone_and_fetch

    # The fetch's last request is the last of all; once it is logged, so
    # is every other.
    wait_for "smallwebwaf logged no line for the fetch" \
        logged '"request_id":"git-fetch"' '"method":"POST"'
    report
}

main "$@"
