Compare commits
22 Commits
b75e1e5c6e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d5d57a367 | |||
| 4a7167ed7b | |||
| 02688a2f47 | |||
| 893bb0b7bd | |||
| 6de7e26f33 | |||
| a0790400e2 | |||
| bcf5572aeb | |||
| d9b639a539 | |||
| 2bcf9117c7 | |||
| 3515c67a20 | |||
| f4feb77b14 | |||
| e2449be3c8 | |||
| ac2d1efec7 | |||
| d2ce4592d4 | |||
| a7b65c031d | |||
| 5d6e8622bf | |||
| f00a1098b4 | |||
| 0d8ee099a0 | |||
| efd302f625 | |||
| 78d90855c5 | |||
| 2531f87c31 | |||
| 994e7fa80c |
@@ -0,0 +1,236 @@
|
|||||||
|
name: CI - Test, Publish and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-tests:
|
||||||
|
runs-on: java26
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
run: |
|
||||||
|
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
||||||
|
rm -rf "$GITHUB_WORKSPACE"/*
|
||||||
|
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Make gradlew executable
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Run JUnit tests
|
||||||
|
id: run_tests
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
./gradlew test --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
if [ -d "build/reports/tests/test" ]; then
|
||||||
|
echo "Test reports available in build/reports/tests/test"
|
||||||
|
ls -la build/reports/tests/test || true
|
||||||
|
fi
|
||||||
|
if [ -d "build/test-results/test" ]; then
|
||||||
|
echo "Test result XMLs:"
|
||||||
|
ls -la build/test-results/test || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
runs-on: java26
|
||||||
|
needs: run-tests
|
||||||
|
env:
|
||||||
|
API_BASE: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
run: |
|
||||||
|
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
||||||
|
rm -rf "$GITHUB_WORKSPACE"/*
|
||||||
|
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Read version from Gradle
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
chmod +x ./gradlew
|
||||||
|
VERSION=$(./gradlew properties | grep "^version:" | awk '{print $2}')
|
||||||
|
echo "Found local project version: $VERSION"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build JAR
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
# Tests already ran in the run-tests job, so skip them here.
|
||||||
|
./gradlew jar --no-daemon --stacktrace -x test
|
||||||
|
|
||||||
|
- name: Locate built JAR
|
||||||
|
id: find_jar
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
JAR_PATH=$(ls build/libs/*.jar | grep -v -e '-sources.jar' -e '-javadoc.jar' | head -n1)
|
||||||
|
if [ -z "$JAR_PATH" ]; then
|
||||||
|
echo "No JAR found in build/libs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found JAR: $JAR_PATH"
|
||||||
|
echo "jar_path=$JAR_PATH" >> $GITHUB_OUTPUT
|
||||||
|
echo "jar_name=$(basename "$JAR_PATH")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if tag/release already exists
|
||||||
|
id: check_tag
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.get_version.outputs.tag }}"
|
||||||
|
CHECK_URL="${{ env.API_BASE }}/releases/tags/${TAG}"
|
||||||
|
echo "Checking for existing release: $CHECK_URL"
|
||||||
|
|
||||||
|
STATUS=$(curl -o /dev/null -s -w "%{http_code}" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"$CHECK_URL")
|
||||||
|
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Release for tag $TAG already exists. Skipping."
|
||||||
|
echo "is_new=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "No release found for tag $TAG (Status $STATUS). Creating release..."
|
||||||
|
echo "is_new=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create tag and release on Gitea
|
||||||
|
id: create_release
|
||||||
|
if: steps.check_tag.outputs.is_new == 'true'
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.get_version.outputs.tag }}"
|
||||||
|
VERSION="${{ steps.get_version.outputs.version }}"
|
||||||
|
CREATE_URL="${{ env.API_BASE }}/releases"
|
||||||
|
echo "Creating release $TAG at $CREATE_URL"
|
||||||
|
|
||||||
|
# Gitea creates the tag automatically from target_commitish when it
|
||||||
|
# does not yet exist, so a separate tag-creation call is not needed.
|
||||||
|
BODY=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag_name": "${TAG}",
|
||||||
|
"target_commitish": "${{ github.sha }}",
|
||||||
|
"name": "Release ${VERSION}",
|
||||||
|
"body": "Automated release for version ${VERSION}.",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
HTTP_STATUS=$(curl -s -o /tmp/release_response.json -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY" \
|
||||||
|
"$CREATE_URL")
|
||||||
|
|
||||||
|
echo "Response ($HTTP_STATUS):"
|
||||||
|
cat /tmp/release_response.json
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" != "201" ]; then
|
||||||
|
echo "Failed to create release (HTTP $HTTP_STATUS)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(grep -o '"id":[0-9]*' /tmp/release_response.json | head -n1 | cut -d':' -f2)
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
echo "Could not determine release id from response"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release $TAG created successfully (id $RELEASE_ID)."
|
||||||
|
echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Attach JAR to release
|
||||||
|
if: steps.check_tag.outputs.is_new == 'true'
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
JAR_PATH="${{ steps.find_jar.outputs.jar_path }}"
|
||||||
|
JAR_NAME="${{ steps.find_jar.outputs.jar_name }}"
|
||||||
|
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
||||||
|
UPLOAD_URL="${{ env.API_BASE }}/releases/${RELEASE_ID}/assets?name=${JAR_NAME}"
|
||||||
|
echo "Uploading $JAR_NAME to $UPLOAD_URL"
|
||||||
|
|
||||||
|
HTTP_STATUS=$(curl -s -o /tmp/asset_response.json -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: multipart/form-data" \
|
||||||
|
-F "attachment=@${JAR_PATH}" \
|
||||||
|
"$UPLOAD_URL")
|
||||||
|
|
||||||
|
echo "Response ($HTTP_STATUS):"
|
||||||
|
cat /tmp/asset_response.json
|
||||||
|
|
||||||
|
if [ "$HTTP_STATUS" != "201" ]; then
|
||||||
|
echo "Failed to upload JAR asset (HTTP $HTTP_STATUS)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "JAR $JAR_NAME attached to release successfully."
|
||||||
|
|
||||||
|
check-and-publish:
|
||||||
|
runs-on: java26
|
||||||
|
needs: run-tests
|
||||||
|
env:
|
||||||
|
MAVEN_REPO_URL: ${{ secrets.MAVEN_REPO_URL }}
|
||||||
|
MAVEN_REPO_USER: ${{ secrets.MAVEN_REPO_USER }}
|
||||||
|
MAVEN_REPO_PASS: ${{ secrets.MAVEN_REPO_PASS }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
run: |
|
||||||
|
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
||||||
|
rm -rf "$GITHUB_WORKSPACE"/*
|
||||||
|
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Read version from Gradle
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
chmod +x ./gradlew
|
||||||
|
VERSION=$(./gradlew properties | grep "^version:" | awk '{print $2}')
|
||||||
|
echo "Found local project version: $VERSION"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if version exists on repository
|
||||||
|
id: check_repo
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
RAW_GROUP=$(./gradlew properties | grep "^group:" | awk '{print $2}')
|
||||||
|
GROUP_PATH=$(echo "$RAW_GROUP" | tr '.' '/')
|
||||||
|
ARTIFACT_ID=$(./gradlew properties | grep "^name:" | awk '{print $2}')
|
||||||
|
|
||||||
|
LOCAL_VERSION="${{ steps.get_version.outputs.version }}"
|
||||||
|
|
||||||
|
echo "Detected project: $RAW_GROUP:$ARTIFACT_ID:$LOCAL_VERSION"
|
||||||
|
|
||||||
|
CHECK_URL="${{ env.MAVEN_REPO_URL }}/${GROUP_PATH}/${ARTIFACT_ID}/${LOCAL_VERSION}/${ARTIFACT_ID}-${LOCAL_VERSION}.pom"
|
||||||
|
echo "Check url: $CHECK_URL"
|
||||||
|
|
||||||
|
STATUS=$(curl -o /dev/null -s -w "%{http_code}" -u "${{ env.MAVEN_REPO_USER }}:${{ env.MAVEN_REPO_PASS }}" "$CHECK_URL")
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Version $LOCAL_VERSION already exists in repository. Skipping publishing."
|
||||||
|
echo "is_new=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version $LOCAL_VERSION not found (Status $STATUS). Start deployment..."
|
||||||
|
echo "is_new=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push to Maven Repository
|
||||||
|
if: steps.check_repo.outputs.is_new == 'true'
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
echo "Publishing version ${{ steps.get_version.outputs.version }} zu Repository..."
|
||||||
|
./gradlew publish
|
||||||
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
name: Auto Publish on Version Change
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-and-publish:
|
|
||||||
runs-on: java26
|
|
||||||
env:
|
|
||||||
MAVEN_REPO_URL: ${{ secrets.MAVEN_REPO_URL }}
|
|
||||||
MAVEN_REPO_USER: ${{ secrets.MAVEN_REPO_USER }}
|
|
||||||
MAVEN_REPO_PASS: ${{ secrets.MAVEN_REPO_PASS }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
run: |
|
|
||||||
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
|
||||||
rm -rf "$GITHUB_WORKSPACE"/*
|
|
||||||
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
git checkout ${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Read version from Gradle
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
chmod +x ./gradlew
|
|
||||||
VERSION=$(./gradlew properties | grep "^version:" | awk '{print $2}')
|
|
||||||
echo "Found local project version: $VERSION"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Check if version exists on repository
|
|
||||||
id: check_repo
|
|
||||||
run: |
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
RAW_GROUP=$(./gradlew properties | grep "^group:" | awk '{print $2}')
|
|
||||||
GROUP_PATH=$(echo "$RAW_GROUP" | tr '.' '/')
|
|
||||||
ARTIFACT_ID=$(./gradlew properties | grep "^name:" | awk '{print $2}')
|
|
||||||
|
|
||||||
LOCAL_VERSION="${{ steps.get_version.outputs.version }}"
|
|
||||||
|
|
||||||
echo "Detected project: $RAW_GROUP:$ARTIFACT_ID:$LOCAL_VERSION"
|
|
||||||
|
|
||||||
CHECK_URL="${{ env.MAVEN_REPO_URL }}/${GROUP_PATH}/${ARTIFACT_ID}/${LOCAL_VERSION}/${ARTIFACT_ID}-${LOCAL_VERSION}.pom"
|
|
||||||
echo "Check url: $CHECK_URL"
|
|
||||||
|
|
||||||
STATUS=$(curl -o /dev/null -s -w "%{http_code}" -u "${{ env.MAVEN_REPO_USER }}:${{ env.MAVEN_REPO_PASS }}" "$CHECK_URL")
|
|
||||||
if [ "$STATUS" = "200" ]; then
|
|
||||||
echo "Version $LOCAL_VERSION already exists in repository. Skipping publishing."
|
|
||||||
echo "is_new=false" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Version $LOCAL_VERSION not found (Status $STATUS). Start deployment..."
|
|
||||||
echo "is_new=true" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push to Maven Repository
|
|
||||||
if: steps.check_repo.outputs.is_new == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Publishing version ${{ steps.get_version.outputs.version }} zu Repository..."
|
|
||||||
./gradlew publish
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Run Tests on Pull Request
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-tests:
|
||||||
|
runs-on: java26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
run: |
|
||||||
|
SERVER_DOMAIN=$(echo "${{ github.server_url }}" | sed 's/https:\/\///')
|
||||||
|
rm -rf "$GITHUB_WORKSPACE"/*
|
||||||
|
git clone "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@${SERVER_DOMAIN}/${{ github.repository }}.git" "$GITHUB_WORKSPACE"
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
git checkout ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Make gradlew executable
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Run JUnit tests
|
||||||
|
id: run_tests
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
./gradlew test --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Upload test reports
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
|
if [ -d "build/reports/tests/test" ]; then
|
||||||
|
echo "Test reports available in build/reports/tests/test"
|
||||||
|
ls -la build/reports/tests/test || true
|
||||||
|
fi
|
||||||
|
if [ -d "build/test-results/test" ]; then
|
||||||
|
echo "Test result XMLs:"
|
||||||
|
ls -la build/test-results/test || true
|
||||||
|
fi
|
||||||
Generated
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:e0f49905-9df6-459a-a57c-731edb2c1607
|
||||||
|
2:0:74720f71-b717-4c46-a783-e93fc40a8785
|
||||||
|
3:0:c2ae7de6-543e-4eed-8b31-a13cb00693a8
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<component name="FrameworkDetectionExcludesConfiguration">
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
<file type="web" url="file://$PROJECT_DIR$" />
|
<file type="web" url="file://$PROJECT_DIR$" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_26" default="true" project-jdk-name="openjdk-26" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_26" default="true" project-jdk-name="26" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -5,12 +5,17 @@ A lightweight, high-performance HTTP server library built on top of Netty. Nexus
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Netty-based** — uses epoll/kqueue/NIO automatically based on the platform
|
- **Netty-based** — uses epoll/kqueue/NIO automatically based on the platform
|
||||||
- **Virtual thread dispatch** — each request is handled on a Java 21 virtual thread
|
- **Virtual thread dispatch** — each request is handled on a Java virtual thread, with per-connection read backpressure and HTTP keep-alive
|
||||||
|
- **TLS / HTTPS** — enable encryption with a single `withTls(...)` call (PEM files or a custom `SslContext`)
|
||||||
|
- **Pluggable authentication** — insert an auth layer that protects selected paths; API key, cookie, HTTP Basic, bearer or any custom scheme (not tied to bearer tokens)
|
||||||
- **Trie-based router** — supports static paths, path parameters (`{id}`), and wildcards (`*`)
|
- **Trie-based router** — supports static paths, path parameters (`{id}`), and wildcards (`*`)
|
||||||
- **Annotation-based controllers** — define routes declaratively with `@Controller`, `@GET`, `@POST`, etc.
|
- **Annotation-based controllers** — define routes declaratively with `@Controller`, `@GET`, `@POST`, etc.
|
||||||
- **Middleware chain** — attach cross-cutting logic to all routes
|
- **Middleware chain** — attach cross-cutting logic to all routes
|
||||||
- **CORS support** — configurable origins, methods, headers, credentials, and preflight caching
|
- **CORS support** — configurable origins, methods, headers, credentials, and preflight caching
|
||||||
- **Rate limiting** — four algorithm implementations with per-IP, per-token, or custom key strategies
|
- **Security headers** — opt-in `nosniff`, `X-Frame-Options`, `Referrer-Policy`, CSP and HTTPS-only HSTS, applied to every response
|
||||||
|
- **Rate limiting** — four algorithm implementations with per-IP, per-header, per-cookie, per-principal or custom key strategies, with automatic eviction of idle state
|
||||||
|
- **Spoofing-safe client IP** — `X-Forwarded-For` is honoured only behind configured trusted proxies
|
||||||
|
- **WebSockets** — path-routed handlers with origin validation, optional authentication, ordered per-connection delivery, backpressure, idle timeout, frame size limits and permessage-deflate
|
||||||
- **JSON I/O** — built-in Jackson integration for request parsing and response serialization
|
- **JSON I/O** — built-in Jackson integration for request parsing and response serialization
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -165,11 +170,17 @@ req.pathParam("id") // path parameter, e.g. from /users/{id}
|
|||||||
req.queryParam("search") // first value of ?search=
|
req.queryParam("search") // first value of ?search=
|
||||||
req.queryParams("tag") // all values of ?tag= as List<String>
|
req.queryParams("tag") // all values of ?tag= as List<String>
|
||||||
req.header("Authorization") // raw header value
|
req.header("Authorization") // raw header value
|
||||||
|
req.cookie("sid") // value of a named cookie
|
||||||
req.body() // raw body as UTF-8 String
|
req.body() // raw body as UTF-8 String
|
||||||
req.json() // body parsed as Jackson JsonNode
|
req.json() // body parsed as Jackson JsonNode
|
||||||
req.jsonAs(MyDto.class) // body deserialized into a POJO
|
req.jsonAs(MyDto.class) // body deserialized into a POJO
|
||||||
req.method() // HttpMethod
|
req.method() // HttpMethod
|
||||||
req.path() // decoded path without query string
|
req.path() // decoded path without query string
|
||||||
|
req.clientIp() // resolved client IP (honours trusted proxies)
|
||||||
|
req.principal() // authenticated principal, or null (see Authentication)
|
||||||
|
req.isAuthenticated() // whether a principal is attached
|
||||||
|
req.attribute("k", value) // attach per-request state
|
||||||
|
req.<T>attribute("k") // read it back
|
||||||
```
|
```
|
||||||
|
|
||||||
`json()` and `jsonAs()` throw `BadRequestException` (→ `400`) on malformed JSON.
|
`json()` and `jsonAs()` throw `BadRequestException` (→ `400`) on malformed JSON.
|
||||||
@@ -213,6 +224,167 @@ Use `CorsConfig.permissive()` for a development preset that allows any origin wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TLS / HTTPS
|
||||||
|
|
||||||
|
Enable encryption by attaching a `TlsConfig`. The TLS handler becomes the first element of every connection's pipeline, so both HTTP and WebSocket traffic are served over TLS (HTTPS / WSS).
|
||||||
|
|
||||||
|
```java
|
||||||
|
import dev.coph.nextusweb.server.tls.TlsConfig;
|
||||||
|
|
||||||
|
HttpServer.builder(443, router)
|
||||||
|
.withTls(TlsConfig.fromPem(
|
||||||
|
new File("fullchain.pem"), // PEM certificate chain
|
||||||
|
new File("privkey.pem"))) // PKCS#8 private key
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
| Factory | Use |
|
||||||
|
|---|---|
|
||||||
|
| `TlsConfig.fromPem(cert, key)` | PEM certificate chain + unencrypted PKCS#8 key |
|
||||||
|
| `TlsConfig.fromPem(cert, key, password)` | …with a password-protected key |
|
||||||
|
| `TlsConfig.fromPem(certStream, keyStream, password)` | Load PEM material from the classpath or another stream |
|
||||||
|
| `TlsConfig.fromSslContext(ctx)` | Full control — supply a Netty `SslContext` (custom ciphers, mutual TLS, …) |
|
||||||
|
|
||||||
|
Any initialisation failure (missing/invalid material) is reported as an `IllegalStateException`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The auth layer authenticates **selected paths** before they reach handlers and attaches a `Principal` to the request (visible to rate limiting, middleware and handlers). It is deliberately **not** tied to bearer tokens — choose any credential scheme.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import dev.coph.nextusweb.server.auth.*;
|
||||||
|
|
||||||
|
// 1. An authenticator turns a credential into a Principal (or null if invalid).
|
||||||
|
Authenticator auth = Authenticator.apiKey("X-API-Key", key ->
|
||||||
|
key.equals(System.getenv("API_KEY")) ? Principal.of("service", Set.of("admin")) : null);
|
||||||
|
|
||||||
|
// 2. Decide which paths it protects.
|
||||||
|
AuthConfig authConfig = AuthConfig.builder(auth)
|
||||||
|
.protectPrefix("/api/") // required: 401 if missing/invalid
|
||||||
|
.optional("/feed") // attach principal if present, never reject
|
||||||
|
.challenge("ApiKey realm=\"api\"")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpServer.builder(8080, router)
|
||||||
|
.withAuth(new AuthGate(authConfig))
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
In a handler:
|
||||||
|
|
||||||
|
```java
|
||||||
|
router.get("/api/me", (req, res) -> {
|
||||||
|
Principal p = req.principal(); // never null on a protected path
|
||||||
|
if (!p.hasRole("admin")) { res.status(403); return; }
|
||||||
|
res.json(Map.of("id", p.id()));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authenticators
|
||||||
|
|
||||||
|
| Factory | Credential |
|
||||||
|
|---|---|
|
||||||
|
| `Authenticator.apiKey(header, validator)` | An API key in a request header (e.g. `X-API-Key`) |
|
||||||
|
| `Authenticator.cookie(name, validator)` | A session (or other) cookie |
|
||||||
|
| `Authenticator.basic(validator)` | HTTP Basic `username` / `password` |
|
||||||
|
| `Authenticator.bearer(validator)` | A bearer token (provided for completeness; never required) |
|
||||||
|
| `Authenticator.anyOf(a, b, …)` | Tries each in order, first match wins |
|
||||||
|
| Custom | Implement `Authenticator` — e.g. mutual-TLS cert, HMAC-signed request |
|
||||||
|
|
||||||
|
`validator` returns the resolved `Principal`, or `null` for missing/invalid credentials (→ `401` on a `REQUIRED` path). A thrown exception is treated as an internal error (→ generic `500`); details are logged, never sent to the client. Rate limiting runs **before** authentication, so an unauthenticated flood is shed before reaching a (potentially expensive) authenticator.
|
||||||
|
|
||||||
|
When a validator compares a presented secret (API key, token, password) against an expected value, use `Authenticator.constantTimeEquals(presented, expected)` instead of `String.equals` to avoid leaking how many characters matched through a timing side channel:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Authenticator auth = Authenticator.apiKey("X-API-Key",
|
||||||
|
key -> Authenticator.constantTimeEquals(key, EXPECTED_KEY) ? Principal.of("svc") : null);
|
||||||
|
```
|
||||||
|
|
||||||
|
WebSocket upgrades on protected paths are authenticated the same way; the resolved principal is available via `session.principal()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Trusted proxies & client IP
|
||||||
|
|
||||||
|
`req.clientIp()` and `KeyResolver.clientIp()` return a spoofing-safe client address. By default (`TrustedProxies.none()`) the transport peer address is used and `X-Forwarded-For` is ignored — a directly connected client cannot forge its IP. When running behind a reverse proxy, declare it trusted so the forwarded header is honoured:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import dev.coph.nextusweb.server.net.TrustedProxies;
|
||||||
|
|
||||||
|
HttpServer.builder(8080, router)
|
||||||
|
.withTrustedProxies(TrustedProxies.of("10.0.0.0/8", "127.0.0.1", "::1"))
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
The resolver walks `X-Forwarded-For` from right to left and returns the first hop that is **not** a trusted proxy, so forged left-most entries are ignored. Use `TrustedProxies.all()` only when the server can never be reached except through a trusted proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardening & limits
|
||||||
|
|
||||||
|
| Concern | How it's handled |
|
||||||
|
|---|---|
|
||||||
|
| Connection reuse | HTTP keep-alive is honoured; connections close only on `Connection: close` or error |
|
||||||
|
| Slow-client / Slowloris | A per-connection read timeout (`httpReadTimeout`, default 30s) closes stalled/idle connections |
|
||||||
|
| Request memory | Auto-read is disabled while a request is in flight (one buffered body per connection); `maxHttpContentLength` (default 1 MiB) caps the body, returning `413` |
|
||||||
|
| Error disclosure | Handler exceptions return a generic `500`; the detail is logged server-side, never sent to the client |
|
||||||
|
|
||||||
|
```java
|
||||||
|
HttpServer.builder(8080, router)
|
||||||
|
.maxHttpContentLength(2 * 1024 * 1024) // 2 MiB body cap
|
||||||
|
.httpReadTimeout(Duration.ofSeconds(20)) // null/zero disables
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security headers
|
||||||
|
|
||||||
|
`withSecurityHeaders(...)` adds standard browser-hardening response headers to **every** response (handler responses, errors, CORS preflights and rejections alike). It is opt-in; without the call no security headers are sent.
|
||||||
|
|
||||||
|
```java
|
||||||
|
import dev.coph.nextusweb.server.security.SecurityHeaders;
|
||||||
|
|
||||||
|
HttpServer.builder(443, router)
|
||||||
|
.withTls(TlsConfig.fromPem(cert, key))
|
||||||
|
.withSecurityHeaders(SecurityHeaders.defaults())
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
`SecurityHeaders.defaults()` emits a conservative baseline:
|
||||||
|
|
||||||
|
| Header | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | Blocks MIME sniffing |
|
||||||
|
| `X-Frame-Options` | `DENY` | Click-jacking defence |
|
||||||
|
| `Referrer-Policy` | `no-referrer` | No referrer leakage |
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000` | **Only sent over HTTPS** (when `withTls(...)` is set); pins HTTPS for a year |
|
||||||
|
|
||||||
|
Two safety rules keep it from breaking anything: **HSTS is emitted only on TLS connections** (a browser ignores it on plain HTTP), and a header a handler has **already set is never overwritten** — so per-route choices win.
|
||||||
|
|
||||||
|
For full control use the builder. Passing `null`/blank to a setter omits that header:
|
||||||
|
|
||||||
|
```java
|
||||||
|
SecurityHeaders headers = SecurityHeaders.builder()
|
||||||
|
.frameOptions("SAMEORIGIN") // or null to omit
|
||||||
|
.referrerPolicy("strict-origin-when-cross-origin")
|
||||||
|
.contentSecurityPolicy("default-src 'self'") // off by default (app-specific)
|
||||||
|
.hsts(Duration.ofDays(365), true, false) // maxAge, includeSubDomains, preload
|
||||||
|
.header("Permissions-Policy", "geolocation=()") // any extra header
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpServer.builder(443, router)
|
||||||
|
.withTls(TlsConfig.fromPem(cert, key))
|
||||||
|
.withSecurityHeaders(headers)
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
> `includeSubDomains` and `preload` are hard to roll back — enable them only once every subdomain is reliably served over HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
### Algorithms
|
### Algorithms
|
||||||
@@ -232,8 +404,8 @@ RateLimitConfig config = RateLimitConfig.builder()
|
|||||||
.global(new TokenBucketLimiter(100, 200), KeyResolver.clientIp())
|
.global(new TokenBucketLimiter(100, 200), KeyResolver.clientIp())
|
||||||
// Stricter rule for a specific path
|
// Stricter rule for a specific path
|
||||||
.forPath("/login", new FixedWindowLimiter(5, 60_000), KeyResolver.clientIp())
|
.forPath("/login", new FixedWindowLimiter(5, 60_000), KeyResolver.clientIp())
|
||||||
// Rule for an entire path prefix
|
// Per-API-key rule for an entire path prefix (no bearer token required)
|
||||||
.forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.userOrIp())
|
.forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.header("X-API-Key"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
RateLimitGate gate = new RateLimitGate(config);
|
RateLimitGate gate = new RateLimitGate(config);
|
||||||
@@ -245,6 +417,8 @@ HttpServer.builder(8080, router)
|
|||||||
|
|
||||||
When the limit is exceeded the server responds with `429 Too Many Requests` and a `Retry-After` header.
|
When the limit is exceeded the server responds with `429 Too Many Requests` and a `Retry-After` header.
|
||||||
|
|
||||||
|
Per-key limiter state is evicted automatically by a background task (every 5 minutes, entries idle for >10 minutes), so a high-cardinality key (many distinct IPs/API keys) cannot grow the limiter maps without bound. Call `gate.shutdown()` when stopping the server.
|
||||||
|
|
||||||
### Response headers
|
### Response headers
|
||||||
|
|
||||||
Every response automatically includes:
|
Every response automatically includes:
|
||||||
@@ -259,9 +433,115 @@ Every response automatically includes:
|
|||||||
|
|
||||||
| Factory | Behaviour |
|
| Factory | Behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `KeyResolver.clientIp()` | Uses `X-Forwarded-For` if present, otherwise the remote IP |
|
| `KeyResolver.clientIp()` | The resolved client IP (honours trusted proxies — `X-Forwarded-For` is **not** trusted from a direct client) |
|
||||||
| `KeyResolver.userOrIp()` | Uses the Bearer token if present (`u:<token>`), otherwise the client IP (`ip:<addr>`) |
|
| `KeyResolver.header(name)` | Header value (e.g. an API key in `X-API-Key`); falls back to `ip:<addr>` when absent |
|
||||||
| Custom lambda | `(req, remoteAddr) -> myKey(req)` |
|
| `KeyResolver.cookie(name)` | Cookie value (e.g. a session id); falls back to `ip:<addr>` when absent |
|
||||||
|
| `KeyResolver.principal()` | The authenticated principal id (`p:<id>`); falls back to `ip:<addr>` when anonymous (requires the auth layer to run for the path) |
|
||||||
|
| Custom lambda | `(req, clientIp) -> myKey(req)` — `req` is the framework `Request`, `clientIp` the resolved IP |
|
||||||
|
|
||||||
|
> The old `KeyResolver.userOrIp()` (which trusted any client's `X-Forwarded-For` and keyed on a raw bearer token) has been removed. It allowed trivial rate-limit bypass and unbounded key growth; use `header(...)`, `cookie(...)` or `principal()` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSockets
|
||||||
|
|
||||||
|
WebSocket routes are registered on a `WebSocketRouter` and attached to the server alongside the HTTP `Router`. Upgrade requests (`GET` + `Upgrade: websocket`) are intercepted before the HTTP router runs.
|
||||||
|
|
||||||
|
### Handler
|
||||||
|
|
||||||
|
Implement `WebSocketHandler`. All callbacks are optional.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public class ChatSocket implements WebSocketHandler {
|
||||||
|
|
||||||
|
private final WebSocketGroup room = new WebSocketGroup("chat");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocketSession session) {
|
||||||
|
room.add(session);
|
||||||
|
session.send("{\"type\":\"welcome\",\"id\":\"" + session.id() + "\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(WebSocketSession session, String message) {
|
||||||
|
room.broadcastExcept(session, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClose(WebSocketSession session, int code, String reason) {
|
||||||
|
room.remove(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration
|
||||||
|
|
||||||
|
```java
|
||||||
|
WebSocketRouter wsRouter = new WebSocketRouter()
|
||||||
|
.on("/ws/chat", new ChatSocket())
|
||||||
|
.on("/ws/rooms/{room}", new RoomSocket());
|
||||||
|
|
||||||
|
WebSocketConfig wsConfig = WebSocketConfig.builder()
|
||||||
|
.allowedOrigins("https://app.example.com")
|
||||||
|
.maxFramePayloadLength(64 * 1024) // 64 KiB per frame
|
||||||
|
.maxAggregatedMessageSize(1024 * 1024) // 1 MiB cap on a reassembled (fragmented) message
|
||||||
|
.maxQueuedMessages(1024) // per-connection backlog before backpressure
|
||||||
|
.idleTimeout(Duration.ofSeconds(60)) // close idle peers
|
||||||
|
.subprotocols("chat.v1")
|
||||||
|
.compression(true) // permessage-deflate
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpServer.builder(8080, router)
|
||||||
|
.withWebSockets(wsRouter, wsConfig)
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `WebSocketConfig.defaults()` (or `.anyOrigin()` on the builder) only for local development — production deployments should always allow-list origins explicitly.
|
||||||
|
|
||||||
|
### Session API
|
||||||
|
|
||||||
|
```java
|
||||||
|
session.id(); // stable UUID for this connection
|
||||||
|
session.path(); // matched path
|
||||||
|
session.pathParam("room"); // path parameter, e.g. from /ws/rooms/{room}
|
||||||
|
session.remoteAddress(); // client IP
|
||||||
|
session.principal(); // authenticated principal, or null
|
||||||
|
session.attribute("userId", id); // attach state to the session
|
||||||
|
session.attribute("userId"); // read it back
|
||||||
|
|
||||||
|
session.send("text"); // text frame
|
||||||
|
session.sendJson(dto); // serialized via Jackson
|
||||||
|
session.sendBinary(bytes); // binary frame
|
||||||
|
session.ping(); // ping frame
|
||||||
|
session.close(); // normal close (1000)
|
||||||
|
session.close(1011, "internal"); // close with code + reason
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broadcasting
|
||||||
|
|
||||||
|
`WebSocketGroup` is a thin fluent wrapper around Netty's `ChannelGroup` — joining a session is cheap and removal happens automatically when the channel closes.
|
||||||
|
|
||||||
|
```java
|
||||||
|
WebSocketGroup group = new WebSocketGroup("lobby")
|
||||||
|
.add(sessionA)
|
||||||
|
.add(sessionB);
|
||||||
|
|
||||||
|
group.broadcast("hello everyone");
|
||||||
|
group.broadcastJson(eventDto);
|
||||||
|
group.broadcastExcept(sessionA, "everyone but A");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & limits
|
||||||
|
|
||||||
|
| Concern | How it's handled |
|
||||||
|
|---|---|
|
||||||
|
| Cross-origin upgrades | `Origin` header validated against `WebSocketConfig.allowedOrigins(...)`; mismatched origins are rejected with `403` |
|
||||||
|
| Authentication | When an `AuthGate` is configured, protected upgrade paths are authenticated and the principal is exposed via `session.principal()` |
|
||||||
|
| Memory exhaustion | `maxFramePayloadLength` caps a single frame; `maxQueuedMessages` (default 1024) bounds the per-connection callback backlog and pauses reads (backpressure) when exceeded |
|
||||||
|
| Message ordering | Callbacks for a single connection run **strictly in arrival order** on a per-connection serial drainer (still on virtual threads, so handlers may block) |
|
||||||
|
| Idle / zombie connections | `idleTimeout` triggers a server-side close when no read **and** no write happen within the window |
|
||||||
|
| User code isolation | All callbacks dispatch onto Java virtual threads, never the Netty event loop |
|
||||||
|
| Subprotocol negotiation | Server advertises the configured `subprotocols(...)` list; clients that ask for an unsupported subprotocol fail the handshake |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -304,9 +584,20 @@ RateLimitConfig rlConfig = RateLimitConfig.builder()
|
|||||||
.build();
|
.build();
|
||||||
RateLimitGate gate = new RateLimitGate(rlConfig);
|
RateLimitGate gate = new RateLimitGate(rlConfig);
|
||||||
|
|
||||||
|
// WebSockets
|
||||||
|
WebSocketRouter wsRouter = new WebSocketRouter()
|
||||||
|
.on("/ws/chat", new ChatSocket());
|
||||||
|
|
||||||
|
WebSocketConfig wsConfig = WebSocketConfig.builder()
|
||||||
|
.allowedOrigins("https://app.example.com")
|
||||||
|
.idleTimeout(Duration.ofSeconds(60))
|
||||||
|
.build();
|
||||||
|
|
||||||
HttpServer.builder(8080, router)
|
HttpServer.builder(8080, router)
|
||||||
.withCorsHandler(cors)
|
.withCorsHandler(cors)
|
||||||
.withRateLimitGate(gate)
|
.withRateLimitGate(gate)
|
||||||
|
.withSecurityHeaders(SecurityHeaders.defaults())
|
||||||
|
.withWebSockets(wsRouter, wsConfig)
|
||||||
.start();
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = 'dev.coph'
|
group = 'dev.coph'
|
||||||
version = '0.0.1'
|
version = '0.0.5'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -13,6 +13,10 @@ repositories {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'io.netty:netty-all:4.2.14.Final'
|
implementation 'io.netty:netty-all:4.2.14.Final'
|
||||||
implementation 'tools.jackson.core:jackson-databind:3.1.3'
|
implementation 'tools.jackson.core:jackson-databind:3.1.3'
|
||||||
|
|
||||||
|
testImplementation platform('org.junit:junit-bom:5.11.4')
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
@@ -23,6 +27,14 @@ java {
|
|||||||
targetCompatibility = JavaVersion.VERSION_26
|
targetCompatibility = JavaVersion.VERSION_26
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events "passed", "skipped", "failed"
|
||||||
|
showStandardStreams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
publications {
|
publications {
|
||||||
|
|||||||
@@ -1,41 +1,177 @@
|
|||||||
package dev.coph.nextusweb.server;
|
package dev.coph.nextusweb.server;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.AuthGate;
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
import dev.coph.nextusweb.server.cores.CorsHandler;
|
import dev.coph.nextusweb.server.cores.CorsHandler;
|
||||||
|
import dev.coph.nextusweb.server.net.ClientIp;
|
||||||
|
import dev.coph.nextusweb.server.net.TrustedProxies;
|
||||||
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
||||||
import dev.coph.nextusweb.server.ratelimit.RateLimiter;
|
import dev.coph.nextusweb.server.ratelimit.RateLimiter;
|
||||||
import dev.coph.nextusweb.server.router.Request;
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
import dev.coph.nextusweb.server.router.Response;
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
import dev.coph.nextusweb.server.router.Router;
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||||
|
import dev.coph.nextusweb.server.security.SecurityHeaders;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketConfig;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketFrameHandlerFactory;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketRouter;
|
||||||
import io.netty.buffer.Unpooled;
|
import io.netty.buffer.Unpooled;
|
||||||
import io.netty.channel.ChannelFutureListener;
|
import io.netty.channel.ChannelFutureListener;
|
||||||
import io.netty.channel.ChannelHandlerContext;
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.ChannelPipeline;
|
||||||
import io.netty.channel.SimpleChannelInboundHandler;
|
import io.netty.channel.SimpleChannelInboundHandler;
|
||||||
import io.netty.handler.codec.http.*;
|
import io.netty.handler.codec.http.*;
|
||||||
|
import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator;
|
||||||
|
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolConfig;
|
||||||
|
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||||
|
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler;
|
||||||
|
import io.netty.handler.timeout.IdleStateHandler;
|
||||||
|
|
||||||
|
import java.lang.System.Logger;
|
||||||
|
import java.lang.System.Logger.Level;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core inbound channel handler that processes every aggregated HTTP request.
|
||||||
|
*
|
||||||
|
* <p>For each request it, in order: detects and performs WebSocket upgrades (when a WebSocket
|
||||||
|
* router is configured), answers CORS preflight requests, enforces rate limits, runs the
|
||||||
|
* authentication layer, resolves the route via the {@link Router}, runs middlewares and the
|
||||||
|
* matched handler, and finally writes the response with security, CORS and rate-limit headers
|
||||||
|
* applied.</p>
|
||||||
|
*
|
||||||
|
* <p>Blocking handler logic runs on a virtual-thread executor rather than on the Netty event
|
||||||
|
* loop, so handlers may perform blocking work without stalling I/O. To keep memory bounded the
|
||||||
|
* connection's auto-read is disabled while a request is in flight and re-enabled once the response
|
||||||
|
* has been flushed, so at most one request per connection is buffered at a time. Persistent
|
||||||
|
* (keep-alive) connections are honoured; the connection is closed only when the client requested
|
||||||
|
* {@code Connection: close} or an unrecoverable error occurred.</p>
|
||||||
|
*/
|
||||||
public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
|
public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
|
||||||
|
|
||||||
private static final Executor VT_EXECUTOR =
|
/**
|
||||||
Executors.newVirtualThreadPerTaskExecutor();
|
* Logger used for server-side error diagnostics (never leaked to clients).
|
||||||
|
*/
|
||||||
|
private static final Logger LOG = System.getLogger(HttpRequestHandler.class.getName());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executor running one virtual thread per task, used to offload blocking handler work.
|
||||||
|
*/
|
||||||
|
private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Router resolving requests to handlers.
|
||||||
|
*/
|
||||||
private final Router router;
|
private final Router router;
|
||||||
|
/**
|
||||||
|
* CORS handler, or {@code null} if CORS is disabled.
|
||||||
|
*/
|
||||||
private final CorsHandler cors;
|
private final CorsHandler cors;
|
||||||
|
/**
|
||||||
|
* Rate-limit gate, or {@code null} if rate limiting is disabled.
|
||||||
|
*/
|
||||||
private final RateLimitGate rateLimit;
|
private final RateLimitGate rateLimit;
|
||||||
|
/**
|
||||||
|
* Authentication gate, or {@code null} if the auth layer is disabled.
|
||||||
|
*/
|
||||||
|
private final AuthGate authGate;
|
||||||
|
/**
|
||||||
|
* Trusted-proxy policy used to resolve the client IP; never {@code null}.
|
||||||
|
*/
|
||||||
|
private final TrustedProxies trustedProxies;
|
||||||
|
/**
|
||||||
|
* WebSocket router, or {@code null} if WebSocket support is disabled.
|
||||||
|
*/
|
||||||
|
private final WebSocketRouter wsRouter;
|
||||||
|
/**
|
||||||
|
* WebSocket configuration; only consulted when {@link #wsRouter} is non-null.
|
||||||
|
*/
|
||||||
|
private final WebSocketConfig wsConfig;
|
||||||
|
/**
|
||||||
|
* Security-header policy applied to every response, or {@code null} if disabled.
|
||||||
|
*/
|
||||||
|
private final SecurityHeaders securityHeaders;
|
||||||
|
/**
|
||||||
|
* Whether this server's connections are secured by TLS (gates HSTS emission).
|
||||||
|
*/
|
||||||
|
private final boolean secure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a handler without WebSocket support.
|
||||||
|
*
|
||||||
|
* @param router the router resolving requests
|
||||||
|
* @param cors the CORS handler, or {@code null} to disable CORS
|
||||||
|
* @param rateLimit the rate-limit gate, or {@code null} to disable rate limiting
|
||||||
|
* @param authGate the auth gate, or {@code null} to disable the auth layer
|
||||||
|
* @param trustedProxies the trusted-proxy policy, or {@code null} for {@link TrustedProxies#none()}
|
||||||
|
*/
|
||||||
|
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, AuthGate authGate, TrustedProxies trustedProxies) {
|
||||||
|
this(router, cors, rateLimit, authGate, trustedProxies, null, null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit) {
|
/**
|
||||||
|
* Creates a handler with WebSocket support and a security-header policy.
|
||||||
|
*
|
||||||
|
* @param router the router resolving requests
|
||||||
|
* @param cors the CORS handler, or {@code null} to disable CORS
|
||||||
|
* @param rateLimit the rate-limit gate, or {@code null} to disable rate limiting
|
||||||
|
* @param authGate the auth gate, or {@code null} to disable the auth layer
|
||||||
|
* @param trustedProxies the trusted-proxy policy, or {@code null} for {@link TrustedProxies#none()}
|
||||||
|
* @param wsRouter the WebSocket router, or {@code null} to disable WebSocket support
|
||||||
|
* @param wsConfig the WebSocket configuration, used only when {@code wsRouter} is non-null
|
||||||
|
* @param securityHeaders the security-header policy, or {@code null} to add no security headers
|
||||||
|
* @param secure whether the server's connections are secured by TLS (gates HSTS)
|
||||||
|
*/
|
||||||
|
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, AuthGate authGate, TrustedProxies trustedProxies, WebSocketRouter wsRouter, WebSocketConfig wsConfig, SecurityHeaders securityHeaders, boolean secure) {
|
||||||
this.router = router;
|
this.router = router;
|
||||||
this.cors = cors;
|
this.cors = cors;
|
||||||
this.rateLimit = rateLimit;
|
this.rateLimit = rateLimit;
|
||||||
|
this.authGate = authGate;
|
||||||
|
this.trustedProxies = trustedProxies == null ? TrustedProxies.none() : trustedProxies;
|
||||||
|
this.wsRouter = wsRouter;
|
||||||
|
this.wsConfig = wsConfig;
|
||||||
|
this.securityHeaders = securityHeaders;
|
||||||
|
this.secure = secure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a handler, optionally with WebSocket support.
|
||||||
|
*
|
||||||
|
* @param router the router resolving requests
|
||||||
|
* @param cors the CORS handler, or {@code null} to disable CORS
|
||||||
|
* @param rateLimit the rate-limit gate, or {@code null} to disable rate limiting
|
||||||
|
* @param authGate the auth gate, or {@code null} to disable the auth layer
|
||||||
|
* @param trustedProxies the trusted-proxy policy, or {@code null} for {@link TrustedProxies#none()}
|
||||||
|
* @param wsRouter the WebSocket router, or {@code null} to disable WebSocket support
|
||||||
|
* @param wsConfig the WebSocket configuration, used only when {@code wsRouter} is non-null
|
||||||
|
*/
|
||||||
|
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, AuthGate authGate, TrustedProxies trustedProxies, WebSocketRouter wsRouter, WebSocketConfig wsConfig) {
|
||||||
|
this(router, cors, rateLimit, authGate, trustedProxies, wsRouter, wsConfig, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked by Netty for each fully aggregated request. WebSocket upgrade requests
|
||||||
|
* are handled inline; all other requests disable the connection's auto-read (one in-flight
|
||||||
|
* request per connection) and are retained and dispatched to a virtual thread for processing,
|
||||||
|
* with the request released once handling completes.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param req the aggregated HTTP request
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
|
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||||
|
if (wsRouter != null && isWebSocketUpgrade(req)) {
|
||||||
|
if (handleWebSocketUpgrade(ctx, req)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.channel().config().setAutoRead(false);
|
||||||
|
|
||||||
req.retain();
|
req.retain();
|
||||||
VT_EXECUTOR.execute(() -> {
|
VT_EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -46,64 +182,232 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handle(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
/**
|
||||||
String origin = raw.headers().get("Origin");
|
* Determines whether a request is a WebSocket upgrade handshake, i.e. a {@code GET} carrying
|
||||||
|
* {@code Upgrade: websocket} and a {@code Connection} header that includes the
|
||||||
if (cors != null && cors.isPreflight(raw.method(), raw.headers())) {
|
* {@code upgrade} token.
|
||||||
send(ctx, cors.handlePreflight(origin, raw.headers()));
|
*
|
||||||
return;
|
* @param req the request to inspect
|
||||||
|
* @return {@code true} if the request is a WebSocket upgrade
|
||||||
|
*/
|
||||||
|
private static boolean isWebSocketUpgrade(FullHttpRequest req) {
|
||||||
|
if (req.method() != HttpMethod.GET) return false;
|
||||||
|
String upgrade = req.headers().get(HttpHeaderNames.UPGRADE);
|
||||||
|
if (!"websocket".equalsIgnoreCase(upgrade)) return false;
|
||||||
|
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
|
||||||
|
if (connection == null) return false;
|
||||||
|
for (String token : connection.split(",")) {
|
||||||
|
if ("upgrade".equalsIgnoreCase(token.trim())) return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
String path = new QueryStringDecoder(raw.uri()).path();
|
|
||||||
|
|
||||||
RateLimiter.Result rlResult = null;
|
|
||||||
if (rateLimit != null) {
|
|
||||||
String remote = ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress().getHostAddress();
|
|
||||||
rlResult = rateLimit.check(raw, path, remote);
|
|
||||||
if (rlResult != null && !rlResult.allowed()) {
|
|
||||||
Response res = new Response().status(429).json("{\"error\":\"Too Many Requests\"}");
|
|
||||||
RateLimitGate.applyHeaders(rlResult, res);
|
|
||||||
if (cors != null) cors.applyHeaders(origin, res);
|
|
||||||
send(ctx, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Router.Resolution resolution = router.resolve(raw.method(), path);
|
|
||||||
|
|
||||||
Response res = new Response();
|
|
||||||
|
|
||||||
switch (resolution) {
|
|
||||||
case Router.Resolution.Match m -> {
|
|
||||||
Request request = new Request(raw, m.pathParams());
|
|
||||||
try {
|
|
||||||
for (var mw : router.middlewares()) mw.accept(request, res);
|
|
||||||
m.handler().handle(request, res);
|
|
||||||
} catch (BadRequestException e) {
|
|
||||||
res.status(400).json("{\"error\":\"" + e.getMessage() + "\"}");
|
|
||||||
} catch (Exception e) {
|
|
||||||
res.status(500).text("Internal Server Error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case Router.Resolution.MethodNotAllowed mna -> {
|
|
||||||
String allow = mna.allowedMethods().stream()
|
|
||||||
.map(HttpMethod::name)
|
|
||||||
.sorted()
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
res.status(405)
|
|
||||||
.header(HttpHeaderNames.ALLOW.toString(), allow)
|
|
||||||
.json("{\"error\":\"Method Not Allowed\",\"allowed\":\"" + allow + "\"}");
|
|
||||||
}
|
|
||||||
case Router.Resolution.NotFound nf -> res.status(404).json("{\"error\":\"Not Found\"}");
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimitGate.applyHeaders(rlResult, res);
|
|
||||||
if (cors != null) cors.applyHeaders(origin, res);
|
|
||||||
send(ctx, res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void send(ChannelHandlerContext ctx, Response res) {
|
/**
|
||||||
|
* Attempts to upgrade the connection to WebSocket for the request's path.
|
||||||
|
*
|
||||||
|
* <p>Resolves the path against the WebSocket router; if no handler matches the upgrade is
|
||||||
|
* declined. Otherwise the origin is validated, the auth layer (if configured) authenticates
|
||||||
|
* the upgrade and may reject it, and then the WebSocket protocol/compression/idle handlers and
|
||||||
|
* the application frame handler are inserted into the pipeline and the request is re-fired so
|
||||||
|
* Netty performs the handshake. The resolved principal (if any) is handed to the session.</p>
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param req the upgrade request
|
||||||
|
* @return {@code true} if the request was consumed (handshake started or rejected),
|
||||||
|
* {@code false} if no WebSocket route matched and normal HTTP handling should continue
|
||||||
|
*/
|
||||||
|
private boolean handleWebSocketUpgrade(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||||
|
String path = new QueryStringDecoder(req.uri()).path();
|
||||||
|
WebSocketRouter.Resolution resolution = wsRouter.resolve(path);
|
||||||
|
if (resolution == null) return false;
|
||||||
|
|
||||||
|
String origin = req.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
|
if (!wsConfig.isOriginAllowed(origin)) {
|
||||||
|
sendStatusAndClose(ctx, HttpResponseStatus.FORBIDDEN);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Principal principal = null;
|
||||||
|
if (authGate != null) {
|
||||||
|
Request upgradeReq = new Request(req, resolution.pathParams());
|
||||||
|
upgradeReq.clientIp(resolveClientIp(ctx, req));
|
||||||
|
Response rejection = authGate.authenticate(upgradeReq, path);
|
||||||
|
if (rejection != null) {
|
||||||
|
sendStatusAndClose(ctx, HttpResponseStatus.valueOf(rejection.status()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
principal = upgradeReq.principal();
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketServerProtocolConfig protoCfg = WebSocketServerProtocolConfig.newBuilder()
|
||||||
|
.websocketPath(path)
|
||||||
|
.checkStartsWith(false)
|
||||||
|
.subprotocols(wsConfig.subprotocolsCsv())
|
||||||
|
.maxFramePayloadLength(wsConfig.maxFramePayloadLength())
|
||||||
|
.allowExtensions(wsConfig.compression())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChannelPipeline pipeline = ctx.pipeline();
|
||||||
|
String myName = ctx.name();
|
||||||
|
|
||||||
|
if (pipeline.get("read-timeout") != null) {
|
||||||
|
pipeline.remove("read-timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsConfig.idleTimeout() != null) {
|
||||||
|
long secs = Math.max(1, wsConfig.idleTimeout().toSeconds());
|
||||||
|
pipeline.addBefore(myName, "ws-idle", new IdleStateHandler(0, 0, secs, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
if (wsConfig.compression()) {
|
||||||
|
pipeline.addBefore(myName, "ws-deflate", new WebSocketServerCompressionHandler());
|
||||||
|
}
|
||||||
|
pipeline.addBefore(myName, "ws-proto", new WebSocketServerProtocolHandler(protoCfg));
|
||||||
|
|
||||||
|
pipeline.addBefore(myName, "ws-aggregator", new WebSocketFrameAggregator(wsConfig.maxAggregatedMessageSize()));
|
||||||
|
pipeline.addBefore(myName, "ws-frames", WebSocketFrameHandlerFactory.create(resolution.handler(), path, resolution.pathParams(), principal, wsConfig.maxQueuedMessages()));
|
||||||
|
|
||||||
|
ChannelHandlerContext anchor = pipeline.context(HttpObjectAggregator.class);
|
||||||
|
if (anchor == null) anchor = pipeline.firstContext();
|
||||||
|
anchor.fireChannelRead(req.retain());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a normal (non-WebSocket) HTTP request: applies CORS preflight handling, rate
|
||||||
|
* limiting and authentication, resolves the route, runs middlewares and the handler, and sends
|
||||||
|
* the response.
|
||||||
|
*
|
||||||
|
* <p>The whole method is guarded so that <em>some</em> response is always produced — even an
|
||||||
|
* unexpected failure in the pre-handler stages yields a generic {@code 500} rather than a
|
||||||
|
* leaked, hung connection. Handler exceptions are mapped to responses: a
|
||||||
|
* {@link BadRequestException} becomes a {@code 400}, any other exception a {@code 500} whose
|
||||||
|
* details are logged server-side but never sent to the client. Routing misses become
|
||||||
|
* {@code 404}, method mismatches a {@code 405} with an {@code Allow} header.</p>
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param raw the aggregated request being handled
|
||||||
|
*/
|
||||||
|
private void handle(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
||||||
|
boolean keepAlive = HttpUtil.isKeepAlive(raw);
|
||||||
|
try {
|
||||||
|
String origin = raw.headers().get(HttpHeaderNames.ORIGIN);
|
||||||
|
|
||||||
|
if (cors != null && cors.isPreflight(raw.method(), raw.headers())) {
|
||||||
|
send(ctx, cors.handlePreflight(origin, raw.headers()), keepAlive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String path = new QueryStringDecoder(raw.uri()).path();
|
||||||
|
String clientIp = resolveClientIp(ctx, raw);
|
||||||
|
|
||||||
|
Router.Resolution resolution = router.resolve(raw.method(), path);
|
||||||
|
Map<String, String> params = resolution instanceof Router.Resolution.Match m ? m.pathParams() : Map.of();
|
||||||
|
Request request = new Request(raw, params);
|
||||||
|
request.clientIp(clientIp);
|
||||||
|
|
||||||
|
RateLimiter.Result rlResult = null;
|
||||||
|
if (rateLimit != null) {
|
||||||
|
rlResult = rateLimit.check(request, path, clientIp);
|
||||||
|
if (rlResult != null && !rlResult.allowed()) {
|
||||||
|
Response res = new Response().status(429).json("{\"error\":\"Too Many Requests\"}");
|
||||||
|
RateLimitGate.applyHeaders(rlResult, res);
|
||||||
|
if (cors != null) cors.applyHeaders(origin, res);
|
||||||
|
send(ctx, res, keepAlive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authGate != null) {
|
||||||
|
Response rejection = authGate.authenticate(request, path);
|
||||||
|
if (rejection != null) {
|
||||||
|
RateLimitGate.applyHeaders(rlResult, rejection);
|
||||||
|
if (cors != null) cors.applyHeaders(origin, rejection);
|
||||||
|
send(ctx, rejection, keepAlive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response res = new Response();
|
||||||
|
switch (resolution) {
|
||||||
|
case Router.Resolution.Match m -> {
|
||||||
|
try {
|
||||||
|
for (var mw : router.middlewares())
|
||||||
|
mw.accept(request, res);
|
||||||
|
m.handler().handle(request, res);
|
||||||
|
} catch (BadRequestException e) {
|
||||||
|
res.status(400).json(Map.of("error", e.getMessage() == null ? "Bad Request" : e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.log(Level.ERROR, "Handler failed for " + raw.method() + " " + path, e);
|
||||||
|
res.status(500).json("{\"error\":\"Internal Server Error\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case Router.Resolution.MethodNotAllowed mna -> {
|
||||||
|
String allow = mna.allowedMethods().stream()
|
||||||
|
.map(HttpMethod::name)
|
||||||
|
.sorted()
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
res.status(405)
|
||||||
|
.header(HttpHeaderNames.ALLOW.toString(), allow)
|
||||||
|
.json(Map.of("error", "Method Not Allowed", "allowed", allow));
|
||||||
|
}
|
||||||
|
case Router.Resolution.NotFound nf -> res.status(404).json("{\"error\":\"Not Found\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimitGate.applyHeaders(rlResult, res);
|
||||||
|
if (cors != null) cors.applyHeaders(origin, res);
|
||||||
|
send(ctx, res, keepAlive);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
LOG.log(Level.ERROR, "Unexpected failure while handling request", t);
|
||||||
|
try {
|
||||||
|
send(ctx, new Response().status(500).json("{\"error\":\"Internal Server Error\"}"), false);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
ctx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an empty-bodied response with the given status and closes the connection, used for
|
||||||
|
* rejected WebSocket upgrades.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param status the HTTP status to send
|
||||||
|
*/
|
||||||
|
private static void sendStatusAndClose(ChannelHandlerContext ctx, HttpResponseStatus status) {
|
||||||
|
FullHttpResponse res = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
|
||||||
|
res.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0);
|
||||||
|
ctx.writeAndFlush(res).addListener(ChannelFutureListener.CLOSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective client IP for a request, honouring the configured trusted proxies.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param raw the request (for the forwarded-for header)
|
||||||
|
* @return the resolved client IP, or {@code "unknown"} if the peer address is unavailable
|
||||||
|
*/
|
||||||
|
private String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
||||||
|
SocketAddress addr = ctx.channel().remoteAddress();
|
||||||
|
String socketIp = (addr instanceof InetSocketAddress isa && isa.getAddress() != null) ? isa.getAddress().getHostAddress() : "unknown";
|
||||||
|
String forwarded = raw.headers().get(ClientIp.FORWARDED_FOR_HEADER);
|
||||||
|
return ClientIp.resolve(socketIp, forwarded, trustedProxies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the framework {@link Response} into a Netty {@link FullHttpResponse}, sets the
|
||||||
|
* {@code Content-Length} and {@code Connection} headers and writes it. For keep-alive
|
||||||
|
* connections the connection is kept open and reading resumes for the next request; otherwise
|
||||||
|
* the connection is closed after the write.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param res the response to send
|
||||||
|
* @param keepAlive whether the client requested a persistent connection
|
||||||
|
*/
|
||||||
|
private void send(ChannelHandlerContext ctx, Response res, boolean keepAlive) {
|
||||||
|
if (securityHeaders != null) {
|
||||||
|
securityHeaders.apply(res, secure);
|
||||||
|
}
|
||||||
var nettyRes = new DefaultFullHttpResponse(
|
var nettyRes = new DefaultFullHttpResponse(
|
||||||
HttpVersion.HTTP_1_1,
|
HttpVersion.HTTP_1_1,
|
||||||
HttpResponseStatus.valueOf(res.status()),
|
HttpResponseStatus.valueOf(res.status()),
|
||||||
@@ -111,11 +415,37 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
|||||||
);
|
);
|
||||||
nettyRes.headers().add(res.headers());
|
nettyRes.headers().add(res.headers());
|
||||||
nettyRes.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, res.body().length);
|
nettyRes.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, res.body().length);
|
||||||
ctx.writeAndFlush(nettyRes).addListener(ChannelFutureListener.CLOSE);
|
|
||||||
|
if (keepAlive) {
|
||||||
|
nettyRes.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
|
||||||
|
ctx.writeAndFlush(nettyRes).addListener(future -> resumeReading(ctx));
|
||||||
|
} else {
|
||||||
|
nettyRes.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
|
||||||
|
ctx.writeAndFlush(nettyRes).addListener(ChannelFutureListener.CLOSE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enables auto-read and requests the next message, resuming intake on a persistent
|
||||||
|
* connection after its in-flight request has been answered.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
*/
|
||||||
|
private static void resumeReading(ChannelHandlerContext ctx) {
|
||||||
|
if (ctx.channel().isActive()) {
|
||||||
|
ctx.channel().config().setAutoRead(true);
|
||||||
|
ctx.read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the channel on any unhandled pipeline exception (including read timeouts).
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param cause the exception that propagated up the pipeline
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||||
ctx.close();
|
ctx.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package dev.coph.nextusweb.server;
|
package dev.coph.nextusweb.server;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.AuthGate;
|
||||||
import dev.coph.nextusweb.server.cores.CorsHandler;
|
import dev.coph.nextusweb.server.cores.CorsHandler;
|
||||||
|
import dev.coph.nextusweb.server.net.TrustedProxies;
|
||||||
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
||||||
import dev.coph.nextusweb.server.router.Router;
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
|
import dev.coph.nextusweb.server.security.SecurityHeaders;
|
||||||
|
import dev.coph.nextusweb.server.tls.TlsConfig;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketConfig;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketRouter;
|
||||||
import io.netty.bootstrap.ServerBootstrap;
|
import io.netty.bootstrap.ServerBootstrap;
|
||||||
import io.netty.channel.*;
|
import io.netty.channel.*;
|
||||||
import io.netty.channel.epoll.Epoll;
|
import io.netty.channel.epoll.Epoll;
|
||||||
@@ -16,39 +22,233 @@ import io.netty.channel.socket.SocketChannel;
|
|||||||
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
import io.netty.channel.socket.nio.NioServerSocketChannel;
|
||||||
import io.netty.handler.codec.http.HttpObjectAggregator;
|
import io.netty.handler.codec.http.HttpObjectAggregator;
|
||||||
import io.netty.handler.codec.http.HttpServerCodec;
|
import io.netty.handler.codec.http.HttpServerCodec;
|
||||||
|
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps and runs the Netty-based HTTP (and optionally WebSocket) server.
|
||||||
|
*
|
||||||
|
* <p>The class doubles as a small fluent builder: {@link #builder(int, Router)} creates an
|
||||||
|
* instance bound to a port and {@link Router}, and the {@code withXxx}/setter methods attach
|
||||||
|
* optional features (TLS, CORS, rate limiting, authentication, WebSockets, trusted proxies,
|
||||||
|
* limits and timeouts) before {@link #start()} launches the server.</p>
|
||||||
|
*
|
||||||
|
* <p>At start-up it selects the most efficient available transport — {@code epoll} on
|
||||||
|
* Linux, {@code kqueue} on macOS/BSD, or the portable NIO transport otherwise — and wires
|
||||||
|
* up the Netty channel pipeline (optional TLS, a read timeout that defeats slow-client attacks,
|
||||||
|
* the HTTP codec, aggregator and the {@link HttpRequestHandler}). The {@link #start()} call
|
||||||
|
* blocks until the server channel is closed.</p>
|
||||||
|
*/
|
||||||
public final class HttpServer {
|
public final class HttpServer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default cap on aggregated HTTP request bodies: 1 MiB.
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_MAX_HTTP_CONTENT_LENGTH = 1_048_576;
|
||||||
|
/**
|
||||||
|
* Default per-connection read timeout that reaps slow/idle clients.
|
||||||
|
*/
|
||||||
|
private static final Duration DEFAULT_HTTP_READ_TIMEOUT = Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCP port the server binds to.
|
||||||
|
*/
|
||||||
private final int port;
|
private final int port;
|
||||||
|
/**
|
||||||
|
* Router resolving requests to handlers.
|
||||||
|
*/
|
||||||
private final Router router;
|
private final Router router;
|
||||||
|
/**
|
||||||
|
* Optional TLS configuration; {@code null} serves plain HTTP.
|
||||||
|
*/
|
||||||
|
private TlsConfig tls;
|
||||||
|
/**
|
||||||
|
* Optional CORS handler; {@code null} disables CORS handling.
|
||||||
|
*/
|
||||||
private CorsHandler cors;
|
private CorsHandler cors;
|
||||||
|
/**
|
||||||
|
* Optional rate-limit gate; {@code null} disables rate limiting.
|
||||||
|
*/
|
||||||
private RateLimitGate gate;
|
private RateLimitGate gate;
|
||||||
|
/**
|
||||||
|
* Optional authentication gate; {@code null} disables the auth layer.
|
||||||
|
*/
|
||||||
|
private AuthGate authGate;
|
||||||
|
/**
|
||||||
|
* Optional security-header policy; {@code null} adds no security headers.
|
||||||
|
*/
|
||||||
|
private SecurityHeaders securityHeaders;
|
||||||
|
/**
|
||||||
|
* Optional WebSocket router; {@code null} disables WebSocket support.
|
||||||
|
*/
|
||||||
|
private WebSocketRouter wsRouter;
|
||||||
|
/**
|
||||||
|
* WebSocket configuration; only used when {@link #wsRouter} is set.
|
||||||
|
*/
|
||||||
|
private WebSocketConfig wsConfig;
|
||||||
|
/**
|
||||||
|
* Trusted-proxy policy for resolving the client IP; never {@code null}.
|
||||||
|
*/
|
||||||
|
private TrustedProxies trustedProxies = TrustedProxies.none();
|
||||||
|
/**
|
||||||
|
* Maximum aggregated HTTP request body size in bytes.
|
||||||
|
*/
|
||||||
|
private int maxHttpContentLength = DEFAULT_MAX_HTTP_CONTENT_LENGTH;
|
||||||
|
/**
|
||||||
|
* Per-connection HTTP read timeout; {@code null} or non-positive disables it.
|
||||||
|
*/
|
||||||
|
private Duration httpReadTimeout = DEFAULT_HTTP_READ_TIMEOUT;
|
||||||
|
|
||||||
private HttpServer(int port, Router router) {
|
private HttpServer(int port, Router router) {
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.router = router;
|
this.router = Objects.requireNonNull(router, "router");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts building a server for the given port and router.
|
||||||
|
*
|
||||||
|
* @param port the TCP port to bind
|
||||||
|
* @param router the router resolving requests
|
||||||
|
* @return a new, configurable {@code HttpServer} instance
|
||||||
|
*/
|
||||||
public static HttpServer builder(int port, Router router) {
|
public static HttpServer builder(int port, Router router) {
|
||||||
return new HttpServer(port, router);
|
return new HttpServer(port, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables TLS (HTTPS / WSS). The TLS handler becomes the first element of every connection's
|
||||||
|
* pipeline.
|
||||||
|
*
|
||||||
|
* @param tls the TLS configuration
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer withTls(TlsConfig tls) {
|
||||||
|
this.tls = tls;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches a CORS handler that decorates responses and answers preflight requests.
|
||||||
|
*
|
||||||
|
* @param cors the CORS handler to use
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
public HttpServer withCorsHandler(CorsHandler cors) {
|
public HttpServer withCorsHandler(CorsHandler cors) {
|
||||||
this.cors = cors;
|
this.cors = cors;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches a rate-limit gate that throttles incoming requests.
|
||||||
|
*
|
||||||
|
* @param gate the rate-limit gate to use
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
public HttpServer withRateLimitGate(RateLimitGate gate) {
|
public HttpServer withRateLimitGate(RateLimitGate gate) {
|
||||||
this.gate = gate;
|
this.gate = gate;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() throws InterruptedException {
|
/**
|
||||||
start(port, router, cors, gate);
|
* Attaches an authentication gate that authenticates configured requests before they reach
|
||||||
|
* handlers and attaches the resolved principal to the request.
|
||||||
|
*
|
||||||
|
* @param authGate the auth gate to use
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer withAuth(AuthGate authGate) {
|
||||||
|
this.authGate = authGate;
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void start(int port, Router router, CorsHandler cors, RateLimitGate gate)
|
/**
|
||||||
throws InterruptedException {
|
* Attaches a security-header policy whose headers are added to every response. HSTS, if
|
||||||
|
* configured, is emitted only when TLS is enabled on this server. Existing headers set by a
|
||||||
|
* handler are preserved.
|
||||||
|
*
|
||||||
|
* @param securityHeaders the security-header policy to apply
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
* @see SecurityHeaders#defaults()
|
||||||
|
*/
|
||||||
|
public HttpServer withSecurityHeaders(SecurityHeaders securityHeaders) {
|
||||||
|
this.securityHeaders = securityHeaders;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures which transport peers are trusted reverse proxies, controlling whether
|
||||||
|
* {@code X-Forwarded-For} is honoured when resolving the client IP. Defaults to
|
||||||
|
* {@link TrustedProxies#none()} (forwarded headers ignored).
|
||||||
|
*
|
||||||
|
* @param trustedProxies the trusted-proxy policy
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer withTrustedProxies(TrustedProxies trustedProxies) {
|
||||||
|
this.trustedProxies = Objects.requireNonNull(trustedProxies, "trustedProxies");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the maximum aggregated HTTP request body size in bytes. Requests exceeding it are
|
||||||
|
* rejected by the aggregator with {@code 413 Request Entity Too Large}.
|
||||||
|
*
|
||||||
|
* @param bytes the limit in bytes; must be positive
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer maxHttpContentLength(int bytes) {
|
||||||
|
if (bytes <= 0)
|
||||||
|
throw new IllegalArgumentException("maxHttpContentLength must be > 0");
|
||||||
|
this.maxHttpContentLength = bytes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the per-connection HTTP read timeout. A connection that sends no data for this long is
|
||||||
|
* closed, which both reaps idle keep-alive connections and defeats slow-client (Slowloris)
|
||||||
|
* attacks. Pass {@code null} or a non-positive duration to disable it.
|
||||||
|
*
|
||||||
|
* @param timeout the read timeout, or {@code null}/non-positive to disable
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer httpReadTimeout(Duration timeout) {
|
||||||
|
this.httpReadTimeout = timeout;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables WebSocket support with default configuration.
|
||||||
|
*
|
||||||
|
* @param wsRouter the WebSocket router resolving upgrade paths to handlers
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
* @see #withWebSockets(WebSocketRouter, WebSocketConfig)
|
||||||
|
*/
|
||||||
|
public HttpServer withWebSockets(WebSocketRouter wsRouter) {
|
||||||
|
return withWebSockets(wsRouter, WebSocketConfig.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables WebSocket support with explicit configuration.
|
||||||
|
*
|
||||||
|
* @param wsRouter the WebSocket router resolving upgrade paths to handlers
|
||||||
|
* @param wsConfig the WebSocket configuration (frame sizes, timeouts, origins, ...)
|
||||||
|
* @return this instance, for fluent chaining
|
||||||
|
*/
|
||||||
|
public HttpServer withWebSockets(WebSocketRouter wsRouter, WebSocketConfig wsConfig) {
|
||||||
|
this.wsRouter = wsRouter;
|
||||||
|
this.wsConfig = wsConfig;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the server using the configuration accumulated on this instance and blocks until the
|
||||||
|
* server channel closes.
|
||||||
|
*
|
||||||
|
* @throws InterruptedException if the binding or close-future wait is interrupted
|
||||||
|
*/
|
||||||
|
public void start() throws InterruptedException {
|
||||||
EventLoopGroup boss, worker;
|
EventLoopGroup boss, worker;
|
||||||
Class<? extends ServerChannel> channelClass;
|
Class<? extends ServerChannel> channelClass;
|
||||||
|
|
||||||
@@ -66,6 +266,18 @@ public final class HttpServer {
|
|||||||
channelClass = NioServerSocketChannel.class;
|
channelClass = NioServerSocketChannel.class;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final TlsConfig tlsCfg = this.tls;
|
||||||
|
final CorsHandler corsHandler = this.cors;
|
||||||
|
final RateLimitGate rateLimitGate = this.gate;
|
||||||
|
final AuthGate auth = this.authGate;
|
||||||
|
final WebSocketRouter websocketRouter = this.wsRouter;
|
||||||
|
final WebSocketConfig websocketConfig = this.wsConfig;
|
||||||
|
final SecurityHeaders secHeaders = this.securityHeaders;
|
||||||
|
final boolean tlsEnabled = tlsCfg != null;
|
||||||
|
final TrustedProxies proxies = this.trustedProxies;
|
||||||
|
final int maxContent = this.maxHttpContentLength;
|
||||||
|
final long readTimeoutSeconds = (httpReadTimeout != null && !httpReadTimeout.isZero() && !httpReadTimeout.isNegative()) ? Math.max(1, httpReadTimeout.toSeconds()) : 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new ServerBootstrap()
|
new ServerBootstrap()
|
||||||
.group(boss, worker)
|
.group(boss, worker)
|
||||||
@@ -76,10 +288,16 @@ public final class HttpServer {
|
|||||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||||
@Override
|
@Override
|
||||||
protected void initChannel(SocketChannel ch) {
|
protected void initChannel(SocketChannel ch) {
|
||||||
ch.pipeline()
|
ChannelPipeline pipeline = ch.pipeline();
|
||||||
.addLast(new HttpServerCodec())
|
if (tlsCfg != null) {
|
||||||
.addLast(new HttpObjectAggregator(1024 * 1024))
|
pipeline.addLast("ssl", tlsCfg.newHandler(ch.alloc()));
|
||||||
.addLast(new HttpRequestHandler(router, cors, gate));
|
}
|
||||||
|
if (readTimeoutSeconds > 0) {
|
||||||
|
pipeline.addLast("read-timeout", new ReadTimeoutHandler(readTimeoutSeconds, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
pipeline.addLast(new HttpServerCodec())
|
||||||
|
.addLast(new HttpObjectAggregator(maxContent))
|
||||||
|
.addLast(new HttpRequestHandler(router, corsHandler, rateLimitGate, auth, proxies, websocketRouter, websocketConfig, secHeaders, tlsEnabled));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.bind(port).sync().channel().closeFuture().sync();
|
.bind(port).sync().channel().closeFuture().sync();
|
||||||
@@ -88,4 +306,4 @@ public final class HttpServer {
|
|||||||
worker.shutdownGracefully();
|
worker.shutdownGracefully();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,69 @@ import dev.coph.nextusweb.server.router.Request;
|
|||||||
import dev.coph.nextusweb.server.router.Response;
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
import dev.coph.nextusweb.server.router.Router;
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
import io.netty.handler.codec.http.HttpMethod;
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
|
||||||
import java.lang.invoke.MethodHandle;
|
import java.lang.invoke.MethodHandle;
|
||||||
import java.lang.invoke.MethodHandles;
|
import java.lang.invoke.MethodHandles;
|
||||||
import java.lang.invoke.MethodType;
|
import java.lang.invoke.MethodType;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflective registrar that wires the routing annotations on a controller object into a
|
||||||
|
* {@link Router}.
|
||||||
|
*
|
||||||
|
* <p>Given a controller instance, the scanner reads the optional {@link Controller} annotation
|
||||||
|
* to determine a path prefix, then walks every declared method looking for one of the
|
||||||
|
* supported route annotations ({@link Route}, {@link GET}, {@link POST}, {@link PUT},
|
||||||
|
* {@link DELETE}, {@link PATCH} or {@link CUSTOM}). For each matching method it:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>validates that the method has the required {@code (Request, Response)} signature and
|
||||||
|
* a {@code void} return type;</li>
|
||||||
|
* <li>creates a {@link MethodHandle} bound to the controller instance for fast,
|
||||||
|
* reflection-free invocation;</li>
|
||||||
|
* <li>registers a {@link Router.Handler} that delegates to that handle under the resolved
|
||||||
|
* HTTP method and full path.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>This class is a stateless utility and cannot be instantiated.</p>
|
||||||
|
*
|
||||||
|
* @see Controller
|
||||||
|
* @see Router
|
||||||
|
*/
|
||||||
public final class AnnotationScanner {
|
public final class AnnotationScanner {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared lookup used to unreflect controller methods into {@link MethodHandle}s. A single
|
||||||
|
* lookup is sufficient because the scanner forces accessibility on each method before
|
||||||
|
* unreflecting it.
|
||||||
|
*/
|
||||||
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
|
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact method type every handler must conform to: {@code void (Request, Response)}.
|
||||||
|
* Used as documentation of the contract enforced by {@link #validateSignature(Method)}.
|
||||||
|
*/
|
||||||
private static final MethodType HANDLER_TYPE =
|
private static final MethodType HANDLER_TYPE =
|
||||||
MethodType.methodType(void.class, Request.class, Response.class);
|
MethodType.methodType(void.class, Request.class, Response.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor preventing instantiation of this stateless utility class.
|
||||||
|
*/
|
||||||
private AnnotationScanner() {
|
private AnnotationScanner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the given controller for route annotations and registers every discovered handler
|
||||||
|
* with the supplied router.
|
||||||
|
*
|
||||||
|
* <p>If the controller class is annotated with {@link Controller}, its value is used as a
|
||||||
|
* path prefix for all routes. Methods without a recognised route annotation are ignored.
|
||||||
|
* A line describing each registered route is printed to standard output.</p>
|
||||||
|
*
|
||||||
|
* @param router the router to register the discovered handlers with
|
||||||
|
* @param controller the controller instance whose annotated methods should be registered
|
||||||
|
* @throws IllegalArgumentException if an annotated method has an invalid signature
|
||||||
|
* @throws RuntimeException if a method cannot be made accessible or unreflected
|
||||||
|
*/
|
||||||
public static void register(Router router, Object controller) {
|
public static void register(Router router, Object controller) {
|
||||||
Class<?> clazz = controller.getClass();
|
Class<?> clazz = controller.getClass();
|
||||||
Controller ctrlAnno = clazz.getAnnotation(Controller.class);
|
Controller ctrlAnno = clazz.getAnnotation(Controller.class);
|
||||||
@@ -55,11 +104,27 @@ public final class AnnotationScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a controller-level path prefix by ensuring it starts with a single leading
|
||||||
|
* slash.
|
||||||
|
*
|
||||||
|
* @param p the raw prefix from the {@link Controller} annotation, may be {@code null} or empty
|
||||||
|
* @return the normalized prefix, or an empty string if {@code p} is {@code null} or empty
|
||||||
|
*/
|
||||||
private static String normalizePrefix(String p) {
|
private static String normalizePrefix(String p) {
|
||||||
if (p == null || p.isEmpty()) return "";
|
if (p == null || p.isEmpty()) return "";
|
||||||
return p.startsWith("/") ? p : "/" + p;
|
return p.startsWith("/") ? p : "/" + p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts route metadata (HTTP method and path) from a method by inspecting the supported
|
||||||
|
* route annotations in priority order. {@link Route} is checked first, followed by the
|
||||||
|
* verb-specific annotations and finally {@link CUSTOM}.
|
||||||
|
*
|
||||||
|
* @param m the method to inspect
|
||||||
|
* @return a {@link RouteInfo} describing the route, or {@code null} if the method carries
|
||||||
|
* no recognised route annotation
|
||||||
|
*/
|
||||||
private static RouteInfo extractRoute(Method m) {
|
private static RouteInfo extractRoute(Method m) {
|
||||||
Route r = m.getAnnotation(Route.class);
|
Route r = m.getAnnotation(Route.class);
|
||||||
if (r != null) return new RouteInfo(r.method(), r.path());
|
if (r != null) return new RouteInfo(r.method(), r.path());
|
||||||
@@ -75,16 +140,24 @@ public final class AnnotationScanner {
|
|||||||
|
|
||||||
DELETE del = m.getAnnotation(DELETE.class);
|
DELETE del = m.getAnnotation(DELETE.class);
|
||||||
if (del != null) return new RouteInfo("DELETE", del.value());
|
if (del != null) return new RouteInfo("DELETE", del.value());
|
||||||
|
|
||||||
PATCH patch = m.getAnnotation(PATCH.class);
|
PATCH patch = m.getAnnotation(PATCH.class);
|
||||||
if (patch != null) return new RouteInfo("PATCH", patch.value());
|
if (patch != null) return new RouteInfo("PATCH", patch.value());
|
||||||
|
|
||||||
CUSTOM custom = m.getAnnotation(CUSTOM.class);
|
CUSTOM custom = m.getAnnotation(CUSTOM.class);
|
||||||
if (custom != null) return new RouteInfo(custom.method(), custom.value());
|
if (custom != null) return new RouteInfo(custom.method(), custom.value());
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a handler method conforms to the required {@code void (Request, Response)}
|
||||||
|
* contract.
|
||||||
|
*
|
||||||
|
* @param m the method to validate
|
||||||
|
* @throws IllegalArgumentException if the method does not take exactly a {@link Request}
|
||||||
|
* and a {@link Response}, or does not return {@code void}
|
||||||
|
*/
|
||||||
private static void validateSignature(Method m) {
|
private static void validateSignature(Method m) {
|
||||||
Class<?>[] params = m.getParameterTypes();
|
Class<?>[] params = m.getParameterTypes();
|
||||||
if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) {
|
if (params.length != 2 || params[0] != Request.class || params[1] != Response.class) {
|
||||||
@@ -95,11 +168,23 @@ public final class AnnotationScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a route-level path by ensuring it starts with a single leading slash.
|
||||||
|
*
|
||||||
|
* @param p the raw path from a route annotation, may be {@code null} or empty
|
||||||
|
* @return the normalized path, or an empty string if {@code p} is {@code null} or empty
|
||||||
|
*/
|
||||||
private static String normalizePath(String p) {
|
private static String normalizePath(String p) {
|
||||||
if (p == null || p.isEmpty()) return "";
|
if (p == null || p.isEmpty()) return "";
|
||||||
return p.startsWith("/") ? p : "/" + p;
|
return p.startsWith("/") ? p : "/" + p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable carrier for the HTTP method and path extracted from a route annotation.
|
||||||
|
*
|
||||||
|
* @param method the HTTP method name (e.g. {@code "GET"})
|
||||||
|
* @param path the route path relative to the controller prefix
|
||||||
|
*/
|
||||||
private record RouteInfo(String method, String path) {
|
private record RouteInfo(String method, String path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,39 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to a route using a custom or non-standard HTTP method. Whereas
|
||||||
|
* {@link GET}, {@link POST} and the other verb annotations hard-code the verb, {@code @CUSTOM}
|
||||||
|
* lets the caller name the verb explicitly via {@link #method()} (for example {@code "HEAD"},
|
||||||
|
* {@code "OPTIONS"} or a WebDAV-style verb).
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface CUSTOM {
|
public @interface CUSTOM {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP method name this route responds to. Must be a value accepted by
|
||||||
|
* {@link io.netty.handler.codec.http.HttpMethod#valueOf(String)}.
|
||||||
|
*
|
||||||
|
* @return the HTTP method name
|
||||||
|
*/
|
||||||
String method();
|
String method();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this route is mounted at, relative to any controller prefix. Supports
|
||||||
|
* {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,36 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a class as a <em>controller</em>: a container for HTTP handler methods that are
|
||||||
|
* discovered and wired into the {@link dev.coph.nextusweb.server.router.Router Router} at
|
||||||
|
* runtime by the {@link AnnotationScanner}.
|
||||||
|
*
|
||||||
|
* <p>The optional {@link #value()} acts as a common path prefix that is prepended to every
|
||||||
|
* route declared inside the annotated class. For example, a controller annotated with
|
||||||
|
* {@code @Controller("/api")} whose method is annotated with {@code @GET("/users")} will be
|
||||||
|
* registered under {@code /api/users}.</p>
|
||||||
|
*
|
||||||
|
* <p>This annotation is retained at {@link RetentionPolicy#RUNTIME runtime} because the
|
||||||
|
* scanner inspects it reflectively while the application is running, and it may only be
|
||||||
|
* placed on {@link ElementType#TYPE types} (classes).</p>
|
||||||
|
*
|
||||||
|
* @see AnnotationScanner
|
||||||
|
* @see Route
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.TYPE)
|
@Target(ElementType.TYPE)
|
||||||
public @interface Controller {
|
public @interface Controller {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base path prefix that is prepended to every route declared in the annotated
|
||||||
|
* controller class.
|
||||||
|
*
|
||||||
|
* <p>A leading slash is optional; the scanner normalizes the value so that
|
||||||
|
* {@code "api"} and {@code "/api"} behave identically. The default empty string means
|
||||||
|
* the controller contributes no prefix and its routes are registered as-is.</p>
|
||||||
|
*
|
||||||
|
* @return the path prefix, or an empty string for no prefix
|
||||||
|
*/
|
||||||
String value() default "";
|
String value() default "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to an HTTP {@code DELETE} route. This is a convenience shorthand
|
||||||
|
* for {@link Route @Route(method = "DELETE", path = ...)}.
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface DELETE {
|
public @interface DELETE {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this {@code DELETE} route is mounted at, relative to any controller prefix.
|
||||||
|
* Supports {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to an HTTP {@code GET} route. This is a convenience shorthand for
|
||||||
|
* {@link Route @Route(method = "GET", path = ...)}.
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface GET {
|
public @interface GET {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this {@code GET} route is mounted at, relative to any controller prefix.
|
||||||
|
* Supports {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to an HTTP {@code PATCH} route. This is a convenience shorthand
|
||||||
|
* for {@link Route @Route(method = "PATCH", path = ...)}.
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface PATCH {
|
public @interface PATCH {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this {@code PATCH} route is mounted at, relative to any controller prefix.
|
||||||
|
* Supports {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to an HTTP {@code POST} route. This is a convenience shorthand for
|
||||||
|
* {@link Route @Route(method = "POST", path = ...)}.
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface POST {
|
public @interface POST {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this {@code POST} route is mounted at, relative to any controller prefix.
|
||||||
|
* Supports {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,29 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a controller method to an HTTP {@code PUT} route. This is a convenience shorthand for
|
||||||
|
* {@link Route @Route(method = "PUT", path = ...)}.
|
||||||
|
*
|
||||||
|
* <p>The annotated method must have the signature {@code void handler(Request, Response)},
|
||||||
|
* which the {@link AnnotationScanner} verifies during registration. The route path given by
|
||||||
|
* {@link #value()} is combined with any {@link Controller#value() controller prefix}.</p>
|
||||||
|
*
|
||||||
|
* <p>Retained at {@link RetentionPolicy#RUNTIME runtime} for reflective scanning and only
|
||||||
|
* applicable to {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see Route
|
||||||
|
* @see AnnotationScanner
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface PUT {
|
public @interface PUT {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this {@code PUT} route is mounted at, relative to any controller prefix.
|
||||||
|
* Supports {@code {param}} path parameters and {@code *} wildcards.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String value();
|
String value();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,41 @@ import java.lang.annotation.Retention;
|
|||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic route declaration that binds a controller method to an arbitrary HTTP method and
|
||||||
|
* path. This is the most flexible of the routing annotations: where {@link GET}, {@link POST}
|
||||||
|
* and friends hard-code the HTTP verb, {@code @Route} lets the verb be specified explicitly
|
||||||
|
* via {@link #method()}.
|
||||||
|
*
|
||||||
|
* <p>Handler methods carrying this annotation must follow the signature
|
||||||
|
* {@code void handler(Request, Response)}; this is enforced by the {@link AnnotationScanner}
|
||||||
|
* when the route is registered.</p>
|
||||||
|
*
|
||||||
|
* <p>The annotation is retained at {@link RetentionPolicy#RUNTIME runtime} so the scanner can
|
||||||
|
* read it reflectively, and may only be placed on {@link ElementType#METHOD methods}.</p>
|
||||||
|
*
|
||||||
|
* @see AnnotationScanner
|
||||||
|
* @see Controller
|
||||||
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.METHOD)
|
@Target(ElementType.METHOD)
|
||||||
public @interface Route {
|
public @interface Route {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP method (verb) this route responds to, for example {@code "GET"} or
|
||||||
|
* {@code "POST"}. The value must match a name accepted by
|
||||||
|
* {@link io.netty.handler.codec.http.HttpMethod#valueOf(String)}.
|
||||||
|
*
|
||||||
|
* @return the HTTP method name
|
||||||
|
*/
|
||||||
String method();
|
String method();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path this route is mounted at, relative to any {@link Controller#value() controller
|
||||||
|
* prefix}. Path segments wrapped in braces (e.g. {@code /users/{id}}) denote path
|
||||||
|
* parameters, and a {@code *} segment denotes a wildcard.
|
||||||
|
*
|
||||||
|
* @return the route path
|
||||||
|
*/
|
||||||
String path();
|
String path();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable mapping from request paths to the authentication requirement that applies to them,
|
||||||
|
* consumed by {@link AuthGate}.
|
||||||
|
*
|
||||||
|
* <p>The model mirrors {@code RateLimitConfig}: a path resolves to at most one rule, chosen with
|
||||||
|
* the precedence</p>
|
||||||
|
* <ol>
|
||||||
|
* <li><strong>exact-path</strong> rules (matched by exact equality);</li>
|
||||||
|
* <li><strong>prefix</strong> rules (longest matching prefix wins);</li>
|
||||||
|
* <li>an optional <strong>global</strong> rule applied to every other path.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Each rule pairs an {@link Authenticator} with a {@link Mode}: {@link Mode#REQUIRED} rejects
|
||||||
|
* the request with {@code 401} when authentication fails, while {@link Mode#OPTIONAL} attaches the
|
||||||
|
* principal when present but never rejects (useful for endpoints that behave differently for
|
||||||
|
* signed-in callers). Paths with no matching rule are not authenticated at all.</p>
|
||||||
|
*/
|
||||||
|
public final class AuthConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule applied to every path with no more specific match, or {@code null} if none.
|
||||||
|
*/
|
||||||
|
private final Rule globalRule;
|
||||||
|
/**
|
||||||
|
* Rules matched by exact path equality.
|
||||||
|
*/
|
||||||
|
private final Map<String, Rule> exactPathRules;
|
||||||
|
/**
|
||||||
|
* Prefix rules, pre-sorted longest-prefix-first.
|
||||||
|
*/
|
||||||
|
private final List<PrefixRule> prefixRules;
|
||||||
|
/**
|
||||||
|
* Optional {@code WWW-Authenticate} challenge sent with {@code 401} responses.
|
||||||
|
*/
|
||||||
|
private final String challenge;
|
||||||
|
|
||||||
|
private AuthConfig(Builder b) {
|
||||||
|
this.globalRule = b.globalRule;
|
||||||
|
this.exactPathRules = Map.copyOf(b.exactPathRules);
|
||||||
|
this.prefixRules = b.prefixRules.stream()
|
||||||
|
.sorted((a, c) -> Integer.compare(c.prefix.length(), a.prefix.length()))
|
||||||
|
.toList();
|
||||||
|
this.challenge = b.challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a builder using the given authenticator as the default for rules that do not specify
|
||||||
|
* their own.
|
||||||
|
*
|
||||||
|
* @param defaultAuthenticator the authenticator used by rules added without an explicit one
|
||||||
|
* @return a fresh builder
|
||||||
|
*/
|
||||||
|
public static Builder builder(Authenticator defaultAuthenticator) {
|
||||||
|
return new Builder(defaultAuthenticator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the rule that applies to the given path, or {@code null} if the path requires no
|
||||||
|
* authentication.
|
||||||
|
*
|
||||||
|
* @param path the request path
|
||||||
|
* @return the applicable rule, or {@code null}
|
||||||
|
*/
|
||||||
|
public Rule ruleFor(String path) {
|
||||||
|
Rule exact = exactPathRules.get(path);
|
||||||
|
if (exact != null) return exact;
|
||||||
|
for (PrefixRule pr : prefixRules) {
|
||||||
|
if (path.startsWith(pr.prefix)) return pr.rule;
|
||||||
|
}
|
||||||
|
return globalRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured {@code WWW-Authenticate} challenge sent with {@code 401} responses.
|
||||||
|
*
|
||||||
|
* @return the challenge string, or {@code null} if none is configured
|
||||||
|
*/
|
||||||
|
public String challenge() {
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a matched rule rejects unauthenticated requests or merely annotates them.
|
||||||
|
*/
|
||||||
|
public enum Mode {
|
||||||
|
/**
|
||||||
|
* Authentication is mandatory; failure yields {@code 401 Unauthorized}.
|
||||||
|
*/
|
||||||
|
REQUIRED,
|
||||||
|
/**
|
||||||
|
* Authentication is best-effort; the principal is attached if present, never rejected.
|
||||||
|
*/
|
||||||
|
OPTIONAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An authentication rule: which authenticator to use and whether it is mandatory.
|
||||||
|
*
|
||||||
|
* @param authenticator the authenticator to apply
|
||||||
|
* @param mode whether authentication is required or optional
|
||||||
|
*/
|
||||||
|
public record Rule(Authenticator authenticator, Mode mode) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal pairing of a path prefix with its rule.
|
||||||
|
*/
|
||||||
|
private record PrefixRule(String prefix, Rule rule) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link AuthConfig}.
|
||||||
|
*/
|
||||||
|
public static final class Builder {
|
||||||
|
private final Authenticator defaultAuthenticator;
|
||||||
|
private final Map<String, Rule> exactPathRules = new HashMap<>();
|
||||||
|
private final List<PrefixRule> prefixRules = new ArrayList<>();
|
||||||
|
private Rule globalRule;
|
||||||
|
private String challenge;
|
||||||
|
|
||||||
|
private Builder(Authenticator defaultAuthenticator) {
|
||||||
|
this.defaultAuthenticator = Objects.requireNonNull(defaultAuthenticator,
|
||||||
|
"defaultAuthenticator");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires authentication for an exact path, using the default authenticator.
|
||||||
|
*
|
||||||
|
* @param path the exact path to protect
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder protect(String path) {
|
||||||
|
return protect(path, defaultAuthenticator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires authentication for an exact path, using a specific authenticator.
|
||||||
|
*
|
||||||
|
* @param path the exact path to protect
|
||||||
|
* @param authenticator the authenticator to apply on that path
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder protect(String path, Authenticator authenticator) {
|
||||||
|
exactPathRules.put(path, new Rule(authenticator, Mode.REQUIRED));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires authentication for every path starting with the given prefix, using the default
|
||||||
|
* authenticator.
|
||||||
|
*
|
||||||
|
* @param prefix the path prefix to protect
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder protectPrefix(String prefix) {
|
||||||
|
return protectPrefix(prefix, defaultAuthenticator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires authentication for every path starting with the given prefix, using a specific
|
||||||
|
* authenticator.
|
||||||
|
*
|
||||||
|
* @param prefix the path prefix to protect
|
||||||
|
* @param authenticator the authenticator to apply on that prefix
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder protectPrefix(String prefix, Authenticator authenticator) {
|
||||||
|
prefixRules.add(new PrefixRule(prefix, new Rule(authenticator, Mode.REQUIRED)));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally authenticates an exact path: the principal is attached when credentials are
|
||||||
|
* valid, but the request is never rejected.
|
||||||
|
*
|
||||||
|
* @param path the exact path
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder optional(String path) {
|
||||||
|
exactPathRules.put(path, new Rule(defaultAuthenticator, Mode.OPTIONAL));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally authenticates a path prefix (attach-if-present, never reject).
|
||||||
|
*
|
||||||
|
* @param prefix the path prefix
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder optionalPrefix(String prefix) {
|
||||||
|
prefixRules.add(new PrefixRule(prefix, new Rule(defaultAuthenticator, Mode.OPTIONAL)));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally authenticates every path that has no more specific rule (attach-if-present,
|
||||||
|
* never reject). Handy for attaching a principal everywhere while protecting only selected
|
||||||
|
* routes with {@link #protect(String)}.
|
||||||
|
*
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder optionalEverywhere() {
|
||||||
|
this.globalRule = new Rule(defaultAuthenticator, Mode.OPTIONAL);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires authentication for every path that has no more specific rule.
|
||||||
|
*
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder requireEverywhere() {
|
||||||
|
this.globalRule = new Rule(defaultAuthenticator, Mode.REQUIRED);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@code WWW-Authenticate} challenge header value sent with {@code 401}
|
||||||
|
* responses (for example {@code "Basic realm=\"api\""}).
|
||||||
|
*
|
||||||
|
* @param challenge the challenge value
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder challenge(String challenge) {
|
||||||
|
this.challenge = challenge;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the immutable configuration.
|
||||||
|
*
|
||||||
|
* @return the configured instance
|
||||||
|
*/
|
||||||
|
public AuthConfig build() {
|
||||||
|
return new AuthConfig(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-pipeline entry point for the authentication layer. Given a request and its path it
|
||||||
|
* consults the {@link AuthConfig}, runs the applicable {@link Authenticator}, and decides whether
|
||||||
|
* the request may proceed.
|
||||||
|
*
|
||||||
|
* <p>On success the resolved {@link Principal} is attached to the {@link Request} (visible to
|
||||||
|
* downstream rate limiting, middlewares and handlers via {@link Request#principal()}). The gate is
|
||||||
|
* stateless and may be shared across all connections.</p>
|
||||||
|
*/
|
||||||
|
public final class AuthGate {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The policy this gate enforces.
|
||||||
|
*/
|
||||||
|
private final AuthConfig config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a gate for the given configuration.
|
||||||
|
*
|
||||||
|
* @param config the authentication policy to enforce
|
||||||
|
*/
|
||||||
|
public AuthGate(AuthConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the authentication policy to a request.
|
||||||
|
*
|
||||||
|
* <p>Returns {@code null} when the request may proceed — either because the path requires no
|
||||||
|
* authentication, because a principal was resolved (and has been attached to {@code req}), or
|
||||||
|
* because the path is only optionally authenticated. Returns a populated rejection response
|
||||||
|
* ({@code 401} when a required credential is missing/invalid, {@code 500} when the
|
||||||
|
* authenticator itself errors) that the caller should send instead of invoking the handler.
|
||||||
|
* Error details are never leaked to the client.</p>
|
||||||
|
*
|
||||||
|
* @param req the incoming request
|
||||||
|
* @param path the resolved request path
|
||||||
|
* @return {@code null} to proceed, or the rejection response to send
|
||||||
|
*/
|
||||||
|
public Response authenticate(Request req, String path) {
|
||||||
|
AuthConfig.Rule rule = config.ruleFor(path);
|
||||||
|
if (rule == null) return null;
|
||||||
|
|
||||||
|
Principal principal;
|
||||||
|
try {
|
||||||
|
principal = rule.authenticator().authenticate(req);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new Response().status(500).json("{\"error\":\"Authentication error\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (principal != null) {
|
||||||
|
req.principal(principal);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.mode() == AuthConfig.Mode.OPTIONAL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response res = new Response().status(401).json("{\"error\":\"Unauthorized\"}");
|
||||||
|
if (config.challenge() != null) {
|
||||||
|
res.header("WWW-Authenticate", config.challenge());
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes the {@link Principal} behind a request from whatever credential the application
|
||||||
|
* uses. The framework is deliberately scheme-agnostic: ready-made factories cover API keys,
|
||||||
|
* session cookies, HTTP Basic and bearer tokens, and {@code Authenticator} is a functional
|
||||||
|
* interface so any custom scheme (mutual-TLS client certs, HMAC-signed requests, opaque session
|
||||||
|
* stores, ...) can be plugged in directly.
|
||||||
|
*
|
||||||
|
* <p>An authenticator returns the resolved {@link Principal} on success, or {@code null} when the
|
||||||
|
* request carries no usable or no valid credential. It must not throw for the ordinary
|
||||||
|
* "no/invalid credential" case (return {@code null} instead); a thrown exception is treated by
|
||||||
|
* {@link AuthGate} as an internal error, not an authentication failure.</p>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Authenticator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates via an API key carried in a request header (for example {@code X-API-Key}).
|
||||||
|
* The validator maps a presented key to a {@link Principal}, or to {@code null} if the key is
|
||||||
|
* unknown/revoked. Prefer a constant-time lookup in the validator to avoid timing oracles.
|
||||||
|
*
|
||||||
|
* @param headerName the header carrying the API key
|
||||||
|
* @param validator maps a presented key to a principal, or {@code null} if invalid
|
||||||
|
* @return an API-key authenticator
|
||||||
|
* @see #constantTimeEquals(String, String)
|
||||||
|
*/
|
||||||
|
static Authenticator apiKey(String headerName, Function<String, Principal> validator) {
|
||||||
|
return request -> {
|
||||||
|
String key = request.header(headerName);
|
||||||
|
if (key == null || key.isEmpty()) return null;
|
||||||
|
return validator.apply(key);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates via a session (or other) cookie. The validator maps a presented cookie value
|
||||||
|
* to a {@link Principal}, or to {@code null} if the session is unknown/expired.
|
||||||
|
*
|
||||||
|
* @param cookieName the cookie carrying the credential
|
||||||
|
* @param validator maps a presented cookie value to a principal, or {@code null} if invalid
|
||||||
|
* @return a cookie authenticator
|
||||||
|
*/
|
||||||
|
static Authenticator cookie(String cookieName, Function<String, Principal> validator) {
|
||||||
|
return request -> {
|
||||||
|
String value = request.cookie(cookieName);
|
||||||
|
if (value == null || value.isEmpty()) return null;
|
||||||
|
return validator.apply(value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates via HTTP Basic credentials from the {@code Authorization} header. The
|
||||||
|
* validator receives the decoded username and password and returns a {@link Principal} or
|
||||||
|
* {@code null}. Malformed headers resolve to {@code null} rather than an error.
|
||||||
|
*
|
||||||
|
* @param validator maps {@code (username, password)} to a principal, or {@code null} if invalid
|
||||||
|
* @return a Basic-auth authenticator
|
||||||
|
* @see #constantTimeEquals(String, String)
|
||||||
|
*/
|
||||||
|
static Authenticator basic(BiFunction<String, String, Principal> validator) {
|
||||||
|
return request -> {
|
||||||
|
String header = request.header("Authorization");
|
||||||
|
if (header == null || !header.regionMatches(true, 0, "Basic ", 0, 6)) return null;
|
||||||
|
String encoded = header.substring(6).trim();
|
||||||
|
byte[] decoded;
|
||||||
|
try {
|
||||||
|
decoded = Base64.getDecoder().decode(encoded);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String creds = new String(decoded, StandardCharsets.UTF_8);
|
||||||
|
int colon = creds.indexOf(':');
|
||||||
|
if (colon < 0) return null;
|
||||||
|
return validator.apply(creds.substring(0, colon), creds.substring(colon + 1));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates via a bearer token from the {@code Authorization} header. Provided for
|
||||||
|
* completeness; the rest of the framework never requires bearer tokens.
|
||||||
|
*
|
||||||
|
* @param validator maps a presented token to a principal, or {@code null} if invalid
|
||||||
|
* @return a bearer-token authenticator
|
||||||
|
*/
|
||||||
|
static Authenticator bearer(Function<String, Principal> validator) {
|
||||||
|
return request -> {
|
||||||
|
String header = request.header("Authorization");
|
||||||
|
if (header == null || !header.regionMatches(true, 0, "Bearer ", 0, 7)) return null;
|
||||||
|
String token = header.substring(7).trim();
|
||||||
|
if (token.isEmpty()) return null;
|
||||||
|
return validator.apply(token);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines several authenticators, trying each in order and returning the first principal one
|
||||||
|
* of them produces. Lets an endpoint accept, say, either an API key or a session cookie.
|
||||||
|
*
|
||||||
|
* @param authenticators the authenticators to try, in order
|
||||||
|
* @return a composite authenticator
|
||||||
|
*/
|
||||||
|
static Authenticator anyOf(Authenticator... authenticators) {
|
||||||
|
Authenticator[] copy = authenticators.clone();
|
||||||
|
return request -> {
|
||||||
|
for (Authenticator a : copy) {
|
||||||
|
Principal p = a.authenticate(request);
|
||||||
|
if (p != null) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to authenticate a request.
|
||||||
|
*
|
||||||
|
* @param request the incoming request
|
||||||
|
* @return the authenticated principal, or {@code null} if the request is unauthenticated
|
||||||
|
* @throws Exception if an unexpected error occurs while validating the credential (treated as
|
||||||
|
* an internal error, not an authentication failure)
|
||||||
|
*/
|
||||||
|
Principal authenticate(Request request) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two secrets (API keys, tokens, passwords, ...) in length-constant time, so the
|
||||||
|
* time taken does not reveal how many leading characters matched. Use this inside a validator
|
||||||
|
* instead of {@link String#equals(Object)} whenever the comparison guards a credential, to
|
||||||
|
* deny attackers a timing oracle for guessing the secret byte by byte.
|
||||||
|
*
|
||||||
|
* <p>The comparison is performed on the UTF-8 bytes of the inputs via
|
||||||
|
* {@link MessageDigest#isEqual(byte[], byte[])}. A {@code null} on either side yields
|
||||||
|
* {@code false}. Note that the <em>length</em> of the presented value is not hidden; keep
|
||||||
|
* secrets of a fixed length if even that must not leak.</p>
|
||||||
|
*
|
||||||
|
* @param a the first value (for example the presented credential), may be {@code null}
|
||||||
|
* @param b the second value (for example the expected secret), may be {@code null}
|
||||||
|
* @return {@code true} if both are non-{@code null} and byte-for-byte equal
|
||||||
|
*/
|
||||||
|
static boolean constantTimeEquals(String a, String b) {
|
||||||
|
if (a == null || b == null) return false;
|
||||||
|
return MessageDigest.isEqual(
|
||||||
|
a.getBytes(StandardCharsets.UTF_8),
|
||||||
|
b.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authenticated identity attached to a request by the {@link AuthGate auth layer}.
|
||||||
|
*
|
||||||
|
* <p>The framework deliberately does not prescribe <em>how</em> an identity is established —
|
||||||
|
* it may come from an API key, a session cookie, HTTP Basic credentials, a mutual-TLS client
|
||||||
|
* certificate or any custom scheme implemented by an {@link Authenticator}. All the rest of the
|
||||||
|
* pipeline needs is a stable {@link #id() identifier} (used, for example, as a rate-limit key
|
||||||
|
* via {@link dev.coph.nextusweb.server.ratelimit.KeyResolver#principal()}) plus optional
|
||||||
|
* {@link #roles() roles} for authorization and free-form {@link #claims() claims}.</p>
|
||||||
|
*
|
||||||
|
* <p>A ready-made immutable implementation is available via {@link #of(String)} and
|
||||||
|
* {@link #of(String, Set)}; applications may also implement this interface to carry richer
|
||||||
|
* domain objects.</p>
|
||||||
|
*/
|
||||||
|
public interface Principal {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a simple immutable principal with no roles.
|
||||||
|
*
|
||||||
|
* @param id the principal identifier
|
||||||
|
* @return a principal carrying only the given id
|
||||||
|
*/
|
||||||
|
static Principal of(String id) {
|
||||||
|
return of(id, Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a simple immutable principal with the given id and roles.
|
||||||
|
*
|
||||||
|
* @param id the principal identifier
|
||||||
|
* @param roles the roles granted to the principal
|
||||||
|
* @return an immutable principal
|
||||||
|
*/
|
||||||
|
static Principal of(String id, Set<String> roles) {
|
||||||
|
Set<String> copy = Set.copyOf(roles);
|
||||||
|
return new Principal() {
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> roles() {
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Principal[id=" + id + ", roles=" + copy + "]";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stable, unique identifier of this principal (for example a user id, an account
|
||||||
|
* name or an API-key id). Used wherever the identity must be reduced to a single string, such
|
||||||
|
* as principal-based rate limiting.
|
||||||
|
*
|
||||||
|
* @return the principal identifier; never {@code null}
|
||||||
|
*/
|
||||||
|
String id();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether this principal holds the given role.
|
||||||
|
*
|
||||||
|
* @param role the role to test for
|
||||||
|
* @return {@code true} if {@link #roles()} contains {@code role}
|
||||||
|
*/
|
||||||
|
default boolean hasRole(String role) {
|
||||||
|
return roles().contains(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the roles granted to this principal, for coarse-grained authorization checks.
|
||||||
|
*
|
||||||
|
* @return the (possibly empty) set of roles; never {@code null}
|
||||||
|
*/
|
||||||
|
default Set<String> roles() {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns arbitrary additional attributes describing this principal (for example token
|
||||||
|
* scopes, an email address or tenant information).
|
||||||
|
*
|
||||||
|
* @return the (possibly empty) claim map; never {@code null}
|
||||||
|
*/
|
||||||
|
default Map<String, Object> claims() {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,56 @@ import java.util.Collections;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable configuration describing the Cross-Origin Resource Sharing (CORS) policy the
|
||||||
|
* server enforces. Instances are created through the nested {@link Builder} and consumed by
|
||||||
|
* {@link CorsHandler} to decide which origins, methods and headers are permitted.
|
||||||
|
*
|
||||||
|
* <p>As a safety measure the configuration forbids combining a wildcard origin
|
||||||
|
* ({@link #allowAnyOrigin()}) with {@link #allowCredentials() credentialed requests}, which
|
||||||
|
* the CORS specification disallows.</p>
|
||||||
|
*
|
||||||
|
* @see CorsHandler
|
||||||
|
*/
|
||||||
public final class CorsConfig {
|
public final class CorsConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}.
|
||||||
|
*/
|
||||||
private final Set<String> allowedOrigins;
|
private final Set<String> allowedOrigins;
|
||||||
|
/**
|
||||||
|
* HTTP methods advertised as allowed in preflight responses.
|
||||||
|
*/
|
||||||
private final Set<HttpMethod> allowedMethods;
|
private final Set<HttpMethod> allowedMethods;
|
||||||
|
/**
|
||||||
|
* Request headers advertised as allowed in preflight responses.
|
||||||
|
*/
|
||||||
private final Set<String> allowedHeaders;
|
private final Set<String> allowedHeaders;
|
||||||
|
/**
|
||||||
|
* Response headers exposed to the browser via {@code Access-Control-Expose-Headers}.
|
||||||
|
*/
|
||||||
private final Set<String> exposedHeaders;
|
private final Set<String> exposedHeaders;
|
||||||
|
/**
|
||||||
|
* Whether credentialed (cookie/authorization) requests are permitted.
|
||||||
|
*/
|
||||||
private final boolean allowCredentials;
|
private final boolean allowCredentials;
|
||||||
|
/**
|
||||||
|
* How long (in seconds) a preflight response may be cached by the browser.
|
||||||
|
*/
|
||||||
private final long maxAgeSeconds;
|
private final long maxAgeSeconds;
|
||||||
|
/**
|
||||||
|
* Whether any origin is allowed (the {@code *} wildcard).
|
||||||
|
*/
|
||||||
private final boolean allowAnyOrigin;
|
private final boolean allowAnyOrigin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an immutable configuration from a {@link Builder}, defensively copying its
|
||||||
|
* collections.
|
||||||
|
*
|
||||||
|
* @param b the builder carrying the configured values
|
||||||
|
* @throws IllegalStateException if a wildcard origin is combined with
|
||||||
|
* {@code allowCredentials = true}
|
||||||
|
*/
|
||||||
private CorsConfig(Builder b) {
|
private CorsConfig(Builder b) {
|
||||||
this.allowedOrigins = Set.copyOf(b.allowedOrigins);
|
this.allowedOrigins = Set.copyOf(b.allowedOrigins);
|
||||||
this.allowedMethods = Set.copyOf(b.allowedMethods);
|
this.allowedMethods = Set.copyOf(b.allowedMethods);
|
||||||
@@ -32,6 +72,14 @@ public final class CorsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a permissive, development-friendly configuration that allows any origin, the
|
||||||
|
* common HTTP methods, a handful of common headers and a one-hour preflight cache.
|
||||||
|
*
|
||||||
|
* <p>Because it allows any origin it intentionally does not enable credentials.</p>
|
||||||
|
*
|
||||||
|
* @return a ready-to-use permissive configuration
|
||||||
|
*/
|
||||||
public static CorsConfig permissive() {
|
public static CorsConfig permissive() {
|
||||||
return builder()
|
return builder()
|
||||||
.anyOrigin()
|
.anyOrigin()
|
||||||
@@ -42,86 +90,208 @@ public final class CorsConfig {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new, empty {@link Builder}.
|
||||||
|
*
|
||||||
|
* @return a fresh builder
|
||||||
|
*/
|
||||||
public static Builder builder() {
|
public static Builder builder() {
|
||||||
return new Builder();
|
return new Builder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether a given request origin is permitted by this policy.
|
||||||
|
*
|
||||||
|
* @param origin the {@code Origin} header value, may be {@code null}
|
||||||
|
* @return {@code true} if the origin is allowed; {@code false} for a {@code null} origin or
|
||||||
|
* one not in the allow-list (unless any origin is permitted)
|
||||||
|
*/
|
||||||
public boolean isOriginAllowed(String origin) {
|
public boolean isOriginAllowed(String origin) {
|
||||||
if (origin == null) return false;
|
if (origin == null) return false;
|
||||||
if (allowAnyOrigin) return true;
|
if (allowAnyOrigin) return true;
|
||||||
return allowedOrigins.contains(origin);
|
return allowedOrigins.contains(origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the HTTP methods advertised as allowed in preflight responses.
|
||||||
|
*
|
||||||
|
* @return the immutable set of allowed HTTP methods
|
||||||
|
*/
|
||||||
public Set<HttpMethod> allowedMethods() {
|
public Set<HttpMethod> allowedMethods() {
|
||||||
return allowedMethods;
|
return allowedMethods;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request headers advertised as allowed in preflight responses.
|
||||||
|
*
|
||||||
|
* @return the immutable set of allowed request headers
|
||||||
|
*/
|
||||||
public Set<String> allowedHeaders() {
|
public Set<String> allowedHeaders() {
|
||||||
return allowedHeaders;
|
return allowedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the response headers that browsers are permitted to read.
|
||||||
|
*
|
||||||
|
* @return the immutable set of response headers exposed to the browser
|
||||||
|
*/
|
||||||
public Set<String> exposedHeaders() {
|
public Set<String> exposedHeaders() {
|
||||||
return exposedHeaders;
|
return exposedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether credentialed (cookie/authorization) requests are permitted.
|
||||||
|
*
|
||||||
|
* @return {@code true} if credentialed requests are permitted
|
||||||
|
*/
|
||||||
public boolean allowCredentials() {
|
public boolean allowCredentials() {
|
||||||
return allowCredentials;
|
return allowCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns how long a preflight response may be cached by the browser.
|
||||||
|
*
|
||||||
|
* @return the preflight cache lifetime in seconds ({@code 0} disables the header)
|
||||||
|
*/
|
||||||
public long maxAgeSeconds() {
|
public long maxAgeSeconds() {
|
||||||
return maxAgeSeconds;
|
return maxAgeSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether requests from any origin are permitted.
|
||||||
|
*
|
||||||
|
* @return {@code true} if any origin is permitted
|
||||||
|
*/
|
||||||
public boolean allowAnyOrigin() {
|
public boolean allowAnyOrigin() {
|
||||||
return allowAnyOrigin;
|
return allowAnyOrigin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link CorsConfig}. All collection setters are additive, so they may
|
||||||
|
* be called multiple times to accumulate values.
|
||||||
|
*/
|
||||||
public static final class Builder {
|
public static final class Builder {
|
||||||
|
/**
|
||||||
|
* Accumulated explicit origins.
|
||||||
|
*/
|
||||||
private final Set<String> allowedOrigins = new HashSet<>();
|
private final Set<String> allowedOrigins = new HashSet<>();
|
||||||
|
/**
|
||||||
|
* Accumulated allowed methods.
|
||||||
|
*/
|
||||||
private final Set<HttpMethod> allowedMethods = new HashSet<>();
|
private final Set<HttpMethod> allowedMethods = new HashSet<>();
|
||||||
|
/**
|
||||||
|
* Accumulated allowed request headers.
|
||||||
|
*/
|
||||||
private final Set<String> allowedHeaders = new HashSet<>();
|
private final Set<String> allowedHeaders = new HashSet<>();
|
||||||
|
/**
|
||||||
|
* Accumulated exposed response headers.
|
||||||
|
*/
|
||||||
private final Set<String> exposedHeaders = new HashSet<>();
|
private final Set<String> exposedHeaders = new HashSet<>();
|
||||||
|
/**
|
||||||
|
* Whether credentialed requests are permitted; defaults to {@code false}.
|
||||||
|
*/
|
||||||
private boolean allowCredentials = false;
|
private boolean allowCredentials = false;
|
||||||
|
/**
|
||||||
|
* Preflight cache lifetime in seconds; defaults to {@code 0} (disabled).
|
||||||
|
*/
|
||||||
private long maxAgeSeconds = 0;
|
private long maxAgeSeconds = 0;
|
||||||
|
/**
|
||||||
|
* Whether any origin is permitted; defaults to {@code false}.
|
||||||
|
*/
|
||||||
private boolean allowAnyOrigin = false;
|
private boolean allowAnyOrigin = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a builder with no origins, methods or headers configured and all flags at
|
||||||
|
* their defaults. Obtain instances via {@link CorsConfig#builder()}.
|
||||||
|
*/
|
||||||
|
public Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more explicit origins to the allow-list.
|
||||||
|
*
|
||||||
|
* @param origins the origins to allow
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder allowedOrigins(String... origins) {
|
public Builder allowedOrigins(String... origins) {
|
||||||
Collections.addAll(allowedOrigins, origins);
|
Collections.addAll(allowedOrigins, origins);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows requests from any origin (the {@code *} wildcard). Cannot be combined with
|
||||||
|
* {@link #allowCredentials(boolean) credentials}.
|
||||||
|
*
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder anyOrigin() {
|
public Builder anyOrigin() {
|
||||||
this.allowAnyOrigin = true;
|
this.allowAnyOrigin = true;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more allowed HTTP methods.
|
||||||
|
*
|
||||||
|
* @param ms the methods to allow
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder allowedMethods(HttpMethod... ms) {
|
public Builder allowedMethods(HttpMethod... ms) {
|
||||||
Collections.addAll(allowedMethods, ms);
|
Collections.addAll(allowedMethods, ms);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more allowed request headers.
|
||||||
|
*
|
||||||
|
* @param hs the request headers to allow
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder allowedHeaders(String... hs) {
|
public Builder allowedHeaders(String... hs) {
|
||||||
Collections.addAll(allowedHeaders, hs);
|
Collections.addAll(allowedHeaders, hs);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more response headers to expose to the browser.
|
||||||
|
*
|
||||||
|
* @param hs the response headers to expose
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder exposedHeaders(String... hs) {
|
public Builder exposedHeaders(String... hs) {
|
||||||
Collections.addAll(exposedHeaders, hs);
|
Collections.addAll(exposedHeaders, hs);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether credentialed requests are permitted.
|
||||||
|
*
|
||||||
|
* @param v {@code true} to allow credentials
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder allowCredentials(boolean v) {
|
public Builder allowCredentials(boolean v) {
|
||||||
this.allowCredentials = v;
|
this.allowCredentials = v;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the preflight cache lifetime in seconds.
|
||||||
|
*
|
||||||
|
* @param s the max-age in seconds ({@code 0} disables the header)
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder maxAgeSeconds(long s) {
|
public Builder maxAgeSeconds(long s) {
|
||||||
this.maxAgeSeconds = s;
|
this.maxAgeSeconds = s;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the immutable {@link CorsConfig}.
|
||||||
|
*
|
||||||
|
* @return the configured instance
|
||||||
|
* @throws IllegalStateException if any origin is combined with credentials
|
||||||
|
*/
|
||||||
public CorsConfig build() {
|
public CorsConfig build() {
|
||||||
return new CorsConfig(this);
|
return new CorsConfig(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,51 @@
|
|||||||
package dev.coph.nextusweb.server.cores;
|
package dev.coph.nextusweb.server.cores;
|
||||||
|
|
||||||
import dev.coph.nextusweb.server.router.Response;
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
import io.netty.handler.codec.http.*;
|
import io.netty.handler.codec.http.HttpHeaders;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a {@link CorsConfig} to outgoing responses and handles CORS preflight requests.
|
||||||
|
*
|
||||||
|
* <p>The handler pre-computes the comma-separated header strings derived from the
|
||||||
|
* configuration (allowed methods, allowed headers, exposed headers) once at construction time
|
||||||
|
* so they need not be rebuilt for every request. It then offers two entry points:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #applyHeaders(String, Response)} decorates a normal response with the
|
||||||
|
* appropriate {@code Access-Control-*} headers;</li>
|
||||||
|
* <li>{@link #handlePreflight(String, HttpHeaders)} produces a complete response for an
|
||||||
|
* {@code OPTIONS} preflight request.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @see CorsConfig
|
||||||
|
*/
|
||||||
public final class CorsHandler {
|
public final class CorsHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The policy this handler enforces.
|
||||||
|
*/
|
||||||
private final CorsConfig config;
|
private final CorsConfig config;
|
||||||
|
/**
|
||||||
|
* Pre-joined {@code Access-Control-Allow-Methods} value.
|
||||||
|
*/
|
||||||
private final String allowedMethodsHeader;
|
private final String allowedMethodsHeader;
|
||||||
|
/**
|
||||||
|
* Pre-joined {@code Access-Control-Allow-Headers} value.
|
||||||
|
*/
|
||||||
private final String allowedHeadersHeader;
|
private final String allowedHeadersHeader;
|
||||||
|
/**
|
||||||
|
* Pre-joined {@code Access-Control-Expose-Headers} value.
|
||||||
|
*/
|
||||||
private final String exposedHeadersHeader;
|
private final String exposedHeadersHeader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a handler for the given configuration, pre-computing the header strings it will
|
||||||
|
* emit.
|
||||||
|
*
|
||||||
|
* @param config the CORS policy to enforce
|
||||||
|
*/
|
||||||
public CorsHandler(CorsConfig config) {
|
public CorsHandler(CorsConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.allowedMethodsHeader = config.allowedMethods().stream().map(HttpMethod::name).collect(Collectors.joining(", "));
|
this.allowedMethodsHeader = config.allowedMethods().stream().map(HttpMethod::name).collect(Collectors.joining(", "));
|
||||||
@@ -19,37 +53,36 @@ public final class CorsHandler {
|
|||||||
this.exposedHeadersHeader = String.join(", ", config.exposedHeaders());
|
this.exposedHeadersHeader = String.join(", ", config.exposedHeaders());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
public void applyHeaders(String origin, Response res) {
|
* Determines whether a request is a CORS preflight request, i.e. an {@code OPTIONS}
|
||||||
if (origin == null) return;
|
* request carrying an {@code Access-Control-Request-Method} header.
|
||||||
|
*
|
||||||
if (!config.isOriginAllowed(origin)) return;
|
* @param method the request method
|
||||||
|
* @param headers the request headers
|
||||||
if (config.allowAnyOrigin() && !config.allowCredentials()) {
|
* @return {@code true} if the request is a preflight request
|
||||||
res.header("Access-Control-Allow-Origin", "*");
|
*/
|
||||||
} else {
|
|
||||||
res.header("Access-Control-Allow-Origin", origin);
|
|
||||||
res.header("Vary", "Origin");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.allowCredentials()) {
|
|
||||||
res.header("Access-Control-Allow-Credentials", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exposedHeadersHeader.isEmpty()) {
|
|
||||||
res.header("Access-Control-Expose-Headers", exposedHeadersHeader);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isPreflight(HttpMethod method, HttpHeaders headers) {
|
public boolean isPreflight(HttpMethod method, HttpHeaders headers) {
|
||||||
return method.equals(HttpMethod.OPTIONS)
|
return method.equals(HttpMethod.OPTIONS)
|
||||||
&& headers.contains("Access-Control-Request-Method");
|
&& headers.contains("Access-Control-Request-Method");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the response to a CORS preflight request.
|
||||||
|
*
|
||||||
|
* <p>If the origin is missing or disallowed the response is a {@code 403 Forbidden};
|
||||||
|
* otherwise it is a {@code 204 No Content} carrying the allowed methods and headers, the
|
||||||
|
* requested headers echoed back when no explicit allow-list is configured, and the
|
||||||
|
* {@code Access-Control-Max-Age} cache hint when configured.</p>
|
||||||
|
*
|
||||||
|
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||||
|
* @param requestHeaders the request's headers (used to read
|
||||||
|
* {@code Access-Control-Request-Headers})
|
||||||
|
* @return the fully populated preflight response
|
||||||
|
*/
|
||||||
public Response handlePreflight(String origin, HttpHeaders requestHeaders) {
|
public Response handlePreflight(String origin, HttpHeaders requestHeaders) {
|
||||||
Response res = new Response().status(204);
|
Response res = new Response().status(204);
|
||||||
|
|
||||||
if (origin == null || !config.isOriginAllowed(origin)) {
|
if (!config.isOriginAllowed(origin)) {
|
||||||
return res.status(403);
|
return res.status(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,4 +102,37 @@ public final class CorsHandler {
|
|||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Adds the {@code Access-Control-Allow-Origin} (and related) headers to a response, if and
|
||||||
|
* only if the request carried an allowed {@code Origin}.
|
||||||
|
*
|
||||||
|
* <p>For wildcard, credential-less policies a literal {@code *} is emitted; otherwise the
|
||||||
|
* concrete origin is echoed back together with a {@code Vary: Origin} header so caches key
|
||||||
|
* on the origin. Requests without an origin or with a disallowed origin are left
|
||||||
|
* untouched.</p>
|
||||||
|
*
|
||||||
|
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||||
|
* @param res the response to decorate
|
||||||
|
*/
|
||||||
|
public void applyHeaders(String origin, Response res) {
|
||||||
|
if (origin == null) return;
|
||||||
|
|
||||||
|
if (!config.isOriginAllowed(origin)) return;
|
||||||
|
|
||||||
|
if (config.allowAnyOrigin() && !config.allowCredentials()) {
|
||||||
|
res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
} else {
|
||||||
|
res.header("Access-Control-Allow-Origin", origin);
|
||||||
|
res.header("Vary", "Origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.allowCredentials()) {
|
||||||
|
res.header("Access-Control-Allow-Credentials", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exposedHeadersHeader.isEmpty()) {
|
||||||
|
res.header("Access-Control-Expose-Headers", exposedHeadersHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,29 @@ package dev.coph.nextusweb.server.json;
|
|||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holder for the application-wide Jackson {@link ObjectMapper}.
|
||||||
|
*
|
||||||
|
* <p>A single, pre-configured mapper instance is shared across the whole server because
|
||||||
|
* {@code ObjectMapper} is thread-safe once configured and is relatively expensive to build.
|
||||||
|
* Centralizing it here ensures every component (request parsing, response serialization,
|
||||||
|
* WebSocket payloads) uses identical serialization settings.</p>
|
||||||
|
*
|
||||||
|
* <p>This class is a static holder and cannot be instantiated.</p>
|
||||||
|
*/
|
||||||
public final class JsonMapper {
|
public final class JsonMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared, thread-safe Jackson mapper used throughout the server for all JSON reading
|
||||||
|
* and writing.
|
||||||
|
*/
|
||||||
public static final ObjectMapper MAPPER = tools.jackson.databind.json.JsonMapper.builder()
|
public static final ObjectMapper MAPPER = tools.jackson.databind.json.JsonMapper.builder()
|
||||||
// .addModule(new JavaTimeModule())
|
// .addModule(new JavaTimeModule())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private JsonMapper() {}
|
/**
|
||||||
}
|
* Private constructor preventing instantiation of this static holder class.
|
||||||
|
*/
|
||||||
|
private JsonMapper() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package dev.coph.nextusweb.server.net;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective client IP of a request from the transport-level peer address and an
|
||||||
|
* optional {@code X-Forwarded-For} header, according to a {@link TrustedProxies} policy.
|
||||||
|
*
|
||||||
|
* <p>This is the single place where forwarded headers are interpreted, so the spoofing-resistant
|
||||||
|
* logic lives in one spot and is reused by rate limiting, the auth layer and logging.</p>
|
||||||
|
*/
|
||||||
|
public final class ClientIp {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The de-facto standard header proxies use to record the originating client chain.
|
||||||
|
*/
|
||||||
|
public static final String FORWARDED_FOR_HEADER = "X-Forwarded-For";
|
||||||
|
|
||||||
|
private ClientIp() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the client IP.
|
||||||
|
*
|
||||||
|
* <p>If the immediate peer is not a trusted proxy (or no forwarded header is present), the
|
||||||
|
* transport-level {@code socketIp} is returned unchanged — a directly connected client cannot
|
||||||
|
* influence its own apparent address. Otherwise the comma-separated forwarded chain is walked
|
||||||
|
* from right to left and the first address that is <em>not</em> itself a trusted proxy is
|
||||||
|
* returned: that is the closest hop the trusted infrastructure actually observed and which the
|
||||||
|
* real client cannot forge. If every listed hop is trusted, the left-most entry is used.</p>
|
||||||
|
*
|
||||||
|
* @param socketIp the transport-level peer IP (never {@code null} in practice)
|
||||||
|
* @param forwardedForHeader the {@code X-Forwarded-For} header value, may be {@code null}
|
||||||
|
* @param trusted the trusted-proxy policy
|
||||||
|
* @return the resolved client IP
|
||||||
|
*/
|
||||||
|
public static String resolve(String socketIp, String forwardedForHeader, TrustedProxies trusted) {
|
||||||
|
if (forwardedForHeader == null || forwardedForHeader.isBlank() || !trusted.isTrusted(socketIp)) {
|
||||||
|
return socketIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] hops = forwardedForHeader.split(",");
|
||||||
|
for (int i = hops.length - 1; i >= 0; i--) {
|
||||||
|
String hop = hops[i].trim();
|
||||||
|
if (hop.isEmpty()) continue;
|
||||||
|
if (!trusted.isTrusted(hop)) {
|
||||||
|
return hop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String first = hops[0].trim();
|
||||||
|
return first.isEmpty() ? socketIp : first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package dev.coph.nextusweb.server.net;
|
||||||
|
|
||||||
|
import io.netty.util.NetUtil;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes which transport-level peers are trusted reverse proxies, and is the basis for safely
|
||||||
|
* honouring {@code X-Forwarded-For} (and similar) headers.
|
||||||
|
*
|
||||||
|
* <p>Forwarded-for headers are client-supplied and therefore trivially spoofable: a client that
|
||||||
|
* talks to the server directly can claim any IP it likes. Trusting them unconditionally lets an
|
||||||
|
* attacker forge a fresh client IP on every request, which defeats IP-based rate limiting and
|
||||||
|
* pollutes logs. To avoid that, {@link ClientIp} only consults the forwarded header when the
|
||||||
|
* immediate peer is a trusted proxy, and then walks the header from right to left skipping
|
||||||
|
* further trusted hops — so the value returned is the address of the first <em>untrusted</em>
|
||||||
|
* hop, which cannot be spoofed by the real client.</p>
|
||||||
|
*
|
||||||
|
* <p>Both IPv4 and IPv6 CIDR ranges are supported. Instances are immutable and thread-safe.</p>
|
||||||
|
*/
|
||||||
|
public final class TrustedProxies {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared instance that trusts no peer; forwarded headers are always ignored.
|
||||||
|
*/
|
||||||
|
private static final TrustedProxies NONE = new TrustedProxies(List.of(), false);
|
||||||
|
/**
|
||||||
|
* Shared instance that trusts every peer; forwarded headers are always honoured.
|
||||||
|
*/
|
||||||
|
private static final TrustedProxies ALL = new TrustedProxies(List.of(), true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed CIDR ranges of trusted proxies.
|
||||||
|
*/
|
||||||
|
private final List<Cidr> cidrs;
|
||||||
|
/**
|
||||||
|
* When {@code true}, every peer is trusted regardless of {@link #cidrs}.
|
||||||
|
*/
|
||||||
|
private final boolean trustAll;
|
||||||
|
|
||||||
|
private TrustedProxies(List<Cidr> cidrs, boolean trustAll) {
|
||||||
|
this.cidrs = cidrs;
|
||||||
|
this.trustAll = trustAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a policy that trusts no peer. Forwarded headers are ignored and the transport-level
|
||||||
|
* peer address is always used. This is the safe default for servers exposed directly to
|
||||||
|
* clients.
|
||||||
|
*
|
||||||
|
* @return a never-trust policy
|
||||||
|
*/
|
||||||
|
public static TrustedProxies none() {
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a policy that trusts every peer and therefore always honours the forwarded header.
|
||||||
|
*
|
||||||
|
* <p>Only use this when the server can <em>never</em> be reached except through a trusted
|
||||||
|
* proxy that overwrites the forwarded header (for example a private network behind a load
|
||||||
|
* balancer), otherwise it reintroduces the spoofing problem.</p>
|
||||||
|
*
|
||||||
|
* @return a trust-all policy
|
||||||
|
*/
|
||||||
|
public static TrustedProxies all() {
|
||||||
|
return ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a policy that trusts peers whose address falls inside any of the given CIDR ranges.
|
||||||
|
* A bare address (without {@code /prefix}) is treated as a single host ({@code /32} for IPv4,
|
||||||
|
* {@code /128} for IPv6).
|
||||||
|
*
|
||||||
|
* @param cidrs the trusted ranges, e.g. {@code "10.0.0.0/8"}, {@code "127.0.0.1"},
|
||||||
|
* {@code "::1"}, {@code "fd00::/8"}
|
||||||
|
* @return a policy trusting the given ranges
|
||||||
|
* @throws IllegalArgumentException if a range cannot be parsed
|
||||||
|
*/
|
||||||
|
public static TrustedProxies of(String... cidrs) {
|
||||||
|
List<Cidr> parsed = new ArrayList<>(cidrs.length);
|
||||||
|
for (String c : cidrs) parsed.add(Cidr.parse(c));
|
||||||
|
return new TrustedProxies(List.copyOf(parsed), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether the given IP address belongs to a trusted proxy.
|
||||||
|
*
|
||||||
|
* @param ip the literal IP address to test (no DNS resolution is performed)
|
||||||
|
* @return {@code true} if the address is trusted
|
||||||
|
*/
|
||||||
|
public boolean isTrusted(String ip) {
|
||||||
|
if (trustAll) return true;
|
||||||
|
if (ip == null || cidrs.isEmpty()) return false;
|
||||||
|
byte[] addr = NetUtil.createByteArrayFromIpAddressString(ip);
|
||||||
|
if (addr == null) return false;
|
||||||
|
for (Cidr c : cidrs) {
|
||||||
|
if (c.contains(addr)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An IP range expressed as a base address and a prefix length, matching either IPv4 or IPv6.
|
||||||
|
*
|
||||||
|
* @param base the network base address bytes (4 for IPv4, 16 for IPv6)
|
||||||
|
* @param prefixBits the number of leading bits that must match
|
||||||
|
*/
|
||||||
|
private record Cidr(byte[] base, int prefixBits) {
|
||||||
|
|
||||||
|
static Cidr parse(String spec) {
|
||||||
|
if (spec == null || spec.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Empty CIDR specification");
|
||||||
|
}
|
||||||
|
String trimmed = spec.trim();
|
||||||
|
int slash = trimmed.indexOf('/');
|
||||||
|
String ipPart = slash >= 0 ? trimmed.substring(0, slash) : trimmed;
|
||||||
|
byte[] base = NetUtil.createByteArrayFromIpAddressString(ipPart);
|
||||||
|
if (base == null) {
|
||||||
|
throw new IllegalArgumentException("Not a valid IP address: " + ipPart);
|
||||||
|
}
|
||||||
|
int maxBits = base.length * 8;
|
||||||
|
int prefixBits = maxBits;
|
||||||
|
if (slash >= 0) {
|
||||||
|
try {
|
||||||
|
prefixBits = Integer.parseInt(trimmed.substring(slash + 1).trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new IllegalArgumentException("Invalid CIDR prefix in: " + spec, e);
|
||||||
|
}
|
||||||
|
if (prefixBits < 0 || prefixBits > maxBits) {
|
||||||
|
throw new IllegalArgumentException("CIDR prefix out of range in: " + spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Cidr(base, prefixBits);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean contains(byte[] addr) {
|
||||||
|
if (addr.length != base.length) return false;
|
||||||
|
int fullBytes = prefixBits / 8;
|
||||||
|
for (int i = 0; i < fullBytes; i++) {
|
||||||
|
if (addr[i] != base[i]) return false;
|
||||||
|
}
|
||||||
|
int remainingBits = prefixBits % 8;
|
||||||
|
if (remainingBits != 0) {
|
||||||
|
int mask = 0xFF << (8 - remainingBits) & 0xFF;
|
||||||
|
return (addr[fullBytes] & mask) == (base[fullBytes] & mask);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
return o instanceof Cidr(byte[] base1, int bits) && prefixBits == bits && Arrays.equals(base, base1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return 31 * Arrays.hashCode(base) + prefixBits;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Cidr[base=" + Arrays.toString(base) + ", prefixBits=" + prefixBits + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,51 +3,121 @@ package dev.coph.nextusweb.server.ratelimit;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RateLimiter} implementing the <em>fixed window</em> counter algorithm.
|
||||||
|
*
|
||||||
|
* <p>Time is divided into consecutive windows of {@code windowMillis} length. Each key may make
|
||||||
|
* up to {@code limit} requests within a window; the counter resets to zero when a new window
|
||||||
|
* begins. This is the simplest counting strategy but can permit up to twice the limit across a
|
||||||
|
* window boundary (the "burst at the edge" problem) — see {@link SlidingWindowLimiter}
|
||||||
|
* for a smoother variant.</p>
|
||||||
|
*
|
||||||
|
* <p>Window state is held in {@link AtomicLong}s, making the limiter safe for concurrent
|
||||||
|
* use.</p>
|
||||||
|
*/
|
||||||
public final class FixedWindowLimiter implements RateLimiter {
|
public final class FixedWindowLimiter implements RateLimiter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of requests permitted per window.
|
||||||
|
*/
|
||||||
private final long limit;
|
private final long limit;
|
||||||
|
/**
|
||||||
|
* Window length in nanoseconds.
|
||||||
|
*/
|
||||||
private final long windowNanos;
|
private final long windowNanos;
|
||||||
|
/**
|
||||||
|
* Per-key windows, created on demand.
|
||||||
|
*/
|
||||||
private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fixed-window limiter.
|
||||||
|
*
|
||||||
|
* @param limit the maximum number of requests per window
|
||||||
|
* @param windowMillis the window length in milliseconds
|
||||||
|
*/
|
||||||
public FixedWindowLimiter(long limit, long windowMillis) {
|
public FixedWindowLimiter(long limit, long windowMillis) {
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.windowNanos = windowMillis * 1_000_000L;
|
this.windowNanos = windowMillis * 1_000_000L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Lazily creates the window for {@code key} and counts this request against it.</p>
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Result tryAcquire(String key, long nowNanos) {
|
public Result tryAcquire(String key, long nowNanos) {
|
||||||
Window w = windows.computeIfAbsent(key, k -> new Window(nowNanos));
|
Window w = windows.computeIfAbsent(key, k -> new Window(nowNanos));
|
||||||
return w.tryAcquire(nowNanos, limit, windowNanos);
|
return w.tryAcquire(nowNanos, limit, windowNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts windows whose start time is older than the given age.
|
||||||
|
*
|
||||||
|
* @param olderThanNanos maximum age in nanoseconds before a window is removed
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public void cleanup(long olderThanNanos) {
|
public void cleanup(long olderThanNanos) {
|
||||||
long now = System.nanoTime();
|
long now = System.nanoTime();
|
||||||
windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos);
|
windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single client's fixed window, tracking the window start time and the request count
|
||||||
|
* within it.
|
||||||
|
*/
|
||||||
private static final class Window {
|
private static final class Window {
|
||||||
|
/**
|
||||||
|
* Start timestamp of the current window, in nanoseconds.
|
||||||
|
*/
|
||||||
final AtomicLong windowStart;
|
final AtomicLong windowStart;
|
||||||
|
/**
|
||||||
|
* Number of requests counted in the current window.
|
||||||
|
*/
|
||||||
final AtomicLong count;
|
final AtomicLong count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a window starting at the given time with a zero count.
|
||||||
|
*
|
||||||
|
* @param now the window start timestamp in nanoseconds
|
||||||
|
*/
|
||||||
Window(long now) {
|
Window(long now) {
|
||||||
this.windowStart = new AtomicLong(now);
|
this.windowStart = new AtomicLong(now);
|
||||||
this.count = new AtomicLong(0);
|
this.count = new AtomicLong(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Result tryAcquire(long now, long limit, long windowNanos) {
|
/**
|
||||||
|
* Rolls the window over if it has expired, then counts this request and decides whether
|
||||||
|
* it stays within the limit.
|
||||||
|
*
|
||||||
|
* <p>The roll-over (resetting {@code windowStart} and {@code count}) and the subsequent
|
||||||
|
* increment must happen atomically together: the previous lock-free version reset the
|
||||||
|
* count in one thread while another was incrementing it, so increments were silently lost
|
||||||
|
* and the window admitted more than {@code limit} requests around a boundary. Guarding the
|
||||||
|
* whole operation with the window's monitor keeps the count exact; contention is per key
|
||||||
|
* only, so throughput is unaffected in practice.</p>
|
||||||
|
*
|
||||||
|
* @param now the current time in nanoseconds
|
||||||
|
* @param limit the per-window request limit
|
||||||
|
* @param windowNanos the window length in nanoseconds
|
||||||
|
* @return an allow result with the remaining quota, or a deny result with the time until
|
||||||
|
* the window resets
|
||||||
|
*/
|
||||||
|
synchronized Result tryAcquire(long now, long limit, long windowNanos) {
|
||||||
long start = windowStart.get();
|
long start = windowStart.get();
|
||||||
if (now - start >= windowNanos) {
|
if (now - start >= windowNanos) {
|
||||||
if (windowStart.compareAndSet(start, now)) {
|
windowStart.set(now);
|
||||||
count.set(0);
|
count.set(0);
|
||||||
}
|
start = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
long current = count.incrementAndGet();
|
long current = count.incrementAndGet();
|
||||||
if (current > limit) {
|
if (current > limit) {
|
||||||
long retryMs = (windowNanos - (now - windowStart.get())) / 1_000_000L;
|
long retryMs = (windowNanos - (now - start)) / 1_000_000L;
|
||||||
return Result.deny(limit, Math.max(1, retryMs));
|
return Result.deny(limit, Math.max(1, retryMs));
|
||||||
}
|
}
|
||||||
return Result.allow(limit - current, limit);
|
return Result.allow(limit - current, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,88 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
import io.netty.handler.codec.http.HttpRequest;
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy for deriving the logical key under which a request is rate limited. The key
|
||||||
|
* determines which bucket a request counts against — for example one bucket per client IP, one
|
||||||
|
* per API key, one per session cookie, or one per authenticated user.
|
||||||
|
*
|
||||||
|
* <p>Resolvers receive the framework {@link Request} together with the already-resolved client
|
||||||
|
* IP (the pipeline computes it once, honouring the configured trusted proxies — see
|
||||||
|
* {@link dev.coph.nextusweb.server.net.ClientIp}). They are therefore <strong>not</strong> tied
|
||||||
|
* to bearer tokens: pick whichever request facet identifies the caller for your API.</p>
|
||||||
|
*
|
||||||
|
* <p>Built-in resolvers: {@link #clientIp()}, {@link #header(String)}, {@link #cookie(String)}
|
||||||
|
* and {@link #principal()}. The header/cookie/principal resolvers fall back to the client IP when
|
||||||
|
* their facet is absent, so an anonymous caller is still bucketed rather than sharing one global
|
||||||
|
* bucket. Each key is additionally namespaced by the rule, so different rules never collide.</p>
|
||||||
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface KeyResolver {
|
public interface KeyResolver {
|
||||||
String resolve(HttpRequest req, String remoteAddress);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a resolver that keys purely on the resolved client IP. This is the spoofing-safe
|
||||||
|
* replacement for the old header-trusting behaviour: the IP has already been derived through
|
||||||
|
* the trusted-proxy policy, so a directly connected client cannot forge it.
|
||||||
|
*
|
||||||
|
* @return a client-IP key resolver
|
||||||
|
*/
|
||||||
static KeyResolver clientIp() {
|
static KeyResolver clientIp() {
|
||||||
return (req, remote) -> {
|
return (request, clientIp) -> clientIp;
|
||||||
String forwarded = req.headers().get("X-Forwarded-For");
|
}
|
||||||
if (forwarded != null && !forwarded.isEmpty()) {
|
|
||||||
int comma = forwarded.indexOf(',');
|
/**
|
||||||
return comma > 0 ? forwarded.substring(0, comma).trim() : forwarded.trim();
|
* Returns a resolver that keys on the value of a request header (for example an API key in
|
||||||
}
|
* {@code X-API-Key}), falling back to the client IP when the header is absent.
|
||||||
return remote;
|
*
|
||||||
|
* @param headerName the header to key on
|
||||||
|
* @return a header-value key resolver
|
||||||
|
*/
|
||||||
|
static KeyResolver header(String headerName) {
|
||||||
|
return (request, clientIp) -> {
|
||||||
|
String value = request.header(headerName);
|
||||||
|
return (value != null && !value.isEmpty()) ? "h:" + value : "ip:" + clientIp;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static KeyResolver userOrIp() {
|
/**
|
||||||
return (req, remote) -> {
|
* Returns a resolver that keys on the value of a request cookie (for example a session id),
|
||||||
String auth = req.headers().get("Authorization");
|
* falling back to the client IP when the cookie is absent.
|
||||||
if (auth != null && auth.startsWith("Bearer ")) {
|
*
|
||||||
return "u:" + auth.substring(7);
|
* @param cookieName the cookie to key on
|
||||||
}
|
* @return a cookie-value key resolver
|
||||||
return "ip:" + clientIp().resolve(req, remote);
|
*/
|
||||||
|
static KeyResolver cookie(String cookieName) {
|
||||||
|
return (request, clientIp) -> {
|
||||||
|
String value = request.cookie(cookieName);
|
||||||
|
return (value != null && !value.isEmpty()) ? "c:" + value : "ip:" + clientIp;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Returns a resolver that keys on the authenticated {@link Principal} attached to the request,
|
||||||
|
* falling back to the client IP for unauthenticated requests.
|
||||||
|
*
|
||||||
|
* <p>For this to key on the principal, the {@link dev.coph.nextusweb.server.auth.AuthGate auth
|
||||||
|
* layer} must have run before rate limiting (configure it to authenticate the relevant paths).
|
||||||
|
* When no principal is present the resolver degrades gracefully to per-IP limiting.</p>
|
||||||
|
*
|
||||||
|
* @return a principal-or-IP key resolver
|
||||||
|
*/
|
||||||
|
static KeyResolver principal() {
|
||||||
|
return (request, clientIp) -> {
|
||||||
|
Principal p = request.principal();
|
||||||
|
return p != null ? "p:" + p.id() : "ip:" + clientIp;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the rate-limit key for a request.
|
||||||
|
*
|
||||||
|
* @param request the incoming request (headers, cookies, attached principal, ...)
|
||||||
|
* @param clientIp the resolved client IP, honouring trusted proxies
|
||||||
|
* @return the key the request should be counted against; never {@code null}
|
||||||
|
*/
|
||||||
|
String resolve(Request request, String clientIp);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +1,136 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RateLimiter} implementing the <em>leaky bucket</em> algorithm.
|
||||||
|
*
|
||||||
|
* <p>Each key owns a bucket whose "water level" rises by one with every request and "leaks"
|
||||||
|
* back down at a fixed rate of {@code requestsPerSecond} units per second. A request is allowed
|
||||||
|
* while the (post-leak) level is below {@code capacity}; once full, requests are denied until
|
||||||
|
* enough has leaked away. Compared to the token bucket this smooths bursts into a steady
|
||||||
|
* outflow rather than allowing them through up front.</p>
|
||||||
|
*
|
||||||
|
* <p>Each bucket's water level and last-leak timestamp are held together in a single immutable
|
||||||
|
* {@link LeakyBucket.State} behind one {@link AtomicReference} and advanced with a lock-free
|
||||||
|
* compare-and-set loop, so the level and the timestamp it was leaked to are always published
|
||||||
|
* together and the limiter is safe for concurrent use.</p>
|
||||||
|
*/
|
||||||
public final class LeakyBucketLimiter implements RateLimiter {
|
public final class LeakyBucketLimiter implements RateLimiter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum water level (number of queued units) the bucket tolerates.
|
||||||
|
*/
|
||||||
private final long capacity;
|
private final long capacity;
|
||||||
private final long leakIntervalNanos;
|
/**
|
||||||
|
* Nanoseconds it takes for exactly one unit to leak out.
|
||||||
|
*/
|
||||||
|
private final long leakIntervalNanos;
|
||||||
|
/**
|
||||||
|
* Per-key buckets, created on demand.
|
||||||
|
*/
|
||||||
private final ConcurrentHashMap<String, LeakyBucket> buckets = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, LeakyBucket> buckets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a leaky-bucket limiter.
|
||||||
|
*
|
||||||
|
* @param requestsPerSecond the steady leak (drain) rate in units per second
|
||||||
|
* @param capacity the bucket capacity, i.e. the maximum tolerated backlog
|
||||||
|
*/
|
||||||
public LeakyBucketLimiter(long requestsPerSecond, long capacity) {
|
public LeakyBucketLimiter(long requestsPerSecond, long capacity) {
|
||||||
this.capacity = capacity;
|
this.capacity = capacity;
|
||||||
this.leakIntervalNanos = 1_000_000_000L / Math.max(1, requestsPerSecond);
|
this.leakIntervalNanos = 1_000_000_000L / Math.max(1, requestsPerSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Lazily creates the bucket for {@code key} and attempts to add one unit of water.</p>
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Result tryAcquire(String key, long nowNanos) {
|
public Result tryAcquire(String key, long nowNanos) {
|
||||||
LeakyBucket b = buckets.computeIfAbsent(key, k -> new LeakyBucket(nowNanos));
|
LeakyBucket b = buckets.computeIfAbsent(key, k -> new LeakyBucket(nowNanos));
|
||||||
return b.tryAcquire(nowNanos, capacity, leakIntervalNanos);
|
return b.tryAcquire(nowNanos, capacity, leakIntervalNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts buckets that have not leaked/been accessed within the given age.
|
||||||
|
*
|
||||||
|
* @param olderThanNanos maximum idle age in nanoseconds before a bucket is removed
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public void cleanup(long olderThanNanos) {
|
public void cleanup(long olderThanNanos) {
|
||||||
long now = System.nanoTime();
|
long now = System.nanoTime();
|
||||||
buckets.entrySet().removeIf(e -> now - e.getValue().lastLeakNanos.get() > olderThanNanos);
|
buckets.entrySet().removeIf(e -> now - e.getValue().lastLeak() > olderThanNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class LeakyBucket {
|
/**
|
||||||
final AtomicLong waterLevel;
|
* A single client's leaky bucket, tracking the current water level and the timestamp up to
|
||||||
final AtomicLong lastLeakNanos;
|
* which leakage has been accounted for as one atomic unit.
|
||||||
|
*
|
||||||
LeakyBucket(long now) {
|
* @param state Holds the current {@code (waterLevel, lastLeakNanos)} pair as one atomic unit.
|
||||||
this.waterLevel = new AtomicLong(0);
|
*/
|
||||||
this.lastLeakNanos = new AtomicLong(now);
|
private record LeakyBucket(AtomicReference<State> state) {
|
||||||
|
/**
|
||||||
|
* Creates an empty bucket.
|
||||||
|
*
|
||||||
|
* @param nowNanos the creation timestamp in nanoseconds
|
||||||
|
*/
|
||||||
|
private LeakyBucket(long nowNanos) {
|
||||||
|
this(new AtomicReference<>(new State(0, nowNanos)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the timestamp leakage was last accounted to, used by {@link #cleanup(long)}.
|
||||||
|
*
|
||||||
|
* @return the last-leak timestamp in nanoseconds
|
||||||
|
*/
|
||||||
|
long lastLeak() {
|
||||||
|
return state.get().lastLeakNanos();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies elapsed leakage and, if there is room, adds one unit of water. The new level and
|
||||||
|
* the timestamp it was leaked to are swapped in together, so the previous race where the
|
||||||
|
* level advanced but the timestamp update was lost (drifting the leak accounting) can no
|
||||||
|
* longer occur.
|
||||||
|
*
|
||||||
|
* @param now the current time in nanoseconds
|
||||||
|
* @param capacity the bucket capacity
|
||||||
|
* @param leakIntervalNanos the nanoseconds per leaked unit
|
||||||
|
* @return an allow result with the remaining headroom, or a deny result with a retry
|
||||||
|
* hint when the bucket is full
|
||||||
|
*/
|
||||||
Result tryAcquire(long now, long capacity, long leakIntervalNanos) {
|
Result tryAcquire(long now, long capacity, long leakIntervalNanos) {
|
||||||
while (true) {
|
while (true) {
|
||||||
long lastLeak = lastLeakNanos.get();
|
State current = state.get();
|
||||||
long current = waterLevel.get();
|
|
||||||
|
|
||||||
long leaked = (now - lastLeak) / leakIntervalNanos;
|
long leaked = (now - current.lastLeakNanos()) / leakIntervalNanos;
|
||||||
long newLevel = Math.max(0, current - leaked);
|
long newLevel = Math.max(0, current.waterLevel() - leaked);
|
||||||
|
|
||||||
if (newLevel >= capacity) {
|
if (newLevel >= capacity) {
|
||||||
long retryMs = leakIntervalNanos / 1_000_000L;
|
long retryMs = leakIntervalNanos / 1_000_000L;
|
||||||
return Result.deny(capacity, retryMs);
|
return Result.deny(capacity, retryMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
long newLastLeak = leaked > 0 ? lastLeak + leaked * leakIntervalNanos : lastLeak;
|
long newLastLeak = leaked > 0
|
||||||
|
? current.lastLeakNanos() + leaked * leakIntervalNanos
|
||||||
|
: current.lastLeakNanos();
|
||||||
|
|
||||||
if (waterLevel.compareAndSet(current, newLevel + 1)) {
|
if (state.compareAndSet(current, new State(newLevel + 1, newLastLeak))) {
|
||||||
lastLeakNanos.compareAndSet(lastLeak, newLastLeak);
|
|
||||||
return Result.allow(capacity - newLevel - 1, capacity);
|
return Result.allow(capacity - newLevel - 1, capacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a bucket's mutable state.
|
||||||
|
*
|
||||||
|
* @param waterLevel current water level (number of units in the bucket)
|
||||||
|
* @param lastLeakNanos timestamp leakage has been applied up to, in nanoseconds
|
||||||
|
*/
|
||||||
|
private record State(long waterLevel, long lastLeakNanos) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,79 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable mapping from request paths to the {@link Rule rate-limit rules} that apply to them.
|
||||||
|
*
|
||||||
|
* <p>Three kinds of rules can be configured, resolved with the following precedence by
|
||||||
|
* {@link #rulesFor(String)}:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>an optional <strong>global</strong> rule that applies to every request;</li>
|
||||||
|
* <li><strong>exact-path</strong> rules matched by exact path equality;</li>
|
||||||
|
* <li><strong>prefix</strong> rules matched by path prefix, evaluated longest-prefix-first.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>A request is subject to the global rule (if any) plus the single most specific path rule
|
||||||
|
* that matches. Instances are built through the nested {@link Builder}.</p>
|
||||||
|
*/
|
||||||
public final class RateLimitConfig {
|
public final class RateLimitConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule applied to every request, or {@code null} if no global rule is configured.
|
||||||
|
*/
|
||||||
private final Rule globalRule;
|
private final Rule globalRule;
|
||||||
|
/**
|
||||||
|
* Rules matched by exact path equality, keyed by path.
|
||||||
|
*/
|
||||||
private final Map<String, Rule> exactPathRules;
|
private final Map<String, Rule> exactPathRules;
|
||||||
|
/**
|
||||||
|
* Prefix rules, pre-sorted longest-prefix-first so the most specific match wins.
|
||||||
|
*/
|
||||||
private final List<PrefixRule> prefixRules;
|
private final List<PrefixRule> prefixRules;
|
||||||
|
/**
|
||||||
|
* Every distinct limiter referenced by any rule, by identity; used for periodic cleanup.
|
||||||
|
*/
|
||||||
|
private final Set<RateLimiter> allLimiters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an immutable configuration from a {@link Builder}, copying the exact-path rules
|
||||||
|
* and sorting the prefix rules by descending prefix length.
|
||||||
|
*
|
||||||
|
* @param b the builder carrying the configured rules
|
||||||
|
*/
|
||||||
private RateLimitConfig(Builder b) {
|
private RateLimitConfig(Builder b) {
|
||||||
this.globalRule = b.globalRule;
|
this.globalRule = b.globalRule;
|
||||||
this.exactPathRules = Map.copyOf(b.exactPathRules);
|
this.exactPathRules = Map.copyOf(b.exactPathRules);
|
||||||
this.prefixRules = b.prefixRules.stream()
|
this.prefixRules = b.prefixRules.stream()
|
||||||
.sorted((a, c) -> Integer.compare(c.prefix.length(), a.prefix.length()))
|
.sorted((a, c) -> Integer.compare(c.prefix.length(), a.prefix.length()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
Set<RateLimiter> limiters = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||||
|
if (globalRule != null) limiters.add(globalRule.limiter());
|
||||||
|
for (Rule r : exactPathRules.values()) limiters.add(r.limiter());
|
||||||
|
for (PrefixRule pr : prefixRules) limiters.add(pr.rule.limiter());
|
||||||
|
this.allLimiters = Collections.unmodifiableSet(limiters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new, empty {@link Builder}.
|
||||||
|
*
|
||||||
|
* @return a fresh builder
|
||||||
|
*/
|
||||||
public static Builder builder() {
|
public static Builder builder() {
|
||||||
return new Builder();
|
return new Builder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ordered list of rules that apply to the given path.
|
||||||
|
*
|
||||||
|
* <p>The list contains the global rule first (if configured) followed by at most one
|
||||||
|
* path-specific rule: the exact-path rule if one matches, otherwise the longest matching
|
||||||
|
* prefix rule. The returned list may be empty if no rule applies.</p>
|
||||||
|
*
|
||||||
|
* @param path the request path
|
||||||
|
* @return the applicable rules, in evaluation order
|
||||||
|
*/
|
||||||
public List<Rule> rulesFor(String path) {
|
public List<Rule> rulesFor(String path) {
|
||||||
List<Rule> rules = new ArrayList<>(2);
|
List<Rule> rules = new ArrayList<>(2);
|
||||||
if (globalRule != null) rules.add(globalRule);
|
if (globalRule != null) rules.add(globalRule);
|
||||||
@@ -40,34 +92,106 @@ public final class RateLimitConfig {
|
|||||||
return rules;
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every distinct limiter referenced by this configuration, for periodic state
|
||||||
|
* eviction by {@link RateLimitGate}.
|
||||||
|
*
|
||||||
|
* @return the immutable set of distinct limiters (de-duplicated by identity)
|
||||||
|
*/
|
||||||
|
public Set<RateLimiter> allLimiters() {
|
||||||
|
return allLimiters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single rate-limit rule: a limiter, the key resolver feeding it, and a name used to
|
||||||
|
* namespace keys and aid diagnostics.
|
||||||
|
*
|
||||||
|
* @param limiter the limiter that enforces the quota
|
||||||
|
* @param keyResolver resolves the per-request key the limiter buckets on
|
||||||
|
* @param name a human-readable label (e.g. {@code "global"} or a path/prefix)
|
||||||
|
*/
|
||||||
public record Rule(RateLimiter limiter, KeyResolver keyResolver, String name) {
|
public record Rule(RateLimiter limiter, KeyResolver keyResolver, String name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal pairing of a path prefix with the rule that applies to paths starting with it.
|
||||||
|
*
|
||||||
|
* @param prefix the path prefix
|
||||||
|
* @param rule the rule to apply for matching paths
|
||||||
|
*/
|
||||||
private record PrefixRule(String prefix, Rule rule) {
|
private record PrefixRule(String prefix, Rule rule) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link RateLimitConfig}.
|
||||||
|
*/
|
||||||
public static final class Builder {
|
public static final class Builder {
|
||||||
|
/**
|
||||||
|
* Accumulated exact-path rules, keyed by path.
|
||||||
|
*/
|
||||||
private final Map<String, Rule> exactPathRules = new HashMap<>();
|
private final Map<String, Rule> exactPathRules = new HashMap<>();
|
||||||
|
/**
|
||||||
|
* Accumulated prefix rules.
|
||||||
|
*/
|
||||||
private final List<PrefixRule> prefixRules = new ArrayList<>();
|
private final List<PrefixRule> prefixRules = new ArrayList<>();
|
||||||
|
/**
|
||||||
|
* The global rule, if configured.
|
||||||
|
*/
|
||||||
private Rule globalRule;
|
private Rule globalRule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a builder with no rules configured. Obtain instances via
|
||||||
|
* {@link RateLimitConfig#builder()}.
|
||||||
|
*/
|
||||||
|
public Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the global rule applied to every request.
|
||||||
|
*
|
||||||
|
* @param limiter the limiter enforcing the global quota
|
||||||
|
* @param keys the key resolver for the global rule
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder global(RateLimiter limiter, KeyResolver keys) {
|
public Builder global(RateLimiter limiter, KeyResolver keys) {
|
||||||
this.globalRule = new Rule(limiter, keys, "global");
|
this.globalRule = new Rule(limiter, keys, "global");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rule that applies only to requests whose path equals {@code path} exactly.
|
||||||
|
*
|
||||||
|
* @param path the exact request path
|
||||||
|
* @param limiter the limiter enforcing the quota
|
||||||
|
* @param keys the key resolver for this rule
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder forPath(String path, RateLimiter limiter, KeyResolver keys) {
|
public Builder forPath(String path, RateLimiter limiter, KeyResolver keys) {
|
||||||
exactPathRules.put(path, new Rule(limiter, keys, path));
|
exactPathRules.put(path, new Rule(limiter, keys, path));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a rule that applies to requests whose path starts with {@code prefix}. When
|
||||||
|
* several prefixes match, the longest one wins.
|
||||||
|
*
|
||||||
|
* @param prefix the path prefix
|
||||||
|
* @param limiter the limiter enforcing the quota
|
||||||
|
* @param keys the key resolver for this rule
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
public Builder forPrefix(String prefix, RateLimiter limiter, KeyResolver keys) {
|
public Builder forPrefix(String prefix, RateLimiter limiter, KeyResolver keys) {
|
||||||
prefixRules.add(new PrefixRule(prefix, new Rule(limiter, keys, prefix + "*")));
|
prefixRules.add(new PrefixRule(prefix, new Rule(limiter, keys, prefix + "*")));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the immutable {@link RateLimitConfig}.
|
||||||
|
*
|
||||||
|
* @return the configured instance
|
||||||
|
*/
|
||||||
public RateLimitConfig build() {
|
public RateLimitConfig build() {
|
||||||
return new RateLimitConfig(this);
|
return new RateLimitConfig(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,67 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
import dev.coph.nextusweb.server.router.Response;
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
import io.netty.handler.codec.http.HttpRequest;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-pipeline entry point that applies a {@link RateLimitConfig} to incoming requests and
|
||||||
|
* surfaces the outcome as standard {@code X-RateLimit-*} response headers.
|
||||||
|
*
|
||||||
|
* <p>For each request the gate evaluates every {@link RateLimitConfig.Rule rule} that applies
|
||||||
|
* to the request path. If any rule denies the request, evaluation stops and that denial is
|
||||||
|
* returned; otherwise the strictest (lowest remaining) allowance is returned so the headers
|
||||||
|
* reflect the tightest applicable budget.</p>
|
||||||
|
*
|
||||||
|
* <p>A daemon background thread periodically triggers cleanup of stale limiter state. The gate
|
||||||
|
* should be {@link #shutdown() shut down} when the server stops.</p>
|
||||||
|
*/
|
||||||
public final class RateLimitGate {
|
public final class RateLimitGate {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default idle age after which per-key limiter state is eligible for eviction.
|
||||||
|
*/
|
||||||
|
private static final long DEFAULT_STALE_AFTER_NANOS = 10L * 60 * 1_000_000_000L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule set this gate enforces.
|
||||||
|
*/
|
||||||
private final RateLimitConfig config;
|
private final RateLimitConfig config;
|
||||||
|
/**
|
||||||
|
* Idle age (nanoseconds) after which a limiter's per-key state may be evicted.
|
||||||
|
*/
|
||||||
|
private final long staleAfterNanos;
|
||||||
|
/**
|
||||||
|
* Single-threaded scheduler driving periodic cleanup of stale buckets.
|
||||||
|
*/
|
||||||
private final ScheduledExecutorService cleanup;
|
private final ScheduledExecutorService cleanup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a gate for the given configuration and starts a background cleanup task that runs
|
||||||
|
* every five minutes on a daemon thread, evicting per-key state idle for more than ten
|
||||||
|
* minutes.
|
||||||
|
*
|
||||||
|
* @param config the rate-limit rules to enforce
|
||||||
|
*/
|
||||||
public RateLimitGate(RateLimitConfig config) {
|
public RateLimitGate(RateLimitConfig config) {
|
||||||
|
this(config, DEFAULT_STALE_AFTER_NANOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a gate with an explicit idle age before per-key limiter state is evicted.
|
||||||
|
*
|
||||||
|
* @param config the rate-limit rules to enforce
|
||||||
|
* @param staleAfterNanos idle age in nanoseconds after which per-key state is evicted; must
|
||||||
|
* be positive
|
||||||
|
*/
|
||||||
|
public RateLimitGate(RateLimitConfig config, long staleAfterNanos) {
|
||||||
|
if (staleAfterNanos <= 0) throw new IllegalArgumentException("staleAfterNanos must be > 0");
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.staleAfterNanos = staleAfterNanos;
|
||||||
this.cleanup = Executors.newSingleThreadScheduledExecutor(r -> {
|
this.cleanup = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "ratelimit-cleanup");
|
Thread t = new Thread(r, "ratelimit-cleanup");
|
||||||
t.setDaemon(true);
|
t.setDaemon(true);
|
||||||
@@ -22,28 +69,31 @@ public final class RateLimitGate {
|
|||||||
});
|
});
|
||||||
cleanup.scheduleAtFixedRate(this::doCleanup, 5, 5, TimeUnit.MINUTES);
|
cleanup.scheduleAtFixedRate(this::doCleanup, 5, 5, TimeUnit.MINUTES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public RateLimiter.Result check(HttpRequest req, String path, String remoteAddress) {
|
/**
|
||||||
List<RateLimitConfig.Rule> rules = config.rulesFor(path);
|
* Periodic cleanup hook invoked by the background scheduler. Asks every configured limiter to
|
||||||
if (rules.isEmpty()) return null;
|
* evict per-key state idle for longer than {@link #staleAfterNanos}. A failure cleaning one
|
||||||
|
* limiter must not abort the others or kill the scheduler, so each call is guarded.
|
||||||
long now = System.nanoTime();
|
*/
|
||||||
RateLimiter.Result strictest = null;
|
private void doCleanup() {
|
||||||
|
for (RateLimiter limiter : config.allLimiters()) {
|
||||||
for (var rule : rules) {
|
try {
|
||||||
String key = rule.name() + ":" + rule.keyResolver().resolve(req, remoteAddress);
|
limiter.cleanup(staleAfterNanos);
|
||||||
RateLimiter.Result result = rule.limiter().tryAcquire(key, now);
|
} catch (RuntimeException ignored) {
|
||||||
|
|
||||||
if (!result.allowed()) return result;
|
|
||||||
|
|
||||||
if (strictest == null || result.remaining() < strictest.remaining()) {
|
|
||||||
strictest = result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strictest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the standard rate-limit headers ({@code X-RateLimit-Limit},
|
||||||
|
* {@code X-RateLimit-Remaining}, and {@code Retry-After} when denied) onto a response.
|
||||||
|
*
|
||||||
|
* <p>Does nothing when {@code result} is {@code null} (no rule applied). The retry hint is
|
||||||
|
* rounded up to whole seconds as required by the {@code Retry-After} header.</p>
|
||||||
|
*
|
||||||
|
* @param result the limiting result, may be {@code null}
|
||||||
|
* @param res the response to decorate
|
||||||
|
*/
|
||||||
public static void applyHeaders(RateLimiter.Result result, Response res) {
|
public static void applyHeaders(RateLimiter.Result result, Response res) {
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
res.header("X-RateLimit-Limit", String.valueOf(result.limit()));
|
res.header("X-RateLimit-Limit", String.valueOf(result.limit()));
|
||||||
@@ -53,9 +103,44 @@ public final class RateLimitGate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doCleanup() {
|
/**
|
||||||
long threshold = 10L * 60 * 1_000_000_000L;
|
* Evaluates all rules applicable to the given path and decides whether the request may
|
||||||
|
* proceed.
|
||||||
|
*
|
||||||
|
* <p>Each rule's key is namespaced with the rule name to keep buckets from different rules
|
||||||
|
* independent. The first denial short-circuits and is returned immediately; if every rule
|
||||||
|
* allows the request, the result with the least remaining quota is returned.</p>
|
||||||
|
*
|
||||||
|
* @param req the incoming request, used by key resolvers
|
||||||
|
* @param path the request path used to select rules
|
||||||
|
* @param clientIp the resolved client IP (honouring trusted proxies), used as a key-resolver
|
||||||
|
* fallback
|
||||||
|
* @return the limiting result, or {@code null} if no rule applies to the path
|
||||||
|
*/
|
||||||
|
public RateLimiter.Result check(Request req, String path, String clientIp) {
|
||||||
|
List<RateLimitConfig.Rule> rules = config.rulesFor(path);
|
||||||
|
if (rules.isEmpty()) return null;
|
||||||
|
|
||||||
|
long now = System.nanoTime();
|
||||||
|
RateLimiter.Result strictest = null;
|
||||||
|
|
||||||
|
for (var rule : rules) {
|
||||||
|
String key = rule.name() + ":" + rule.keyResolver().resolve(req, clientIp);
|
||||||
|
RateLimiter.Result result = rule.limiter().tryAcquire(key, now);
|
||||||
|
|
||||||
|
if (!result.allowed()) return result;
|
||||||
|
|
||||||
|
if (strictest == null || result.remaining() < strictest.remaining()) {
|
||||||
|
strictest = result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strictest;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void shutdown() { cleanup.shutdown(); }
|
/**
|
||||||
}
|
* Stops the background cleanup scheduler. Should be called when the server shuts down.
|
||||||
|
*/
|
||||||
|
public void shutdown() {
|
||||||
|
cleanup.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,83 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy interface for rate limiting. An implementation decides, per logical key, whether a
|
||||||
|
* single request may proceed right now.
|
||||||
|
*
|
||||||
|
* <p>Concrete strategies in this package include {@link TokenBucketLimiter},
|
||||||
|
* {@link LeakyBucketLimiter}, {@link FixedWindowLimiter} and {@link SlidingWindowLimiter}.
|
||||||
|
* Implementations are expected to be thread-safe, since the same limiter is shared across all
|
||||||
|
* request-handling threads.</p>
|
||||||
|
*
|
||||||
|
* <p>The interface remains effectively functional ({@link #tryAcquire} is its single abstract
|
||||||
|
* method), so simple stateless limiters can still be written as a lambda; stateful limiters that
|
||||||
|
* keep one entry per key should additionally override {@link #cleanup(long)}.</p>
|
||||||
|
*/
|
||||||
public interface RateLimiter {
|
public interface RateLimiter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to consume one unit of quota for the given key at the given timestamp.
|
||||||
|
*
|
||||||
|
* @param key the logical bucket key (for example a client IP or user identifier)
|
||||||
|
* @param nowNanos the current time in nanoseconds, typically {@link System#nanoTime()}
|
||||||
|
* @return a {@link Result} describing whether the request was allowed and the remaining
|
||||||
|
* quota
|
||||||
|
*/
|
||||||
Result tryAcquire(String key, long nowNanos);
|
Result tryAcquire(String key, long nowNanos);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts per-key state that has not been accessed within the given age, bounding the memory
|
||||||
|
* a limiter consumes when it has seen many distinct keys.
|
||||||
|
*
|
||||||
|
* <p>Implementations keep one entry per key seen ({@code clientIp}, API key, ...). Without
|
||||||
|
* periodic eviction those maps grow without bound, which is both a memory leak and a denial
|
||||||
|
* of service vector (an attacker that varies the key on every request can exhaust the heap).
|
||||||
|
* {@link RateLimitGate} calls this periodically for every configured limiter.</p>
|
||||||
|
*
|
||||||
|
* <p>The default implementation does nothing, which is correct for stateless limiters; any
|
||||||
|
* limiter that retains per-key state <strong>must</strong> override it to evict stale
|
||||||
|
* entries.</p>
|
||||||
|
*
|
||||||
|
* @param olderThanNanos maximum idle age in nanoseconds before an entry is removed
|
||||||
|
*/
|
||||||
|
default void cleanup(long olderThanNanos) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable outcome of a {@link #tryAcquire(String, long)} call.
|
||||||
|
*
|
||||||
|
* @param allowed whether the request may proceed
|
||||||
|
* @param remaining the remaining quota in the current window/bucket
|
||||||
|
* @param limit the configured limit, surfaced as {@code X-RateLimit-Limit}
|
||||||
|
* @param retryAfterMillis when denied, how long the caller should wait before retrying, in
|
||||||
|
* milliseconds (0 when allowed)
|
||||||
|
*/
|
||||||
record Result(
|
record Result(
|
||||||
boolean allowed,
|
boolean allowed,
|
||||||
long remaining,
|
long remaining,
|
||||||
long limit,
|
long limit,
|
||||||
long retryAfterMillis
|
long retryAfterMillis
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Creates a result representing an allowed request.
|
||||||
|
*
|
||||||
|
* @param remaining the remaining quota after this request
|
||||||
|
* @param limit the configured limit
|
||||||
|
* @return an "allowed" result with no retry delay
|
||||||
|
*/
|
||||||
public static Result allow(long remaining, long limit) {
|
public static Result allow(long remaining, long limit) {
|
||||||
return new Result(true, remaining, limit, 0);
|
return new Result(true, remaining, limit, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a result representing a denied (rate-limited) request.
|
||||||
|
*
|
||||||
|
* @param limit the configured limit
|
||||||
|
* @param retryAfterMillis how long to wait before retrying, in milliseconds
|
||||||
|
* @return a "denied" result with zero remaining quota
|
||||||
|
*/
|
||||||
public static Result deny(long limit, long retryAfterMillis) {
|
public static Result deny(long limit, long retryAfterMillis) {
|
||||||
return new Result(false, 0, limit, retryAfterMillis);
|
return new Result(false, 0, limit, retryAfterMillis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,112 @@ package dev.coph.nextusweb.server.ratelimit;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RateLimiter} implementing the <em>sliding window counter</em> algorithm.
|
||||||
|
*
|
||||||
|
* <p>This refines {@link FixedWindowLimiter} by smoothing the boundary between adjacent
|
||||||
|
* windows. It keeps the count for the current window and the previous window, and estimates the
|
||||||
|
* effective rate by weighting the previous window's count by how much of the current window has
|
||||||
|
* not yet elapsed. This avoids the burst-doubling that a plain fixed window allows at window
|
||||||
|
* boundaries, at the cost of a little extra state.</p>
|
||||||
|
*
|
||||||
|
* <p>Because the weighted calculation must read and update several fields atomically together,
|
||||||
|
* the per-key update is guarded by {@code synchronized}; the per-key state objects are stored
|
||||||
|
* in a {@link ConcurrentHashMap}.</p>
|
||||||
|
*/
|
||||||
public final class SlidingWindowLimiter implements RateLimiter {
|
public final class SlidingWindowLimiter implements RateLimiter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum effective (weighted) number of requests per window.
|
||||||
|
*/
|
||||||
private final long limit;
|
private final long limit;
|
||||||
|
/**
|
||||||
|
* Window length in nanoseconds.
|
||||||
|
*/
|
||||||
private final long windowNanos;
|
private final long windowNanos;
|
||||||
|
/**
|
||||||
|
* Per-key sliding windows, created on demand.
|
||||||
|
*/
|
||||||
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sliding-window limiter.
|
||||||
|
*
|
||||||
|
* @param limit the maximum effective number of requests per window
|
||||||
|
* @param windowMillis the window length in milliseconds
|
||||||
|
*/
|
||||||
public SlidingWindowLimiter(long limit, long windowMillis) {
|
public SlidingWindowLimiter(long limit, long windowMillis) {
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.windowNanos = windowMillis * 1_000_000L;
|
this.windowNanos = windowMillis * 1_000_000L;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Lazily creates the sliding window for {@code key} and counts this request against
|
||||||
|
* it.</p>
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Result tryAcquire(String key, long nowNanos) {
|
public Result tryAcquire(String key, long nowNanos) {
|
||||||
SlidingWindow w = windows.computeIfAbsent(key, k -> new SlidingWindow(nowNanos));
|
SlidingWindow w = windows.computeIfAbsent(key, k -> new SlidingWindow(nowNanos));
|
||||||
return w.tryAcquire(nowNanos, limit, windowNanos);
|
return w.tryAcquire(nowNanos, limit, windowNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts windows whose start time is older than the given age.
|
||||||
|
*
|
||||||
|
* @param olderThanNanos maximum age in nanoseconds before a window is removed
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public void cleanup(long olderThanNanos) {
|
public void cleanup(long olderThanNanos) {
|
||||||
long now = System.nanoTime();
|
long now = System.nanoTime();
|
||||||
windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos);
|
windows.entrySet().removeIf(e -> now - e.getValue().windowStart.get() > olderThanNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single client's sliding window, tracking the current window start plus the current and
|
||||||
|
* previous window counts.
|
||||||
|
*/
|
||||||
private static final class SlidingWindow {
|
private static final class SlidingWindow {
|
||||||
|
/**
|
||||||
|
* Start timestamp of the current window, in nanoseconds.
|
||||||
|
*/
|
||||||
final AtomicLong windowStart;
|
final AtomicLong windowStart;
|
||||||
|
/**
|
||||||
|
* Request count accumulated in the current window.
|
||||||
|
*/
|
||||||
final AtomicLong currentCount;
|
final AtomicLong currentCount;
|
||||||
|
/**
|
||||||
|
* Request count carried over from the immediately preceding window.
|
||||||
|
*/
|
||||||
final AtomicLong previousCount;
|
final AtomicLong previousCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sliding window starting at the given time with zero counts.
|
||||||
|
*
|
||||||
|
* @param now the window start timestamp in nanoseconds
|
||||||
|
*/
|
||||||
SlidingWindow(long now) {
|
SlidingWindow(long now) {
|
||||||
this.windowStart = new AtomicLong(now);
|
this.windowStart = new AtomicLong(now);
|
||||||
this.currentCount = new AtomicLong(0);
|
this.currentCount = new AtomicLong(0);
|
||||||
this.previousCount = new AtomicLong(0);
|
this.previousCount = new AtomicLong(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the window(s) as time has passed, computes the weighted request count and
|
||||||
|
* decides whether this request stays within the limit.
|
||||||
|
*
|
||||||
|
* <p>If two or more full windows have elapsed the counters are reset; if exactly one has
|
||||||
|
* elapsed the current count becomes the previous count and a fresh window starts. The
|
||||||
|
* weighted count blends the previous window's count (scaled by the fraction of the
|
||||||
|
* current window still remaining) with the current count.</p>
|
||||||
|
*
|
||||||
|
* @param now the current time in nanoseconds
|
||||||
|
* @param limit the per-window effective limit
|
||||||
|
* @param windowNanos the window length in nanoseconds
|
||||||
|
* @return an allow result with the remaining quota, or a deny result with the time until
|
||||||
|
* the window slides far enough to admit the request
|
||||||
|
*/
|
||||||
synchronized Result tryAcquire(long now, long limit, long windowNanos) {
|
synchronized Result tryAcquire(long now, long limit, long windowNanos) {
|
||||||
long start = windowStart.get();
|
long start = windowStart.get();
|
||||||
long elapsed = now - start;
|
long elapsed = now - start;
|
||||||
@@ -64,4 +137,4 @@ public final class SlidingWindowLimiter implements RateLimiter {
|
|||||||
return Result.allow(limit - weightedCount - 1, limit);
|
return Result.allow(limit - weightedCount - 1, limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,153 @@
|
|||||||
package dev.coph.nextusweb.server.ratelimit;
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link RateLimiter} implementing the <em>token bucket</em> algorithm.
|
||||||
|
*
|
||||||
|
* <p>Each key owns a bucket that holds up to {@code burstCapacity} tokens and refills
|
||||||
|
* continuously at {@code requestsPerSecond} tokens per second. Every request consumes one
|
||||||
|
* token; if at least one token is available the request is allowed, otherwise it is denied
|
||||||
|
* with a retry hint computed from the refill rate. This permits short bursts (up to the bucket
|
||||||
|
* capacity) while bounding the sustained rate.</p>
|
||||||
|
*
|
||||||
|
* <p>Token counts are stored in fixed-point form (scaled by 1e9). Each bucket's token count and
|
||||||
|
* last-refill timestamp are held together in a single immutable {@link Bucket.State} behind one
|
||||||
|
* {@link AtomicReference} and advanced with a lock-free compare-and-set loop, so a refill and the
|
||||||
|
* timestamp it is based on are always published as one atomic unit and the limiter is safe for
|
||||||
|
* concurrent use.</p>
|
||||||
|
*/
|
||||||
public final class TokenBucketLimiter implements RateLimiter {
|
public final class TokenBucketLimiter implements RateLimiter {
|
||||||
|
|
||||||
private final long capacity;
|
/**
|
||||||
private final double tokensPerNano;
|
* Maximum number of tokens a bucket can hold (the burst allowance).
|
||||||
|
*/
|
||||||
|
private final long capacity;
|
||||||
|
/**
|
||||||
|
* Refill rate expressed as tokens added per nanosecond.
|
||||||
|
*/
|
||||||
|
private final double tokensPerNano;
|
||||||
|
/**
|
||||||
|
* Approximate nanoseconds between single-token refills, used for retry hints.
|
||||||
|
*/
|
||||||
private final long refillIntervalNs;
|
private final long refillIntervalNs;
|
||||||
|
/**
|
||||||
|
* Per-key buckets, created on demand.
|
||||||
|
*/
|
||||||
private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a token-bucket limiter.
|
||||||
|
*
|
||||||
|
* @param requestsPerSecond the sustained refill rate in tokens (requests) per second
|
||||||
|
* @param burstCapacity the maximum burst size, i.e. the bucket capacity in tokens
|
||||||
|
*/
|
||||||
public TokenBucketLimiter(long requestsPerSecond, long burstCapacity) {
|
public TokenBucketLimiter(long requestsPerSecond, long burstCapacity) {
|
||||||
this.capacity = burstCapacity;
|
this.capacity = burstCapacity;
|
||||||
this.tokensPerNano = (double) requestsPerSecond / 1_000_000_000.0;
|
this.tokensPerNano = (double) requestsPerSecond / 1_000_000_000.0;
|
||||||
this.refillIntervalNs = 1_000_000_000L / Math.max(1, requestsPerSecond);
|
this.refillIntervalNs = 1_000_000_000L / Math.max(1, requestsPerSecond);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Lazily creates the bucket for {@code key} (initially full) and attempts to consume one
|
||||||
|
* token from it.</p>
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Result tryAcquire(String key, long nowNanos) {
|
public Result tryAcquire(String key, long nowNanos) {
|
||||||
Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(capacity, nowNanos));
|
Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(capacity, nowNanos));
|
||||||
return b.tryAcquire(nowNanos, capacity, tokensPerNano, refillIntervalNs);
|
return b.tryAcquire(nowNanos, capacity, tokensPerNano, refillIntervalNs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts buckets that have not been accessed within the given age, bounding memory use.
|
||||||
|
*
|
||||||
|
* @param olderThanNanos maximum idle age in nanoseconds before a bucket is removed
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public void cleanup(long olderThanNanos) {
|
public void cleanup(long olderThanNanos) {
|
||||||
long now = System.nanoTime();
|
long now = System.nanoTime();
|
||||||
buckets.entrySet().removeIf(e -> now - e.getValue().lastAccess() > olderThanNanos);
|
buckets.entrySet().removeIf(e -> now - e.getValue().lastAccess() > olderThanNanos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private record Bucket(AtomicLong tokensFixed, AtomicLong lastRefillNanos) {
|
/**
|
||||||
private Bucket(long tokensFixed, long lastRefillNanos) {
|
* A single client's token bucket. Tokens are stored in fixed-point form (multiplied by
|
||||||
this(new AtomicLong(tokensFixed * 1_000_000_000L), new AtomicLong(lastRefillNanos));
|
* 1e9) to retain sub-token precision; the mutable pair {@code (tokens, timestamp)} lives in a
|
||||||
}
|
* single {@link AtomicReference} so updates are atomic as a unit.
|
||||||
|
*/
|
||||||
long lastAccess() {
|
private static final class Bucket {
|
||||||
return lastRefillNanos.get();
|
/**
|
||||||
}
|
* Holds the current {@code (tokensFixed, lastRefillNanos)} pair as one atomic unit.
|
||||||
|
*/
|
||||||
Result tryAcquire(long now, long capacity, double tokensPerNano, long refillIntervalNs) {
|
private final AtomicReference<State> state;
|
||||||
while (true) {
|
|
||||||
long lastRefill = lastRefillNanos.get();
|
/**
|
||||||
long currentTokens = tokensFixed.get();
|
* Creates a full bucket.
|
||||||
|
*
|
||||||
long elapsed = now - lastRefill;
|
* @param tokens initial token count in whole tokens (scaled internally)
|
||||||
long refilled = currentTokens;
|
* @param lastRefillNanos the creation timestamp in nanoseconds
|
||||||
if (elapsed > 0) {
|
*/
|
||||||
long addedFixed = (long) (elapsed * tokensPerNano * 1_000_000_000.0);
|
Bucket(long tokens, long lastRefillNanos) {
|
||||||
refilled = Math.min(currentTokens + addedFixed, capacity * 1_000_000_000L);
|
this.state = new AtomicReference<>(new State(tokens * 1_000_000_000L, lastRefillNanos));
|
||||||
}
|
}
|
||||||
|
|
||||||
long oneTokenFixed = 1_000_000_000L;
|
/**
|
||||||
if (refilled < oneTokenFixed) {
|
* Returns the timestamp of the last access, used by {@link #cleanup(long)}.
|
||||||
long deficitFixed = oneTokenFixed - refilled;
|
*
|
||||||
long retryNs = (long) (deficitFixed / (tokensPerNano * 1_000_000_000.0));
|
* @return the last-refill timestamp in nanoseconds
|
||||||
return Result.deny(capacity, Math.max(1, retryNs / 1_000_000));
|
*/
|
||||||
}
|
long lastAccess() {
|
||||||
|
return state.get().lastRefillNanos();
|
||||||
long newTokens = refilled - oneTokenFixed;
|
}
|
||||||
if (tokensFixed.compareAndSet(currentTokens, newTokens)) {
|
|
||||||
lastRefillNanos.set(now);
|
/**
|
||||||
return Result.allow(newTokens / 1_000_000_000L, capacity);
|
* Refills the bucket according to elapsed time and attempts to consume one token,
|
||||||
}
|
* retrying via compare-and-set on contention. The token count and the timestamp it was
|
||||||
|
* computed from are swapped in together, so no thread can ever observe refilled tokens
|
||||||
|
* paired with a stale timestamp (or vice versa).
|
||||||
|
*
|
||||||
|
* @param now the current time in nanoseconds
|
||||||
|
* @param capacity the bucket capacity in whole tokens
|
||||||
|
* @param tokensPerNano the refill rate in tokens per nanosecond
|
||||||
|
* @param refillIntervalNs the nominal nanoseconds per token (kept for retry computation)
|
||||||
|
* @return an allow result with the remaining tokens, or a deny result with a retry
|
||||||
|
* hint when fewer than one token is available
|
||||||
|
*/
|
||||||
|
Result tryAcquire(long now, long capacity, double tokensPerNano, long refillIntervalNs) {
|
||||||
|
long oneTokenFixed = 1_000_000_000L;
|
||||||
|
while (true) {
|
||||||
|
State current = state.get();
|
||||||
|
|
||||||
|
long elapsed = now - current.lastRefillNanos();
|
||||||
|
long refilled = current.tokensFixed();
|
||||||
|
if (elapsed > 0) {
|
||||||
|
long addedFixed = (long) (elapsed * tokensPerNano * 1_000_000_000.0);
|
||||||
|
refilled = Math.min(current.tokensFixed() + addedFixed, capacity * 1_000_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refilled < oneTokenFixed) {
|
||||||
|
long deficitFixed = oneTokenFixed - refilled;
|
||||||
|
long retryNs = (long) (deficitFixed / (tokensPerNano * 1_000_000_000.0));
|
||||||
|
return Result.deny(capacity, Math.max(1, retryNs / 1_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
long newTokens = refilled - oneTokenFixed;
|
||||||
|
if (state.compareAndSet(current, new State(newTokens, now))) {
|
||||||
|
return Result.allow(newTokens / 1_000_000_000L, capacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a bucket's mutable state.
|
||||||
|
*
|
||||||
|
* @param tokensFixed current token count in fixed-point (tokens × 1e9)
|
||||||
|
* @param lastRefillNanos timestamp the token count was last advanced to, in nanoseconds
|
||||||
|
*/
|
||||||
|
private record State(long tokensFixed, long lastRefillNanos) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,101 @@
|
|||||||
package dev.coph.nextusweb.server.router;
|
package dev.coph.nextusweb.server.router;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
import dev.coph.nextusweb.server.json.JsonMapper;
|
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||||
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||||
import io.netty.handler.codec.http.*;
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.QueryStringDecoder;
|
||||||
|
import io.netty.handler.codec.http.cookie.Cookie;
|
||||||
|
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
|
||||||
import io.netty.util.CharsetUtil;
|
import io.netty.util.CharsetUtil;
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A convenience wrapper around a Netty {@link FullHttpRequest} that exposes the parts of an
|
||||||
|
* HTTP request handlers typically need: path parameters, query parameters, headers and the
|
||||||
|
* request body (raw, as a parsed JSON tree, or deserialized into a type).
|
||||||
|
*
|
||||||
|
* <p>Query parameters and the parsed JSON body are computed lazily and cached, so repeated
|
||||||
|
* accessors do not re-parse the request. A single {@code Request} instance is not intended to
|
||||||
|
* be shared across threads.</p>
|
||||||
|
*/
|
||||||
public final class Request {
|
public final class Request {
|
||||||
|
|
||||||
private final FullHttpRequest raw;
|
|
||||||
private final Map<String, String> pathParams;
|
|
||||||
private Map<String, List<String>> queryParams;
|
|
||||||
private JsonNode jsonCache;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The underlying Netty request this wrapper delegates to.
|
||||||
|
*/
|
||||||
|
private final FullHttpRequest raw;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path parameters captured by the router while matching, keyed by name.
|
||||||
|
*/
|
||||||
|
private final Map<String, String> pathParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily decoded query-string parameters; {@code null} until first accessed.
|
||||||
|
*/
|
||||||
|
private Map<String, List<String>> queryParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily parsed JSON body; {@code null} until {@link #json()} is first called.
|
||||||
|
*/
|
||||||
|
private JsonNode jsonCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily decoded request cookies, keyed by name; {@code null} until first accessed.
|
||||||
|
*/
|
||||||
|
private Map<String, String> cookies;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily created bag of per-request attributes set by middlewares/handlers.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> attributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved client IP (honouring trusted proxies); {@code null} until set by the pipeline.
|
||||||
|
*/
|
||||||
|
private String clientIp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated principal attached by the auth layer, or {@code null} if unauthenticated.
|
||||||
|
*/
|
||||||
|
private Principal principal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request wrapper.
|
||||||
|
*
|
||||||
|
* @param raw the underlying Netty request
|
||||||
|
* @param pathParams the path parameters captured during routing, keyed by name
|
||||||
|
*/
|
||||||
public Request(FullHttpRequest raw, Map<String, String> pathParams) {
|
public Request(FullHttpRequest raw, Map<String, String> pathParams) {
|
||||||
this.raw = raw;
|
this.raw = raw;
|
||||||
this.pathParams = pathParams;
|
this.pathParams = pathParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of a path parameter captured during routing.
|
||||||
|
*
|
||||||
|
* @param name the parameter name as declared in the route (without braces)
|
||||||
|
* @return the captured value, or {@code null} if no such parameter was matched
|
||||||
|
*/
|
||||||
public String pathParam(String name) {
|
public String pathParam(String name) {
|
||||||
return pathParams.get(name);
|
return pathParams.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first value of a query-string parameter, decoding the query string on first
|
||||||
|
* access.
|
||||||
|
*
|
||||||
|
* @param name the query parameter name
|
||||||
|
* @return the first value, or {@code null} if the parameter is absent or has no value
|
||||||
|
*/
|
||||||
public String queryParam(String name) {
|
public String queryParam(String name) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = new QueryStringDecoder(raw.uri()).parameters();
|
queryParams = new QueryStringDecoder(raw.uri()).parameters();
|
||||||
@@ -33,6 +104,13 @@ public final class Request {
|
|||||||
return values == null || values.isEmpty() ? null : values.getFirst();
|
return values == null || values.isEmpty() ? null : values.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all values of a query-string parameter, decoding the query string on first
|
||||||
|
* access.
|
||||||
|
*
|
||||||
|
* @param name the query parameter name
|
||||||
|
* @return the (possibly empty) list of values for the parameter; never {@code null}
|
||||||
|
*/
|
||||||
public List<String> queryParams(String name) {
|
public List<String> queryParams(String name) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = new QueryStringDecoder(raw.uri()).parameters();
|
queryParams = new QueryStringDecoder(raw.uri()).parameters();
|
||||||
@@ -40,14 +118,32 @@ public final class Request {
|
|||||||
return queryParams.getOrDefault(name, List.of());
|
return queryParams.getOrDefault(name, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of a request header.
|
||||||
|
*
|
||||||
|
* @param name the (case-insensitive) header name
|
||||||
|
* @return the header value, or {@code null} if not present
|
||||||
|
*/
|
||||||
public String header(String name) {
|
public String header(String name) {
|
||||||
return raw.headers().get(name);
|
return raw.headers().get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request body decoded as a UTF-8 string.
|
||||||
|
*
|
||||||
|
* @return the body as text (empty if there is no body)
|
||||||
|
*/
|
||||||
public String body() {
|
public String body() {
|
||||||
return raw.content().toString(CharsetUtil.UTF_8);
|
return raw.content().toString(CharsetUtil.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the request body as a JSON tree, caching the result for subsequent calls. An
|
||||||
|
* empty body resolves to a JSON {@code null} node rather than an error.
|
||||||
|
*
|
||||||
|
* @return the parsed JSON tree
|
||||||
|
* @throws BadRequestException if the body is not valid JSON
|
||||||
|
*/
|
||||||
public JsonNode json() {
|
public JsonNode json() {
|
||||||
if (jsonCache == null) {
|
if (jsonCache == null) {
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +161,17 @@ public final class Request {
|
|||||||
return jsonCache;
|
return jsonCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes the request body directly into an instance of the given type.
|
||||||
|
*
|
||||||
|
* <p>Unlike {@link #json()}, the result is not cached and the body is read fresh on each
|
||||||
|
* call.</p>
|
||||||
|
*
|
||||||
|
* @param type the target type to deserialize into
|
||||||
|
* @param <T> the target type
|
||||||
|
* @return the deserialized value
|
||||||
|
* @throws BadRequestException if the body cannot be deserialized into {@code type}
|
||||||
|
*/
|
||||||
public <T> T jsonAs(Class<T> type) {
|
public <T> T jsonAs(Class<T> type) {
|
||||||
try {
|
try {
|
||||||
byte[] bytes = new byte[raw.content().readableBytes()];
|
byte[] bytes = new byte[raw.content().readableBytes()];
|
||||||
@@ -76,11 +183,121 @@ public final class Request {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of a request cookie, decoding the {@code Cookie} header on first access.
|
||||||
|
*
|
||||||
|
* @param name the cookie name
|
||||||
|
* @return the cookie value, or {@code null} if no such cookie is present
|
||||||
|
*/
|
||||||
|
public String cookie(String name) {
|
||||||
|
if (cookies == null) {
|
||||||
|
String header = raw.headers().get(HttpHeaderNames.COOKIE);
|
||||||
|
if (header == null || header.isEmpty()) {
|
||||||
|
cookies = Map.of();
|
||||||
|
} else {
|
||||||
|
Map<String, String> parsed = new HashMap<>();
|
||||||
|
for (Cookie c : ServerCookieDecoder.STRICT.decode(header)) {
|
||||||
|
parsed.putIfAbsent(c.name(), c.value());
|
||||||
|
}
|
||||||
|
cookies = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a per-request attribute, or removes it when {@code value} is {@code null}. Useful for
|
||||||
|
* passing state from middlewares or the auth layer to handlers.
|
||||||
|
*
|
||||||
|
* @param name the attribute name
|
||||||
|
* @param value the value to store, or {@code null} to remove it
|
||||||
|
* @return this request, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Request attribute(String name, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
if (attributes != null) attributes.remove(name);
|
||||||
|
} else {
|
||||||
|
if (attributes == null) attributes = new HashMap<>(8);
|
||||||
|
attributes.put(name, value);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a previously stored attribute, cast to the caller's expected type.
|
||||||
|
*
|
||||||
|
* @param name the attribute name
|
||||||
|
* @param <T> the expected attribute type
|
||||||
|
* @return the stored value, or {@code null} if absent
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> T attribute(String name) {
|
||||||
|
return attributes == null ? null : (T) attributes.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the resolved client IP address for this request. Unlike the raw socket address this
|
||||||
|
* honours the server's trusted-proxy configuration, so it reflects the originating client
|
||||||
|
* when the server sits behind a trusted reverse proxy and the socket peer otherwise.
|
||||||
|
*
|
||||||
|
* @return the resolved client IP, or {@code null} if the pipeline has not set one
|
||||||
|
*/
|
||||||
|
public String clientIp() {
|
||||||
|
return clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the resolved client IP. Called by the request pipeline; not intended for handler use.
|
||||||
|
*
|
||||||
|
* @param clientIp the resolved client IP
|
||||||
|
*/
|
||||||
|
public void clientIp(String clientIp) {
|
||||||
|
this.clientIp = clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated principal attached to this request by the auth layer.
|
||||||
|
*
|
||||||
|
* @return the principal, or {@code null} if the request was not authenticated
|
||||||
|
*/
|
||||||
|
public Principal principal() {
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches an authenticated principal to this request. Called by the auth layer; not intended
|
||||||
|
* for handler use.
|
||||||
|
*
|
||||||
|
* @param principal the authenticated principal
|
||||||
|
*/
|
||||||
|
public void principal(Principal principal) {
|
||||||
|
this.principal = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether this request carries an authenticated principal.
|
||||||
|
*
|
||||||
|
* @return {@code true} if a principal is attached
|
||||||
|
*/
|
||||||
|
public boolean isAuthenticated() {
|
||||||
|
return principal != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request's HTTP method.
|
||||||
|
*
|
||||||
|
* @return the HTTP method
|
||||||
|
*/
|
||||||
public HttpMethod method() {
|
public HttpMethod method() {
|
||||||
return raw.method();
|
return raw.method();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request's path, with any query string stripped off.
|
||||||
|
*
|
||||||
|
* @return the decoded request path
|
||||||
|
*/
|
||||||
public String path() {
|
public String path() {
|
||||||
return new QueryStringDecoder(raw.uri()).path();
|
return new QueryStringDecoder(raw.uri()).path();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,100 @@
|
|||||||
package dev.coph.nextusweb.server.router;
|
package dev.coph.nextusweb.server.router;
|
||||||
|
|
||||||
import dev.coph.nextusweb.server.json.JsonMapper;
|
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||||
import io.netty.handler.codec.http.*;
|
import io.netty.handler.codec.http.DefaultHttpHeaders;
|
||||||
|
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||||
|
import io.netty.handler.codec.http.HttpHeaders;
|
||||||
import io.netty.util.CharsetUtil;
|
import io.netty.util.CharsetUtil;
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mutable builder for the HTTP response a handler produces. Handlers set the status code,
|
||||||
|
* headers and body fluently; the request pipeline later reads these back via the accessor
|
||||||
|
* methods to construct the actual Netty response on the wire.
|
||||||
|
*
|
||||||
|
* <p>The status defaults to {@code 200 OK} and the body to an empty byte array. The body
|
||||||
|
* setters ({@link #text(String)}, {@link #json(String)}, {@link #json(Object)}) also set an
|
||||||
|
* appropriate {@code Content-Type} header.</p>
|
||||||
|
*/
|
||||||
public final class Response {
|
public final class Response {
|
||||||
|
|
||||||
private int status = 200;
|
/**
|
||||||
|
* Response headers accumulated by the handler.
|
||||||
|
*/
|
||||||
private final HttpHeaders headers = new DefaultHttpHeaders();
|
private final HttpHeaders headers = new DefaultHttpHeaders();
|
||||||
|
/**
|
||||||
|
* HTTP status code; defaults to {@code 200}.
|
||||||
|
*/
|
||||||
|
private int status = 200;
|
||||||
|
/**
|
||||||
|
* Response body bytes; defaults to an empty array.
|
||||||
|
*/
|
||||||
private byte[] body = new byte[0];
|
private byte[] body = new byte[0];
|
||||||
|
|
||||||
public Response status(int s) { this.status = s; return this; }
|
/**
|
||||||
|
* Creates an empty response with status {@code 200}, no headers and an empty body, ready to
|
||||||
|
* be populated fluently by a handler.
|
||||||
|
*/
|
||||||
|
public Response() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the HTTP status code.
|
||||||
|
*
|
||||||
|
* @param s the status code
|
||||||
|
* @return this response, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Response status(int s) {
|
||||||
|
this.status = s;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a response header, replacing any existing value for the same name.
|
||||||
|
*
|
||||||
|
* @param name the header name
|
||||||
|
* @param value the header value
|
||||||
|
* @return this response, for fluent chaining
|
||||||
|
*/
|
||||||
public Response header(String name, String value) {
|
public Response header(String name, String value) {
|
||||||
headers.set(name, value);
|
headers.set(name, value);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the body to the given text encoded as UTF-8 and sets the {@code Content-Type} to
|
||||||
|
* {@code text/plain; charset=utf-8}.
|
||||||
|
*
|
||||||
|
* @param s the text body
|
||||||
|
* @return this response, for fluent chaining
|
||||||
|
*/
|
||||||
public Response text(String s) {
|
public Response text(String s) {
|
||||||
this.body = s.getBytes(CharsetUtil.UTF_8);
|
this.body = s.getBytes(CharsetUtil.UTF_8);
|
||||||
headers.set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
|
headers.set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=utf-8");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the body to an already-serialized JSON string and sets the {@code Content-Type} to
|
||||||
|
* {@code application/json; charset=utf-8}.
|
||||||
|
*
|
||||||
|
* @param json the raw JSON string
|
||||||
|
* @return this response, for fluent chaining
|
||||||
|
*/
|
||||||
public Response json(String json) {
|
public Response json(String json) {
|
||||||
this.body = json.getBytes(CharsetUtil.UTF_8);
|
this.body = json.getBytes(CharsetUtil.UTF_8);
|
||||||
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
|
headers.set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=utf-8");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the given value to JSON, sets it as the body and sets the {@code Content-Type}
|
||||||
|
* to {@code application/json; charset=utf-8}.
|
||||||
|
*
|
||||||
|
* @param value the object to serialize
|
||||||
|
* @return this response, for fluent chaining
|
||||||
|
* @throws RuntimeException if JSON serialization fails
|
||||||
|
*/
|
||||||
public Response json(Object value) {
|
public Response json(Object value) {
|
||||||
try {
|
try {
|
||||||
this.body = JsonMapper.MAPPER.writeValueAsBytes(value);
|
this.body = JsonMapper.MAPPER.writeValueAsBytes(value);
|
||||||
@@ -40,7 +105,30 @@ public final class Response {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int status() { return status; }
|
/**
|
||||||
public HttpHeaders headers() { return headers; }
|
* Returns the configured HTTP status code.
|
||||||
public byte[] body() { return body; }
|
*
|
||||||
}
|
* @return the status code
|
||||||
|
*/
|
||||||
|
public int status() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the accumulated response headers.
|
||||||
|
*
|
||||||
|
* @return the headers
|
||||||
|
*/
|
||||||
|
public HttpHeaders headers() {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the response body bytes.
|
||||||
|
*
|
||||||
|
* @return the body bytes
|
||||||
|
*/
|
||||||
|
public byte[] body() {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,20 +6,75 @@ import java.util.*;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trie-based HTTP router that maps {@code (method, path)} pairs to {@link Handler handlers}.
|
||||||
|
*
|
||||||
|
* <p>Routes are stored in a prefix tree (radix-style {@link Node} tree) keyed by path segment.
|
||||||
|
* Three kinds of segments are supported:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>static</strong> segments such as {@code users}, matched literally;</li>
|
||||||
|
* <li><strong>path parameters</strong> written as {@code {name}}, which match any single
|
||||||
|
* segment and capture its value under {@code name};</li>
|
||||||
|
* <li><strong>wildcards</strong> written as {@code *}, which match any single segment
|
||||||
|
* without capturing it.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>The router also holds an ordered list of {@link BiConsumer middlewares} that the request
|
||||||
|
* pipeline runs against every matched request before the handler executes.</p>
|
||||||
|
*
|
||||||
|
* <p>Registration mutates the shared trie and is intended to happen during start-up;
|
||||||
|
* {@link #resolve(HttpMethod, String)} is safe to call concurrently afterwards because the
|
||||||
|
* per-node maps are {@link ConcurrentHashMap}s.</p>
|
||||||
|
*/
|
||||||
public final class Router {
|
public final class Router {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the routing trie; every registered path descends from here.
|
||||||
|
*/
|
||||||
private final Node root = new Node();
|
private final Node root = new Node();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middlewares executed in insertion order for every matched request.
|
||||||
|
*/
|
||||||
private final List<BiConsumer<Request, Response>> middlewares = new ArrayList<>();
|
private final List<BiConsumer<Request, Response>> middlewares = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an empty router with no registered routes and no middlewares.
|
||||||
|
*/
|
||||||
|
public Router() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a middleware that runs against every matched request before its handler.
|
||||||
|
*
|
||||||
|
* @param middleware a callback receiving the request and the response being built
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router use(BiConsumer<Request, Response> middleware) {
|
public Router use(BiConsumer<Request, Response> middleware) {
|
||||||
middlewares.add(middleware);
|
middlewares.add(middleware);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for the {@code GET} method at the given path.
|
||||||
|
*
|
||||||
|
* @param path the route path (supports {@code {param}} and {@code *} segments)
|
||||||
|
* @param h the handler to invoke
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router get(String path, Handler h) {
|
public Router get(String path, Handler h) {
|
||||||
return register(HttpMethod.GET, path, h);
|
return register(HttpMethod.GET, path, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for an arbitrary HTTP method at the given path, creating any missing
|
||||||
|
* trie nodes along the way.
|
||||||
|
*
|
||||||
|
* @param method the HTTP method to bind the handler to
|
||||||
|
* @param path the route path (supports {@code {param}} and {@code *} segments)
|
||||||
|
* @param h the handler to invoke
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router register(HttpMethod method, String path, Handler h) {
|
public Router register(HttpMethod method, String path, Handler h) {
|
||||||
Node node = root;
|
Node node = root;
|
||||||
for (String segment : split(path)) {
|
for (String segment : split(path)) {
|
||||||
@@ -40,6 +95,13 @@ public final class Router {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a path into its non-empty segments, ignoring leading and collapsing internal
|
||||||
|
* slashes. For example {@code "/a/b/"} yields {@code ["a", "b"]}.
|
||||||
|
*
|
||||||
|
* @param path the raw path
|
||||||
|
* @return the ordered list of path segments
|
||||||
|
*/
|
||||||
private static List<String> split(String path) {
|
private static List<String> split(String path) {
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
int start = path.startsWith("/") ? 1 : 0;
|
int start = path.startsWith("/") ? 1 : 0;
|
||||||
@@ -53,26 +115,62 @@ public final class Router {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for the {@code POST} method at the given path.
|
||||||
|
*
|
||||||
|
* @param path the route path
|
||||||
|
* @param h the handler to invoke
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router post(String path, Handler h) {
|
public Router post(String path, Handler h) {
|
||||||
return register(HttpMethod.POST, path, h);
|
return register(HttpMethod.POST, path, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for the {@code PUT} method at the given path.
|
||||||
|
*
|
||||||
|
* @param path the route path
|
||||||
|
* @param h the handler to invoke
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router put(String path, Handler h) {
|
public Router put(String path, Handler h) {
|
||||||
return register(HttpMethod.PUT, path, h);
|
return register(HttpMethod.PUT, path, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for the {@code DELETE} method at the given path.
|
||||||
|
*
|
||||||
|
* @param path the route path
|
||||||
|
* @param h the handler to invoke
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
public Router delete(String path, Handler h) {
|
public Router delete(String path, Handler h) {
|
||||||
return register(HttpMethod.DELETE, path, h);
|
return register(HttpMethod.DELETE, path, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an incoming request against the routing trie.
|
||||||
|
*
|
||||||
|
* <p>Static segments are matched first, falling back to a path-parameter child (capturing
|
||||||
|
* the segment value) and then a wildcard child. If the path cannot be matched a
|
||||||
|
* {@link Resolution.NotFound} is returned. If the path matches but no handler exists for
|
||||||
|
* the requested method, a {@link Resolution.MethodNotAllowed} carrying the set of allowed
|
||||||
|
* methods is returned. Otherwise a {@link Resolution.Match} with the handler and captured
|
||||||
|
* path parameters is returned.</p>
|
||||||
|
*
|
||||||
|
* @param method the request's HTTP method
|
||||||
|
* @param path the request's path
|
||||||
|
* @return the resolution outcome, never {@code null}
|
||||||
|
*/
|
||||||
public Resolution resolve(HttpMethod method, String path) {
|
public Resolution resolve(HttpMethod method, String path) {
|
||||||
Map<String, String> params = new HashMap<>(4);
|
Map<String, String> params = null;
|
||||||
Node node = root;
|
Node node = root;
|
||||||
for (String segment : split(path)) {
|
for (String segment : split(path)) {
|
||||||
Node next = node.children.get(segment);
|
Node next = node.children.get(segment);
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
node = next;
|
node = next;
|
||||||
} else if (node.paramChild != null) {
|
} else if (node.paramChild != null) {
|
||||||
|
if (params == null) params = new HashMap<>(4);
|
||||||
params.put(node.paramName, segment);
|
params.put(node.paramName, segment);
|
||||||
node = node.paramChild;
|
node = node.paramChild;
|
||||||
} else if (node.wildcardChild != null) {
|
} else if (node.wildcardChild != null) {
|
||||||
@@ -84,7 +182,7 @@ public final class Router {
|
|||||||
|
|
||||||
Handler h = node.handlers.get(method);
|
Handler h = node.handlers.get(method);
|
||||||
if (h != null) {
|
if (h != null) {
|
||||||
return new Resolution.Match(h, params);
|
return new Resolution.Match(h, params == null ? Map.of() : params);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.handlers.isEmpty()) {
|
if (!node.handlers.isEmpty()) {
|
||||||
@@ -95,31 +193,84 @@ public final class Router {
|
|||||||
return new Resolution.NotFound();
|
return new Resolution.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the live, ordered list of registered middlewares.
|
||||||
|
*
|
||||||
|
* @return the middleware list (modifications affect this router)
|
||||||
|
*/
|
||||||
public List<BiConsumer<Request, Response>> middlewares() {
|
public List<BiConsumer<Request, Response>> middlewares() {
|
||||||
return middlewares;
|
return middlewares;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed result type describing the three possible outcomes of {@link #resolve}.
|
||||||
|
*/
|
||||||
public sealed interface Resolution {
|
public sealed interface Resolution {
|
||||||
|
/**
|
||||||
|
* A successful match.
|
||||||
|
*
|
||||||
|
* @param handler the handler to invoke for the request
|
||||||
|
* @param pathParams the path parameters captured while matching, keyed by name
|
||||||
|
*/
|
||||||
record Match(Handler handler, Map<String, String> pathParams) implements Resolution {
|
record Match(Handler handler, Map<String, String> pathParams) implements Resolution {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path matched but no handler is registered for the requested method.
|
||||||
|
*
|
||||||
|
* @param allowedMethods the methods that <em>are</em> registered for this path
|
||||||
|
*/
|
||||||
record MethodNotAllowed(Set<HttpMethod> allowedMethods) implements Resolution {
|
record MethodNotAllowed(Set<HttpMethod> allowedMethods) implements Resolution {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No route matches the requested path.
|
||||||
|
*/
|
||||||
record NotFound() implements Resolution {
|
record NotFound() implements Resolution {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional contract for a request handler: consumes the incoming {@link Request} and
|
||||||
|
* mutates the outgoing {@link Response}.
|
||||||
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface Handler {
|
public interface Handler {
|
||||||
|
/**
|
||||||
|
* Handles a matched request.
|
||||||
|
*
|
||||||
|
* @param req the incoming request
|
||||||
|
* @param res the response to populate
|
||||||
|
* @throws Exception if handling fails; the request pipeline translates this into an
|
||||||
|
* appropriate error response
|
||||||
|
*/
|
||||||
void handle(Request req, Response res) throws Exception;
|
void handle(Request req, Response res) throws Exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single node in the routing trie. Holds static children keyed by segment, the handlers
|
||||||
|
* registered at this node, and optional parameter/wildcard children.
|
||||||
|
*/
|
||||||
private static final class Node {
|
private static final class Node {
|
||||||
|
/**
|
||||||
|
* Static child nodes keyed by their literal path segment.
|
||||||
|
*/
|
||||||
final Map<String, Node> children = new ConcurrentHashMap<>();
|
final Map<String, Node> children = new ConcurrentHashMap<>();
|
||||||
|
/**
|
||||||
|
* Handlers registered directly at this node, keyed by HTTP method.
|
||||||
|
*/
|
||||||
final Map<HttpMethod, Handler> handlers = new ConcurrentHashMap<>();
|
final Map<HttpMethod, Handler> handlers = new ConcurrentHashMap<>();
|
||||||
Node paramChild;
|
/**
|
||||||
|
* Child matching any single segment as a path parameter, or {@code null} if none.
|
||||||
|
*/
|
||||||
|
Node paramChild;
|
||||||
|
/**
|
||||||
|
* Name under which {@link #paramChild} captures the matched segment.
|
||||||
|
*/
|
||||||
String paramName;
|
String paramName;
|
||||||
Node wildcardChild;
|
/**
|
||||||
|
* Child matching any single segment as a wildcard, or {@code null} if none.
|
||||||
|
*/
|
||||||
|
Node wildcardChild;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
package dev.coph.nextusweb.server.router.exception;
|
package dev.coph.nextusweb.server.router.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecked exception signalling that an incoming request is malformed and should be answered
|
||||||
|
* with an HTTP {@code 400 Bad Request}.
|
||||||
|
*
|
||||||
|
* <p>It is thrown, for example, when a request body cannot be parsed as JSON or deserialized
|
||||||
|
* into the expected type. The request pipeline catches it and translates the
|
||||||
|
* {@linkplain #getMessage() message} into a {@code 400} response, distinguishing it from
|
||||||
|
* unexpected errors which produce a {@code 500}.</p>
|
||||||
|
*/
|
||||||
public final class BadRequestException extends RuntimeException {
|
public final class BadRequestException extends RuntimeException {
|
||||||
public BadRequestException(String message) { super(message); }
|
|
||||||
}
|
/**
|
||||||
|
* Creates a bad-request exception with a human-readable explanation.
|
||||||
|
*
|
||||||
|
* @param message the detail message describing why the request is invalid
|
||||||
|
*/
|
||||||
|
public BadRequestException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package dev.coph.nextusweb.server.security;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import io.netty.handler.codec.http.HttpHeaders;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small, immutable policy of standard HTTP security response headers that the server adds to
|
||||||
|
* every response. It complements the transport- and authentication-level protections (TLS, CORS,
|
||||||
|
* the auth gate, rate limiting) with the browser-facing hardening headers that mitigate
|
||||||
|
* MIME-sniffing, click-jacking, referrer leakage and protocol-downgrade attacks.
|
||||||
|
*
|
||||||
|
* <p>Like {@code CorsHandler} the header strings are computed once at construction time and then
|
||||||
|
* reused for every response, so applying them is cheap. Attach an instance with
|
||||||
|
* {@code HttpServer.withSecurityHeaders(...)} and the request pipeline applies it to all
|
||||||
|
* responses.</p>
|
||||||
|
*
|
||||||
|
* <p>Two design choices keep the feature safe to switch on:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>Existing headers are never overwritten.</strong> If a handler has already set a
|
||||||
|
* given header (say a route-specific {@code Content-Security-Policy}), that value is kept and
|
||||||
|
* the policy default is skipped — so enabling security headers cannot silently clobber
|
||||||
|
* deliberate per-route choices.</li>
|
||||||
|
* <li><strong>HSTS is emitted only over HTTPS.</strong> {@code Strict-Transport-Security} is
|
||||||
|
* added only when the connection is actually secured by TLS, because a browser ignores it on
|
||||||
|
* plain HTTP and sending it there is meaningless (and a footgun behind a misconfigured
|
||||||
|
* proxy).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class SecurityHeaders {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code Strict-Transport-Security} header name, gated on a secure connection.
|
||||||
|
*/
|
||||||
|
private static final String HSTS = "Strict-Transport-Security";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers added to every response (subject to not already being present).
|
||||||
|
*/
|
||||||
|
private final List<Map.Entry<String, String>> always;
|
||||||
|
/**
|
||||||
|
* Pre-rendered HSTS header value, or {@code null} if HSTS is disabled.
|
||||||
|
*/
|
||||||
|
private final String hstsValue;
|
||||||
|
|
||||||
|
private SecurityHeaders(Builder b) {
|
||||||
|
List<Map.Entry<String, String>> list = new ArrayList<>();
|
||||||
|
if (b.contentTypeOptions) {
|
||||||
|
list.add(Map.entry("X-Content-Type-Options", "nosniff"));
|
||||||
|
}
|
||||||
|
if (notBlank(b.frameOptions)) {
|
||||||
|
list.add(Map.entry("X-Frame-Options", b.frameOptions));
|
||||||
|
}
|
||||||
|
if (notBlank(b.referrerPolicy)) {
|
||||||
|
list.add(Map.entry("Referrer-Policy", b.referrerPolicy));
|
||||||
|
}
|
||||||
|
if (notBlank(b.contentSecurityPolicy)) {
|
||||||
|
list.add(Map.entry("Content-Security-Policy", b.contentSecurityPolicy));
|
||||||
|
}
|
||||||
|
for (var e : b.custom.entrySet()) {
|
||||||
|
list.add(Map.entry(e.getKey(), e.getValue()));
|
||||||
|
}
|
||||||
|
this.always = List.copyOf(list);
|
||||||
|
|
||||||
|
if (b.hstsMaxAge != null && !b.hstsMaxAge.isZero() && !b.hstsMaxAge.isNegative()) {
|
||||||
|
StringBuilder sb = new StringBuilder("max-age=").append(b.hstsMaxAge.toSeconds());
|
||||||
|
if (b.hstsIncludeSubDomains) sb.append("; includeSubDomains");
|
||||||
|
if (b.hstsPreload) sb.append("; preload");
|
||||||
|
this.hstsValue = sb.toString();
|
||||||
|
} else {
|
||||||
|
this.hstsValue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean notBlank(String s) {
|
||||||
|
return s != null && !s.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sensible, conservative default policy: {@code X-Content-Type-Options: nosniff},
|
||||||
|
* {@code X-Frame-Options: DENY}, {@code Referrer-Policy: no-referrer} and, on HTTPS
|
||||||
|
* connections, a one-year {@code Strict-Transport-Security} header (without
|
||||||
|
* {@code includeSubDomains}/{@code preload}, which are opt-in because of their wide blast
|
||||||
|
* radius). No {@code Content-Security-Policy} is set, since a useful CSP is application
|
||||||
|
* specific.
|
||||||
|
*
|
||||||
|
* @return the default security-header policy
|
||||||
|
*/
|
||||||
|
public static SecurityHeaders defaults() {
|
||||||
|
return builder()
|
||||||
|
.hsts(Duration.ofDays(365), false, false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a builder pre-populated with the conservative defaults (see {@link #defaults()}),
|
||||||
|
* except that HSTS is disabled until configured with {@link Builder#hsts}.
|
||||||
|
*
|
||||||
|
* @return a fresh builder
|
||||||
|
*/
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the configured security headers to a response, skipping any header the handler has
|
||||||
|
* already set, and adding {@code Strict-Transport-Security} only when {@code secure} is
|
||||||
|
* {@code true}.
|
||||||
|
*
|
||||||
|
* @param res the response to decorate
|
||||||
|
* @param secure whether the underlying connection is secured by TLS
|
||||||
|
*/
|
||||||
|
public void apply(Response res, boolean secure) {
|
||||||
|
HttpHeaders headers = res.headers();
|
||||||
|
for (Map.Entry<String, String> e : always) {
|
||||||
|
if (!headers.contains(e.getKey())) {
|
||||||
|
headers.set(e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (secure && hstsValue != null && !headers.contains(HSTS)) {
|
||||||
|
headers.set(HSTS, hstsValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link SecurityHeaders}. Sensible defaults are pre-set; call the setters
|
||||||
|
* only to override them. Passing {@code null} (or a blank string) to a setter disables that
|
||||||
|
* particular header.
|
||||||
|
*/
|
||||||
|
public static final class Builder {
|
||||||
|
private final Map<String, String> custom = new LinkedHashMap<>();
|
||||||
|
private boolean contentTypeOptions = true;
|
||||||
|
private String frameOptions = "DENY";
|
||||||
|
private String referrerPolicy = "no-referrer";
|
||||||
|
private String contentSecurityPolicy;
|
||||||
|
private Duration hstsMaxAge;
|
||||||
|
private boolean hstsIncludeSubDomains;
|
||||||
|
private boolean hstsPreload;
|
||||||
|
|
||||||
|
private Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables {@code X-Content-Type-Options: nosniff} (defends against MIME
|
||||||
|
* sniffing). Enabled by default.
|
||||||
|
*
|
||||||
|
* @param enabled {@code true} to emit the header
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder contentTypeOptions(boolean enabled) {
|
||||||
|
this.contentTypeOptions = enabled;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@code X-Frame-Options} value (click-jacking defence); typical values are
|
||||||
|
* {@code "DENY"} (the default) or {@code "SAMEORIGIN"}. Pass {@code null} or a blank string
|
||||||
|
* to omit the header.
|
||||||
|
*
|
||||||
|
* @param value the header value, or {@code null}/blank to disable
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder frameOptions(String value) {
|
||||||
|
this.frameOptions = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the {@code Referrer-Policy} value (defaults to {@code "no-referrer"}). Pass
|
||||||
|
* {@code null} or a blank string to omit the header.
|
||||||
|
*
|
||||||
|
* @param value the header value, or {@code null}/blank to disable
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder referrerPolicy(String value) {
|
||||||
|
this.referrerPolicy = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a {@code Content-Security-Policy}. Disabled by default because a useful CSP is
|
||||||
|
* application specific; supply one tailored to your app. Pass {@code null} or a blank
|
||||||
|
* string to omit the header.
|
||||||
|
*
|
||||||
|
* @param value the policy string, or {@code null}/blank to disable
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder contentSecurityPolicy(String value) {
|
||||||
|
this.contentSecurityPolicy = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables {@code Strict-Transport-Security} (HSTS), which is emitted only on HTTPS
|
||||||
|
* connections. Be deliberate with {@code includeSubDomains} and {@code preload}: they are
|
||||||
|
* hard to roll back, so enable them only once every subdomain is reliably served over
|
||||||
|
* HTTPS.
|
||||||
|
*
|
||||||
|
* @param maxAge how long browsers should pin HTTPS; {@code null}/zero/negative
|
||||||
|
* disables HSTS
|
||||||
|
* @param includeSubDomains whether the policy also covers every subdomain
|
||||||
|
* @param preload whether to request inclusion in browser preload lists
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder hsts(Duration maxAge, boolean includeSubDomains, boolean preload) {
|
||||||
|
this.hstsMaxAge = maxAge;
|
||||||
|
this.hstsIncludeSubDomains = includeSubDomains;
|
||||||
|
this.hstsPreload = preload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables {@code Strict-Transport-Security}.
|
||||||
|
*
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder noHsts() {
|
||||||
|
this.hstsMaxAge = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an arbitrary additional response header (for example {@code Permissions-Policy} or
|
||||||
|
* {@code Cross-Origin-Opener-Policy}). Like the built-in headers it is only applied when the
|
||||||
|
* handler has not already set it.
|
||||||
|
*
|
||||||
|
* @param name the header name
|
||||||
|
* @param value the header value
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder header(String name, String value) {
|
||||||
|
Objects.requireNonNull(name, "name");
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
this.custom.put(name, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the immutable {@link SecurityHeaders}.
|
||||||
|
*
|
||||||
|
* @return the configured instance
|
||||||
|
*/
|
||||||
|
public SecurityHeaders build() {
|
||||||
|
return new SecurityHeaders(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package dev.coph.nextusweb.server.tls;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBufAllocator;
|
||||||
|
import io.netty.handler.ssl.SslContext;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
import io.netty.handler.ssl.SslHandler;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLException;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration that enables TLS (HTTPS / WSS) on the server. Holds a ready-built Netty
|
||||||
|
* {@link SslContext} and produces the per-connection {@link SslHandler} the pipeline installs as
|
||||||
|
* its first handler.
|
||||||
|
*
|
||||||
|
* <p>Enabling TLS is meant to be a one-liner — point the factory at a PEM certificate chain and
|
||||||
|
* private key and pass the result to {@code HttpServer.withTls(...)}:</p>
|
||||||
|
* <pre>{@code
|
||||||
|
* HttpServer.builder(443, router)
|
||||||
|
* .withTls(TlsConfig.fromPem(new File("fullchain.pem"), new File("privkey.pem")))
|
||||||
|
* .start();
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>For full control (custom cipher suites, client-auth / mutual TLS, a non-default provider)
|
||||||
|
* build a Netty {@link SslContext} yourself and wrap it with {@link #fromSslContext(SslContext)}.
|
||||||
|
* The {@code SslContext} is built once and reused for every connection, so it is cheap per
|
||||||
|
* request.</p>
|
||||||
|
*/
|
||||||
|
public final class TlsConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre-built, shareable server SSL context.
|
||||||
|
*/
|
||||||
|
private final SslContext sslContext;
|
||||||
|
|
||||||
|
private TlsConfig(SslContext sslContext) {
|
||||||
|
this.sslContext = Objects.requireNonNull(sslContext, "sslContext");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a TLS configuration from a PEM-encoded certificate chain and an unencrypted
|
||||||
|
* PKCS#8 private key.
|
||||||
|
*
|
||||||
|
* @param certificateChain the PEM file containing the certificate (chain)
|
||||||
|
* @param privateKey the PEM file containing the PKCS#8 private key
|
||||||
|
* @return a TLS configuration
|
||||||
|
* @throws IllegalStateException if the certificate/key cannot be loaded
|
||||||
|
*/
|
||||||
|
public static TlsConfig fromPem(File certificateChain, File privateKey) {
|
||||||
|
return fromPem(certificateChain, privateKey, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a TLS configuration from a PEM-encoded certificate chain and a (optionally
|
||||||
|
* password-protected) PKCS#8 private key.
|
||||||
|
*
|
||||||
|
* @param certificateChain the PEM file containing the certificate (chain)
|
||||||
|
* @param privateKey the PEM file containing the PKCS#8 private key
|
||||||
|
* @param keyPassword the password protecting {@code privateKey}, or {@code null} if none
|
||||||
|
* @return a TLS configuration
|
||||||
|
* @throws IllegalStateException if the certificate/key cannot be loaded
|
||||||
|
*/
|
||||||
|
public static TlsConfig fromPem(File certificateChain, File privateKey, String keyPassword) {
|
||||||
|
Objects.requireNonNull(certificateChain, "certificateChain");
|
||||||
|
Objects.requireNonNull(privateKey, "privateKey");
|
||||||
|
try {
|
||||||
|
return new TlsConfig(SslContextBuilder.forServer(certificateChain, privateKey, keyPassword).build());
|
||||||
|
} catch (SSLException | RuntimeException e) {
|
||||||
|
throw new IllegalStateException("Failed to initialise TLS from PEM files", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a TLS configuration from PEM-encoded streams, for certificates/keys loaded from the
|
||||||
|
* classpath or another non-file source. The caller retains ownership of the streams.
|
||||||
|
*
|
||||||
|
* @param certificateChain a stream of the PEM certificate (chain)
|
||||||
|
* @param privateKey a stream of the PEM PKCS#8 private key
|
||||||
|
* @param keyPassword the password protecting {@code privateKey}, or {@code null} if none
|
||||||
|
* @return a TLS configuration
|
||||||
|
* @throws IllegalStateException if the certificate/key cannot be loaded
|
||||||
|
*/
|
||||||
|
public static TlsConfig fromPem(InputStream certificateChain, InputStream privateKey, String keyPassword) {
|
||||||
|
Objects.requireNonNull(certificateChain, "certificateChain");
|
||||||
|
Objects.requireNonNull(privateKey, "privateKey");
|
||||||
|
try {
|
||||||
|
return new TlsConfig(SslContextBuilder.forServer(certificateChain, privateKey, keyPassword).build());
|
||||||
|
} catch (SSLException | RuntimeException e) {
|
||||||
|
throw new IllegalStateException("Failed to initialise TLS from PEM streams", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a fully configured Netty {@link SslContext}, for advanced setups such as custom cipher
|
||||||
|
* suites or mutual TLS.
|
||||||
|
*
|
||||||
|
* @param sslContext a server-mode SSL context
|
||||||
|
* @return a TLS configuration backed by the given context
|
||||||
|
*/
|
||||||
|
public static TlsConfig fromSslContext(SslContext sslContext) {
|
||||||
|
return new TlsConfig(sslContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new per-connection {@link SslHandler} from the shared context.
|
||||||
|
*
|
||||||
|
* @param alloc the channel's buffer allocator
|
||||||
|
* @return a fresh TLS handler for one connection
|
||||||
|
*/
|
||||||
|
public SslHandler newHandler(ByteBufAllocator alloc) {
|
||||||
|
return sslContext.newHandler(alloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying Netty SSL context.
|
||||||
|
*
|
||||||
|
* @return the shared server SSL context
|
||||||
|
*/
|
||||||
|
public SslContext sslContext() {
|
||||||
|
return sslContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable configuration for the WebSocket subsystem: frame and message size limits, idle
|
||||||
|
* timeout, allowed origins, negotiated subprotocols, and compression. Instances are created
|
||||||
|
* through the nested {@link Builder}.
|
||||||
|
*
|
||||||
|
* <p>The values configured here govern how {@code HttpRequestHandler} sets up the WebSocket
|
||||||
|
* portion of the Netty pipeline during the upgrade handshake.</p>
|
||||||
|
*/
|
||||||
|
public final class WebSocketConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum size, in bytes, of a single WebSocket frame payload.
|
||||||
|
*/
|
||||||
|
private final int maxFramePayloadLength;
|
||||||
|
/**
|
||||||
|
* Maximum size, in bytes, of an aggregated (multi-frame) message.
|
||||||
|
*/
|
||||||
|
private final int maxAggregatedMessageSize;
|
||||||
|
/**
|
||||||
|
* Idle timeout after which an inactive connection is closed; {@code null} disables it.
|
||||||
|
*/
|
||||||
|
private final Duration idleTimeout;
|
||||||
|
/**
|
||||||
|
* Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}.
|
||||||
|
*/
|
||||||
|
private final Set<String> allowedOrigins;
|
||||||
|
/**
|
||||||
|
* Whether connections from any origin are accepted.
|
||||||
|
*/
|
||||||
|
private final boolean allowAnyOrigin;
|
||||||
|
/**
|
||||||
|
* Subprotocols offered during negotiation.
|
||||||
|
*/
|
||||||
|
private final Set<String> subprotocols;
|
||||||
|
/**
|
||||||
|
* Whether per-message deflate compression is enabled.
|
||||||
|
*/
|
||||||
|
private final boolean compression;
|
||||||
|
/**
|
||||||
|
* Whether the protocol handler matches the path by prefix rather than exact equality.
|
||||||
|
*/
|
||||||
|
private final boolean checkStartsWith;
|
||||||
|
/**
|
||||||
|
* Max in-flight callbacks queued per connection before read backpressure kicks in.
|
||||||
|
*/
|
||||||
|
private final int maxQueuedMessages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an immutable configuration from a {@link Builder}, defensively copying its sets.
|
||||||
|
*
|
||||||
|
* @param b the builder carrying the configured values
|
||||||
|
*/
|
||||||
|
private WebSocketConfig(Builder b) {
|
||||||
|
this.maxFramePayloadLength = b.maxFramePayloadLength;
|
||||||
|
this.maxAggregatedMessageSize = b.maxAggregatedMessageSize;
|
||||||
|
this.idleTimeout = b.idleTimeout;
|
||||||
|
this.allowedOrigins = Set.copyOf(b.allowedOrigins);
|
||||||
|
this.allowAnyOrigin = b.allowAnyOrigin;
|
||||||
|
this.subprotocols = Set.copyOf(b.subprotocols);
|
||||||
|
this.compression = b.compression;
|
||||||
|
this.checkStartsWith = b.checkStartsWith;
|
||||||
|
this.maxQueuedMessages = b.maxQueuedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a configuration with all default values.
|
||||||
|
*
|
||||||
|
* @return a default configuration
|
||||||
|
*/
|
||||||
|
public static WebSocketConfig defaults() {
|
||||||
|
return builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new, empty {@link Builder}.
|
||||||
|
*
|
||||||
|
* @return a fresh builder
|
||||||
|
*/
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether a WebSocket upgrade from the given origin is permitted.
|
||||||
|
*
|
||||||
|
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||||
|
* @return {@code true} if any origin is allowed, or if the origin is in the allow-list;
|
||||||
|
* {@code false} for a {@code null} or disallowed origin
|
||||||
|
*/
|
||||||
|
public boolean isOriginAllowed(String origin) {
|
||||||
|
if (allowAnyOrigin) return true;
|
||||||
|
if (origin == null) return false;
|
||||||
|
return allowedOrigins.contains(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum size of a single WebSocket frame payload.
|
||||||
|
*
|
||||||
|
* @return the maximum single-frame payload size in bytes
|
||||||
|
*/
|
||||||
|
public int maxFramePayloadLength() {
|
||||||
|
return maxFramePayloadLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum size of an aggregated (multi-frame) message.
|
||||||
|
*
|
||||||
|
* @return the maximum aggregated message size in bytes
|
||||||
|
*/
|
||||||
|
public int maxAggregatedMessageSize() {
|
||||||
|
return maxAggregatedMessageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the idle timeout after which inactive connections are closed.
|
||||||
|
*
|
||||||
|
* @return the idle timeout, or {@code null} if idle connections are never closed
|
||||||
|
*/
|
||||||
|
public Duration idleTimeout() {
|
||||||
|
return idleTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether connections from any origin are accepted.
|
||||||
|
*
|
||||||
|
* @return {@code true} if connections from any origin are accepted
|
||||||
|
*/
|
||||||
|
public boolean allowAnyOrigin() {
|
||||||
|
return allowAnyOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the explicitly allowed origins.
|
||||||
|
*
|
||||||
|
* @return the immutable set of explicitly allowed origins
|
||||||
|
*/
|
||||||
|
public Set<String> allowedOrigins() {
|
||||||
|
return allowedOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured subprotocols as a comma-separated string suitable for Netty's
|
||||||
|
* protocol config.
|
||||||
|
*
|
||||||
|
* @return the comma-separated subprotocol list, or {@code null} if none are configured
|
||||||
|
*/
|
||||||
|
public String subprotocolsCsv() {
|
||||||
|
if (subprotocols.isEmpty()) return null;
|
||||||
|
return String.join(",", subprotocols);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether per-message deflate compression is enabled.
|
||||||
|
*
|
||||||
|
* @return {@code true} if per-message compression is enabled
|
||||||
|
*/
|
||||||
|
public boolean compression() {
|
||||||
|
return compression;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the WebSocket path is matched by prefix rather than exact equality.
|
||||||
|
*
|
||||||
|
* @return {@code true} if the WebSocket path is matched by prefix rather than exactly
|
||||||
|
*/
|
||||||
|
public boolean checkStartsWith() {
|
||||||
|
return checkStartsWith;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum number of in-flight callbacks that may be queued per connection before
|
||||||
|
* the framework stops reading further frames (backpressure), protecting the server from a
|
||||||
|
* client that floods messages faster than the handler consumes them.
|
||||||
|
*
|
||||||
|
* @return the per-connection queued-message high-watermark
|
||||||
|
*/
|
||||||
|
public int maxQueuedMessages() {
|
||||||
|
return maxQueuedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fluent builder for {@link WebSocketConfig}, pre-populated with sensible defaults: 64 KiB
|
||||||
|
* frames, 1 MiB aggregated messages, a 60-second idle timeout, no origin restriction
|
||||||
|
* list, compression enabled, and exact path matching.
|
||||||
|
*/
|
||||||
|
public static final class Builder {
|
||||||
|
/**
|
||||||
|
* Accumulated allowed origins (insertion-ordered).
|
||||||
|
*/
|
||||||
|
private final Set<String> allowedOrigins = new LinkedHashSet<>();
|
||||||
|
/**
|
||||||
|
* Accumulated subprotocols (insertion-ordered).
|
||||||
|
*/
|
||||||
|
private final Set<String> subprotocols = new LinkedHashSet<>();
|
||||||
|
/**
|
||||||
|
* Maximum single-frame payload size in bytes; defaults to 64 KiB.
|
||||||
|
*/
|
||||||
|
private int maxFramePayloadLength = 65_536;
|
||||||
|
/**
|
||||||
|
* Maximum aggregated message size in bytes; defaults to 1 MiB.
|
||||||
|
*/
|
||||||
|
private int maxAggregatedMessageSize = 1_048_576;
|
||||||
|
/**
|
||||||
|
* Idle timeout; defaults to 60 seconds.
|
||||||
|
*/
|
||||||
|
private Duration idleTimeout = Duration.ofSeconds(60);
|
||||||
|
/**
|
||||||
|
* Whether any origin is allowed; defaults to {@code false}.
|
||||||
|
*/
|
||||||
|
private boolean allowAnyOrigin = false;
|
||||||
|
/**
|
||||||
|
* Whether compression is enabled; defaults to {@code true}.
|
||||||
|
*/
|
||||||
|
private boolean compression = true;
|
||||||
|
/**
|
||||||
|
* Whether path matching uses a prefix check; defaults to {@code false}.
|
||||||
|
*/
|
||||||
|
private boolean checkStartsWith = false;
|
||||||
|
/**
|
||||||
|
* Per-connection queued-message high-watermark; defaults to 1024.
|
||||||
|
*/
|
||||||
|
private int maxQueuedMessages = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a builder pre-populated with the default configuration values described
|
||||||
|
* above. Obtain instances via {@link WebSocketConfig#builder()}.
|
||||||
|
*/
|
||||||
|
public Builder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the maximum single-frame payload size.
|
||||||
|
*
|
||||||
|
* @param bytes the limit in bytes; must be positive
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
* @throws IllegalArgumentException if {@code bytes <= 0}
|
||||||
|
*/
|
||||||
|
public Builder maxFramePayloadLength(int bytes) {
|
||||||
|
if (bytes <= 0) throw new IllegalArgumentException("maxFramePayloadLength must be > 0");
|
||||||
|
this.maxFramePayloadLength = bytes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the maximum aggregated message size.
|
||||||
|
*
|
||||||
|
* @param bytes the limit in bytes; must be positive
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
* @throws IllegalArgumentException if {@code bytes <= 0}
|
||||||
|
*/
|
||||||
|
public Builder maxAggregatedMessageSize(int bytes) {
|
||||||
|
if (bytes <= 0) throw new IllegalArgumentException("maxAggregatedMessageSize must be > 0");
|
||||||
|
this.maxAggregatedMessageSize = bytes;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the idle timeout after which inactive connections are closed.
|
||||||
|
*
|
||||||
|
* @param timeout the idle timeout
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder idleTimeout(Duration timeout) {
|
||||||
|
this.idleTimeout = timeout;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disables the idle timeout, so connections are never closed for inactivity.
|
||||||
|
*
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder noIdleTimeout() {
|
||||||
|
this.idleTimeout = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more origins to the allow-list.
|
||||||
|
*
|
||||||
|
* @param origins the origins to allow
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder allowedOrigins(String... origins) {
|
||||||
|
Collections.addAll(this.allowedOrigins, origins);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows WebSocket connections from any origin.
|
||||||
|
*
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder anyOrigin() {
|
||||||
|
this.allowAnyOrigin = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more subprotocols to offer during negotiation.
|
||||||
|
*
|
||||||
|
* @param protocols the subprotocol names
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder subprotocols(String... protocols) {
|
||||||
|
Collections.addAll(this.subprotocols, protocols);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables per-message compression.
|
||||||
|
*
|
||||||
|
* @param enabled {@code true} to enable compression
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder compression(boolean enabled) {
|
||||||
|
this.compression = enabled;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the WebSocket path is matched by prefix rather than exact equality.
|
||||||
|
*
|
||||||
|
* @param v {@code true} to match by prefix
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
*/
|
||||||
|
public Builder checkStartsWith(boolean v) {
|
||||||
|
this.checkStartsWith = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the per-connection queued-message high-watermark for backpressure.
|
||||||
|
*
|
||||||
|
* @param messages the maximum queued callbacks before reads are paused; must be positive
|
||||||
|
* @return this builder, for fluent chaining
|
||||||
|
* @throws IllegalArgumentException if {@code messages <= 0}
|
||||||
|
*/
|
||||||
|
public Builder maxQueuedMessages(int messages) {
|
||||||
|
if (messages <= 0) throw new IllegalArgumentException("maxQueuedMessages must be > 0");
|
||||||
|
this.maxQueuedMessages = messages;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the immutable {@link WebSocketConfig}.
|
||||||
|
*
|
||||||
|
* @return the configured instance
|
||||||
|
*/
|
||||||
|
public WebSocketConfig build() {
|
||||||
|
return new WebSocketConfig(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import io.netty.channel.ChannelHandlerContext;
|
||||||
|
import io.netty.channel.SimpleChannelInboundHandler;
|
||||||
|
import io.netty.handler.codec.http.websocketx.*;
|
||||||
|
import io.netty.handler.timeout.IdleStateEvent;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Netty channel handler that bridges low-level WebSocket frames to the high-level
|
||||||
|
* {@link WebSocketHandler} callbacks.
|
||||||
|
*
|
||||||
|
* <p>It creates a {@link WebSocketSession} when the handshake completes, then translates each
|
||||||
|
* incoming frame into the matching callback ({@code onMessage}, {@code onBinary},
|
||||||
|
* {@code onClose}). Callbacks run on virtual threads so application code may block without
|
||||||
|
* stalling the Netty event loop, but, crucially, all callbacks for a single connection are
|
||||||
|
* executed <strong>strictly in arrival order</strong> by a per-connection serial drainer rather
|
||||||
|
* than each on its own thread — message ordering within a connection is therefore guaranteed.</p>
|
||||||
|
*
|
||||||
|
* <p>The drainer's pending queue is bounded: once it reaches the configured high-watermark the
|
||||||
|
* handler disables the channel's auto-read (backpressure), so a client that floods messages faster
|
||||||
|
* than the handler consumes them cannot exhaust memory or spawn unbounded work; reading resumes
|
||||||
|
* once the backlog drains. Each handler instance serves exactly one connection, so this state is
|
||||||
|
* per-connection.</p>
|
||||||
|
*
|
||||||
|
* <p>This class is package-private; instances are created via
|
||||||
|
* {@link WebSocketFrameHandlerFactory}.</p>
|
||||||
|
*/
|
||||||
|
final class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executor running one virtual thread per drain task.
|
||||||
|
*/
|
||||||
|
private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application handler receiving lifecycle callbacks.
|
||||||
|
*/
|
||||||
|
private final WebSocketHandler handler;
|
||||||
|
/**
|
||||||
|
* The path the connection was established on.
|
||||||
|
*/
|
||||||
|
private final String path;
|
||||||
|
/**
|
||||||
|
* Path parameters captured during routing, keyed by name.
|
||||||
|
*/
|
||||||
|
private final Map<String, String> pathParams;
|
||||||
|
/**
|
||||||
|
* Authenticated principal for the connection, or {@code null} if anonymous.
|
||||||
|
*/
|
||||||
|
private final Principal principal;
|
||||||
|
/**
|
||||||
|
* Queued-callback high-watermark at which reads are paused.
|
||||||
|
*/
|
||||||
|
private final int maxQueued;
|
||||||
|
/**
|
||||||
|
* Watermark at which reads resume after having been paused.
|
||||||
|
*/
|
||||||
|
private final int resumeQueued;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIFO of pending callbacks for this connection; drained by a single virtual thread.
|
||||||
|
*/
|
||||||
|
private final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();
|
||||||
|
/**
|
||||||
|
* Number of callbacks currently queued (drives the backpressure watermarks).
|
||||||
|
*/
|
||||||
|
private final AtomicInteger queued = new AtomicInteger();
|
||||||
|
/**
|
||||||
|
* Guards that at most one drainer runs at a time, preserving ordering.
|
||||||
|
*/
|
||||||
|
private final AtomicBoolean draining = new AtomicBoolean(false);
|
||||||
|
/**
|
||||||
|
* Whether reads are currently paused for backpressure.
|
||||||
|
*/
|
||||||
|
private volatile boolean readsPaused = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a frame handler bound to an application handler and connection metadata.
|
||||||
|
*
|
||||||
|
* @param handler the application handler to dispatch to
|
||||||
|
* @param path the connection path
|
||||||
|
* @param pathParams the captured path parameters
|
||||||
|
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||||
|
* @param maxQueued the per-connection queued-callback high-watermark for backpressure
|
||||||
|
*/
|
||||||
|
WebSocketFrameHandler(WebSocketHandler handler, String path, Map<String, String> pathParams,
|
||||||
|
Principal principal, int maxQueued) {
|
||||||
|
this.handler = handler;
|
||||||
|
this.path = path;
|
||||||
|
this.pathParams = pathParams;
|
||||||
|
this.principal = principal;
|
||||||
|
this.maxQueued = Math.max(1, maxQueued);
|
||||||
|
this.resumeQueued = Math.max(1, this.maxQueued / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches an incoming frame to the appropriate handler callback. Text, binary and close
|
||||||
|
* frames are forwarded to {@code onMessage}, {@code onBinary} and {@code onClose}
|
||||||
|
* respectively, in arrival order via the per-connection serial drainer. Frame payloads are
|
||||||
|
* copied eagerly here (on the event loop) before the frame buffer is recycled. Frames arriving
|
||||||
|
* before the session exists are ignored.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param frame the received WebSocket frame
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
|
||||||
|
WebSocketSession session = ctx.channel().attr(WebSocketSession.SESSION_KEY).get();
|
||||||
|
if (session == null) return;
|
||||||
|
|
||||||
|
if (frame instanceof TextWebSocketFrame text) {
|
||||||
|
String content = text.text();
|
||||||
|
submit(ctx, () -> {
|
||||||
|
try {
|
||||||
|
handler.onMessage(session, content);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
safeError(session, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (frame instanceof BinaryWebSocketFrame bin) {
|
||||||
|
int readable = bin.content().readableBytes();
|
||||||
|
byte[] data = new byte[readable];
|
||||||
|
bin.content().getBytes(bin.content().readerIndex(), data);
|
||||||
|
submit(ctx, () -> {
|
||||||
|
try {
|
||||||
|
handler.onBinary(session, data);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
safeError(session, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (frame instanceof CloseWebSocketFrame close) {
|
||||||
|
int code = close.statusCode();
|
||||||
|
String reason = close.reasonText() == null ? "" : close.reasonText();
|
||||||
|
submit(ctx, () -> {
|
||||||
|
try {
|
||||||
|
handler.onClose(session, code, reason);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
safeError(session, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues a callback for ordered execution and ensures a drainer is running. Applies read
|
||||||
|
* backpressure when the queue reaches its high-watermark.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param task the callback to run in order
|
||||||
|
*/
|
||||||
|
private void submit(ChannelHandlerContext ctx, Runnable task) {
|
||||||
|
tasks.add(task);
|
||||||
|
if (queued.incrementAndGet() >= maxQueued && !readsPaused) {
|
||||||
|
readsPaused = true;
|
||||||
|
ctx.channel().config().setAutoRead(false);
|
||||||
|
}
|
||||||
|
if (draining.compareAndSet(false, true)) {
|
||||||
|
VT_EXECUTOR.execute(() -> drain(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drains and runs queued callbacks one at a time (preserving order) until the queue is empty,
|
||||||
|
* resuming reads once the backlog falls back below the low-watermark. The single-drainer
|
||||||
|
* invariant is upheld by {@link #draining}; a final re-check avoids a lost wake-up if a task
|
||||||
|
* was enqueued just as the drainer was finishing.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
*/
|
||||||
|
private void drain(ChannelHandlerContext ctx) {
|
||||||
|
try {
|
||||||
|
Runnable task;
|
||||||
|
while ((task = tasks.poll()) != null) {
|
||||||
|
try {
|
||||||
|
task.run();
|
||||||
|
} finally {
|
||||||
|
int remaining = queued.decrementAndGet();
|
||||||
|
if (readsPaused && remaining <= resumeQueued) {
|
||||||
|
readsPaused = false;
|
||||||
|
if (ctx.channel().isActive()) {
|
||||||
|
ctx.channel().config().setAutoRead(true);
|
||||||
|
ctx.read();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining.set(false);
|
||||||
|
if (!tasks.isEmpty() && draining.compareAndSet(false, true)) {
|
||||||
|
VT_EXECUTOR.execute(() -> drain(ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when the channel goes inactive (the connection dropped without a clean close
|
||||||
|
* handshake). Clears the stored session and dispatches {@link WebSocketHandler#onClose} with
|
||||||
|
* the abnormal-closure code {@code 1006}, ordered after any still-queued callbacks.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void channelInactive(ChannelHandlerContext ctx) {
|
||||||
|
WebSocketSession session = ctx.channel().attr(WebSocketSession.SESSION_KEY).getAndSet(null);
|
||||||
|
if (session == null) return;
|
||||||
|
submit(ctx, () -> {
|
||||||
|
try {
|
||||||
|
handler.onClose(session, 1006, "Connection closed");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
safeError(session, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles pipeline user events. On handshake completion it creates and stores the
|
||||||
|
* {@link WebSocketSession} and dispatches {@link WebSocketHandler#onOpen}; on an idle-state
|
||||||
|
* event it closes the channel; other events are passed up the pipeline.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param evt the user event
|
||||||
|
* @throws Exception if the superclass handling of an unrecognized event fails
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
|
||||||
|
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
|
||||||
|
WebSocketSession session = new WebSocketSession(ctx.channel(), path, pathParams, principal);
|
||||||
|
ctx.channel().attr(WebSocketSession.SESSION_KEY).set(session);
|
||||||
|
submit(ctx, () -> {
|
||||||
|
try {
|
||||||
|
handler.onOpen(session);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
safeError(session, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (evt instanceof IdleStateEvent) {
|
||||||
|
ctx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.userEventTriggered(ctx, evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a pipeline exception to {@link WebSocketHandler#onError} (when a session exists)
|
||||||
|
* and then closes the channel.
|
||||||
|
*
|
||||||
|
* @param ctx the channel context
|
||||||
|
* @param cause the exception that propagated up the pipeline
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
|
||||||
|
WebSocketSession session = ctx.channel().attr(WebSocketSession.SESSION_KEY).get();
|
||||||
|
if (session != null) safeError(session, cause);
|
||||||
|
ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes {@link WebSocketHandler#onError} while swallowing any secondary exception the
|
||||||
|
* error callback itself might throw, so error handling can never cascade.
|
||||||
|
*
|
||||||
|
* @param session the affected session
|
||||||
|
* @param cause the original error to report
|
||||||
|
*/
|
||||||
|
private void safeError(WebSocketSession session, Throwable cause) {
|
||||||
|
try {
|
||||||
|
handler.onError(session, cause);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import io.netty.channel.ChannelHandler;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small factory that creates the package-private {@code WebSocketFrameHandler} channel handler.
|
||||||
|
*
|
||||||
|
* <p>It exists so that other packages (notably {@code HttpRequestHandler} during the upgrade
|
||||||
|
* handshake) can insert a frame handler into the pipeline without the handler class itself
|
||||||
|
* having to be public. The class is a stateless utility and cannot be instantiated.</p>
|
||||||
|
*/
|
||||||
|
public final class WebSocketFrameHandlerFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default per-connection queued-message high-watermark when none is supplied.
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_MAX_QUEUED = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor preventing instantiation of this stateless utility class.
|
||||||
|
*/
|
||||||
|
private WebSocketFrameHandlerFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel handler that bridges Netty WebSocket frames to the given application
|
||||||
|
* {@link WebSocketHandler}, using the default backpressure watermark and no principal.
|
||||||
|
*
|
||||||
|
* @param handler the application handler to dispatch lifecycle events to
|
||||||
|
* @param path the path the connection was established on
|
||||||
|
* @param pathParams the path parameters captured during routing
|
||||||
|
* @return a new channel handler ready to be inserted into the pipeline
|
||||||
|
*/
|
||||||
|
public static ChannelHandler create(WebSocketHandler handler, String path,
|
||||||
|
Map<String, String> pathParams) {
|
||||||
|
return create(handler, path, pathParams, null, DEFAULT_MAX_QUEUED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel handler with an authenticated principal and an explicit backpressure
|
||||||
|
* watermark.
|
||||||
|
*
|
||||||
|
* @param handler the application handler to dispatch lifecycle events to
|
||||||
|
* @param path the path the connection was established on
|
||||||
|
* @param pathParams the path parameters captured during routing
|
||||||
|
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||||
|
* @param maxQueued the per-connection queued-message high-watermark before reads are paused
|
||||||
|
* @return a new channel handler ready to be inserted into the pipeline
|
||||||
|
*/
|
||||||
|
public static ChannelHandler create(WebSocketHandler handler, String path,
|
||||||
|
Map<String, String> pathParams, Principal principal,
|
||||||
|
int maxQueued) {
|
||||||
|
return new WebSocketFrameHandler(handler, path, pathParams, principal, maxQueued);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a channel handler with an authenticated principal and the default backpressure
|
||||||
|
* watermark.
|
||||||
|
*
|
||||||
|
* @param handler the application handler to dispatch lifecycle events to
|
||||||
|
* @param path the path the connection was established on
|
||||||
|
* @param pathParams the path parameters captured during routing
|
||||||
|
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||||
|
* @return a new channel handler ready to be inserted into the pipeline
|
||||||
|
*/
|
||||||
|
public static ChannelHandler create(WebSocketHandler handler, String path,
|
||||||
|
Map<String, String> pathParams, Principal principal) {
|
||||||
|
return create(handler, path, pathParams, principal, DEFAULT_MAX_QUEUED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.channel.group.ChannelGroup;
|
||||||
|
import io.netty.channel.group.DefaultChannelGroup;
|
||||||
|
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||||
|
import io.netty.util.concurrent.GlobalEventExecutor;
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named collection of WebSocket connections that supports broadcasting to all members at
|
||||||
|
* once — useful for chat rooms, pub/sub topics, presence channels and similar fan-out
|
||||||
|
* scenarios.
|
||||||
|
*
|
||||||
|
* <p>It is backed by a Netty {@link ChannelGroup}, which automatically removes channels as they
|
||||||
|
* close, so callers do not need to prune disconnected sessions manually. The group is
|
||||||
|
* thread-safe.</p>
|
||||||
|
*/
|
||||||
|
public final class WebSocketGroup {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Underlying Netty channel group holding the member connections.
|
||||||
|
*/
|
||||||
|
private final ChannelGroup channels;
|
||||||
|
/**
|
||||||
|
* Human-readable name of this group.
|
||||||
|
*/
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an unnamed group (named {@code "anonymous"}).
|
||||||
|
*/
|
||||||
|
public WebSocketGroup() {
|
||||||
|
this("anonymous");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a named group.
|
||||||
|
*
|
||||||
|
* @param name the group name
|
||||||
|
*/
|
||||||
|
public WebSocketGroup(String name) {
|
||||||
|
this.name = name;
|
||||||
|
this.channels = new DefaultChannelGroup(name, GlobalEventExecutor.INSTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of this group.
|
||||||
|
*
|
||||||
|
* @return the group name
|
||||||
|
*/
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a session to the group.
|
||||||
|
*
|
||||||
|
* @param session the session to add
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup add(WebSocketSession session) {
|
||||||
|
channels.add(session.channel());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a session from the group.
|
||||||
|
*
|
||||||
|
* @param session the session to remove
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup remove(WebSocketSession session) {
|
||||||
|
channels.remove(session.channel());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns how many connections are currently in the group.
|
||||||
|
*
|
||||||
|
* @return the current number of member connections
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return channels.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts a text message to every member of the group.
|
||||||
|
*
|
||||||
|
* @param text the text to send
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup broadcast(String text) {
|
||||||
|
channels.writeAndFlush(new TextWebSocketFrame(text));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the given value to JSON and broadcasts it as a text message to every member.
|
||||||
|
*
|
||||||
|
* @param value the object to serialize and broadcast
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
* @throws RuntimeException if JSON serialization fails
|
||||||
|
*/
|
||||||
|
public WebSocketGroup broadcastJson(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(value);
|
||||||
|
channels.writeAndFlush(new TextWebSocketFrame(Unpooled.wrappedBuffer(bytes)));
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new RuntimeException("JSON serialization failed", e);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts a binary message to every active member, allocating a fresh buffer per channel.
|
||||||
|
*
|
||||||
|
* @param data the bytes to broadcast
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup broadcastBinary(byte[] data) {
|
||||||
|
for (var ch : channels) {
|
||||||
|
if (ch.isActive()) {
|
||||||
|
var buf = ch.alloc().buffer(data.length).writeBytes(data);
|
||||||
|
ch.writeAndFlush(new BinaryWebSocketFrame(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts a text message to every active member except one — typically the sender,
|
||||||
|
* so a client does not receive its own message echoed back.
|
||||||
|
*
|
||||||
|
* @param exclude the session to skip, or {@code null} to broadcast to everyone
|
||||||
|
* @param text the text to send
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup broadcastExcept(WebSocketSession exclude, String text) {
|
||||||
|
var excludeCh = exclude == null ? null : exclude.channel();
|
||||||
|
for (var ch : channels) {
|
||||||
|
if (ch.isActive() && ch != excludeCh) {
|
||||||
|
ch.writeAndFlush(new TextWebSocketFrame(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes every connection in the group.
|
||||||
|
*
|
||||||
|
* @return this group, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketGroup closeAll() {
|
||||||
|
channels.close();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-facing callback interface for a WebSocket endpoint. Implementations react to the
|
||||||
|
* lifecycle events of a single connection: opening, incoming text and binary messages, closing,
|
||||||
|
* and errors.
|
||||||
|
*
|
||||||
|
* <p>Every method has an empty default implementation, so handlers need only override the
|
||||||
|
* events they care about. Callbacks are dispatched on virtual threads by the framework, so they
|
||||||
|
* may perform blocking work, and they are allowed to throw — any thrown exception is
|
||||||
|
* routed to {@link #onError(WebSocketSession, Throwable)}.</p>
|
||||||
|
*
|
||||||
|
* @see WebSocketSession
|
||||||
|
* @see WebSocketRouter
|
||||||
|
*/
|
||||||
|
public interface WebSocketHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked once the WebSocket handshake has completed and the session is ready for use.
|
||||||
|
*
|
||||||
|
* @param session the newly opened session
|
||||||
|
* @throws Exception if the handler fails; routed to {@link #onError}
|
||||||
|
*/
|
||||||
|
default void onOpen(WebSocketSession session) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when a text message is received.
|
||||||
|
*
|
||||||
|
* @param session the session the message arrived on
|
||||||
|
* @param message the decoded text payload
|
||||||
|
* @throws Exception if the handler fails; routed to {@link #onError}
|
||||||
|
*/
|
||||||
|
default void onMessage(WebSocketSession session, String message) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when a binary message is received.
|
||||||
|
*
|
||||||
|
* @param session the session the message arrived on
|
||||||
|
* @param data the raw binary payload
|
||||||
|
* @throws Exception if the handler fails; routed to {@link #onError}
|
||||||
|
*/
|
||||||
|
default void onBinary(WebSocketSession session, byte[] data) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when the connection closes, whether initiated by the peer or the server.
|
||||||
|
*
|
||||||
|
* @param session the session being closed
|
||||||
|
* @param code the WebSocket close status code
|
||||||
|
* @param reason the close reason text (empty if none was provided)
|
||||||
|
* @throws Exception if the handler fails; routed to {@link #onError}
|
||||||
|
*/
|
||||||
|
default void onClose(WebSocketSession session, int code, String reason) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when an error occurs on the connection or when another callback throws.
|
||||||
|
*
|
||||||
|
* @param session the affected session
|
||||||
|
* @param cause the error that occurred
|
||||||
|
* @throws Exception if the error handler itself fails (such failures are swallowed)
|
||||||
|
*/
|
||||||
|
default void onError(WebSocketSession session, Throwable cause) throws Exception {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trie-based router that maps WebSocket upgrade paths to {@link WebSocketHandler}s.
|
||||||
|
*
|
||||||
|
* <p>It mirrors the HTTP {@link dev.coph.nextusweb.server.router.Router Router} but is simpler:
|
||||||
|
* a path resolves to a single handler (there is no HTTP method dimension) and only static and
|
||||||
|
* {@code {param}} path-parameter segments are supported (no wildcards). Registration mutates the
|
||||||
|
* shared trie at start-up; {@link #resolve(String)} is safe to call concurrently afterwards.</p>
|
||||||
|
*/
|
||||||
|
public final class WebSocketRouter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root of the routing trie.
|
||||||
|
*/
|
||||||
|
private final Node root = new Node();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an empty WebSocket router with no registered handlers.
|
||||||
|
*/
|
||||||
|
public WebSocketRouter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler at the given path, creating any missing trie nodes. Segments wrapped
|
||||||
|
* in braces (e.g. {@code /chat/{room}}) are treated as path parameters.
|
||||||
|
*
|
||||||
|
* @param path the WebSocket path to mount the handler at
|
||||||
|
* @param handler the handler to invoke for connections on that path
|
||||||
|
* @return this router, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketRouter on(String path, WebSocketHandler handler) {
|
||||||
|
Node node = root;
|
||||||
|
for (String segment : split(path)) {
|
||||||
|
if (segment.startsWith("{") && segment.endsWith("}")) {
|
||||||
|
if (node.paramChild == null) {
|
||||||
|
node.paramChild = new Node();
|
||||||
|
node.paramName = segment.substring(1, segment.length() - 1);
|
||||||
|
}
|
||||||
|
node = node.paramChild;
|
||||||
|
} else {
|
||||||
|
node = node.children.computeIfAbsent(segment, k -> new Node());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.handler = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a path into its non-empty segments, ignoring leading and collapsing internal
|
||||||
|
* slashes.
|
||||||
|
*
|
||||||
|
* @param path the raw path
|
||||||
|
* @return the ordered list of path segments
|
||||||
|
*/
|
||||||
|
private static List<String> split(String path) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
int start = path.startsWith("/") ? 1 : 0;
|
||||||
|
for (int i = start; i < path.length(); i++) {
|
||||||
|
if (path.charAt(i) == '/') {
|
||||||
|
if (i > start) out.add(path.substring(start, i));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < path.length()) out.add(path.substring(start));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a path to its handler, capturing any path parameters along the way.
|
||||||
|
*
|
||||||
|
* @param path the request path
|
||||||
|
* @return a {@link Resolution} carrying the handler and captured parameters, or {@code null}
|
||||||
|
* if no handler is registered for the path
|
||||||
|
*/
|
||||||
|
public Resolution resolve(String path) {
|
||||||
|
Map<String, String> params = new HashMap<>(4);
|
||||||
|
Node node = root;
|
||||||
|
for (String segment : split(path)) {
|
||||||
|
Node next = node.children.get(segment);
|
||||||
|
if (next != null) {
|
||||||
|
node = next;
|
||||||
|
} else if (node.paramChild != null) {
|
||||||
|
params.put(node.paramName, segment);
|
||||||
|
node = node.paramChild;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.handler == null) return null;
|
||||||
|
return new Resolution(node.handler, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A successful path resolution.
|
||||||
|
*
|
||||||
|
* @param handler the handler bound to the matched path
|
||||||
|
* @param pathParams the path parameters captured while matching, keyed by name
|
||||||
|
*/
|
||||||
|
public record Resolution(WebSocketHandler handler, Map<String, String> pathParams) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single node in the WebSocket routing trie. Holds static children keyed by segment, an
|
||||||
|
* optional path-parameter child, and the handler (if any) registered at this node.
|
||||||
|
*/
|
||||||
|
private static final class Node {
|
||||||
|
/**
|
||||||
|
* Static child nodes keyed by their literal path segment.
|
||||||
|
*/
|
||||||
|
final Map<String, Node> children = new ConcurrentHashMap<>();
|
||||||
|
/**
|
||||||
|
* Child matching any single segment as a path parameter, or {@code null} if none.
|
||||||
|
*/
|
||||||
|
Node paramChild;
|
||||||
|
/**
|
||||||
|
* Name under which {@link #paramChild} captures the matched segment.
|
||||||
|
*/
|
||||||
|
String paramName;
|
||||||
|
/**
|
||||||
|
* Handler registered at this node, or {@code null} if the path is only a prefix.
|
||||||
|
*/
|
||||||
|
WebSocketHandler handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.channel.Channel;
|
||||||
|
import io.netty.channel.ChannelFuture;
|
||||||
|
import io.netty.channel.ChannelFutureListener;
|
||||||
|
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||||
|
import io.netty.util.AttributeKey;
|
||||||
|
import io.netty.util.CharsetUtil;
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.SocketAddress;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single, live WebSocket connection and is the primary object application handlers
|
||||||
|
* interact with.
|
||||||
|
*
|
||||||
|
* <p>It wraps the underlying Netty {@link Channel} and offers convenient methods to send text,
|
||||||
|
* JSON and binary payloads, to ping the peer, and to close the connection. It also carries
|
||||||
|
* read-only connection metadata (a generated id, the path, and captured path parameters) and a
|
||||||
|
* thread-safe bag of arbitrary {@link #attribute(String, Object) attributes} that handlers can
|
||||||
|
* use to associate state with the connection.</p>
|
||||||
|
*
|
||||||
|
* <p>Each connection's session is stored on its channel under {@link #SESSION_KEY} so the frame
|
||||||
|
* handler can retrieve it for every incoming frame.</p>
|
||||||
|
*/
|
||||||
|
public final class WebSocketSession {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel attribute key under which the session is stored on its Netty channel.
|
||||||
|
*/
|
||||||
|
static final AttributeKey<WebSocketSession> SESSION_KEY =
|
||||||
|
AttributeKey.valueOf("nexusweb.ws.session");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The underlying Netty channel for this connection.
|
||||||
|
*/
|
||||||
|
private final Channel channel;
|
||||||
|
/**
|
||||||
|
* Unique identifier generated for this session.
|
||||||
|
*/
|
||||||
|
private final String id;
|
||||||
|
/**
|
||||||
|
* The path the connection was established on.
|
||||||
|
*/
|
||||||
|
private final String path;
|
||||||
|
/**
|
||||||
|
* Path parameters captured during routing, keyed by name.
|
||||||
|
*/
|
||||||
|
private final Map<String, String> pathParams;
|
||||||
|
/**
|
||||||
|
* Authenticated principal for this connection, or {@code null} if anonymous.
|
||||||
|
*/
|
||||||
|
private final Principal principal;
|
||||||
|
/**
|
||||||
|
* Thread-safe bag of user-defined attributes attached to the session.
|
||||||
|
*/
|
||||||
|
private final Map<String, Object> attributes = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a session for a freshly upgraded channel. Package-private; created by the frame
|
||||||
|
* handler once the handshake completes.
|
||||||
|
*
|
||||||
|
* @param channel the underlying Netty channel
|
||||||
|
* @param path the connection path
|
||||||
|
* @param pathParams the path parameters captured during routing
|
||||||
|
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||||
|
*/
|
||||||
|
WebSocketSession(Channel channel, String path, Map<String, String> pathParams, Principal principal) {
|
||||||
|
this.channel = channel;
|
||||||
|
this.id = UUID.randomUUID().toString();
|
||||||
|
this.path = path;
|
||||||
|
this.pathParams = pathParams;
|
||||||
|
this.principal = principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level helper that writes a text payload directly to a channel, allocating the buffer
|
||||||
|
* from the channel's allocator. Used by collaborators that hold a channel but not a session.
|
||||||
|
*
|
||||||
|
* @param channel the channel to write to
|
||||||
|
* @param text the text to send
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
*/
|
||||||
|
static ChannelFuture sendRaw(Channel channel, String text) {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
ByteBuf buf = channel.alloc().buffer();
|
||||||
|
buf.writeCharSequence(text, CharsetUtil.UTF_8);
|
||||||
|
return channel.writeAndFlush(new TextWebSocketFrame(true, 0, buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level helper that writes a binary payload directly to a channel.
|
||||||
|
*
|
||||||
|
* @param channel the channel to write to
|
||||||
|
* @param data the bytes to send
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
*/
|
||||||
|
static ChannelFuture sendRawBinary(Channel channel, byte[] data) {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(Unpooled.wrappedBuffer(data));
|
||||||
|
return channel.writeAndFlush(new BinaryWebSocketFrame(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier generated for this session.
|
||||||
|
*
|
||||||
|
* @return the unique session id
|
||||||
|
*/
|
||||||
|
public String id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path the connection was established on.
|
||||||
|
*
|
||||||
|
* @return the path the connection was established on
|
||||||
|
*/
|
||||||
|
public String path() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of a path parameter captured during routing.
|
||||||
|
*
|
||||||
|
* @param name the parameter name (without braces)
|
||||||
|
* @return the captured value, or {@code null} if there is no such parameter
|
||||||
|
*/
|
||||||
|
public String pathParam(String name) {
|
||||||
|
return pathParams.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the authenticated principal associated with this connection, established by the auth
|
||||||
|
* layer during the upgrade handshake.
|
||||||
|
*
|
||||||
|
* @return the principal, or {@code null} if the connection is anonymous
|
||||||
|
*/
|
||||||
|
public Principal principal() {
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the connection is still open.
|
||||||
|
*
|
||||||
|
* @return {@code true} if the underlying channel is still active (open)
|
||||||
|
*/
|
||||||
|
public boolean isOpen() {
|
||||||
|
return channel.isActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the peer's remote IP address.
|
||||||
|
*
|
||||||
|
* @return the remote host address, or a string form of the address if it is not an
|
||||||
|
* {@link InetSocketAddress}; {@code null} if unavailable
|
||||||
|
*/
|
||||||
|
public String remoteAddress() {
|
||||||
|
SocketAddress addr = channel.remoteAddress();
|
||||||
|
if (addr instanceof InetSocketAddress inet) {
|
||||||
|
return inet.getAddress().getHostAddress();
|
||||||
|
}
|
||||||
|
return addr == null ? null : addr.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying Netty channel for advanced, low-level use.
|
||||||
|
*
|
||||||
|
* @return the underlying Netty channel, for advanced use
|
||||||
|
*/
|
||||||
|
public Channel channel() {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associates a user-defined attribute with this session, or removes it when {@code value} is
|
||||||
|
* {@code null}.
|
||||||
|
*
|
||||||
|
* @param name the attribute name
|
||||||
|
* @param value the value to store, or {@code null} to remove the attribute
|
||||||
|
* @return this session, for fluent chaining
|
||||||
|
*/
|
||||||
|
public WebSocketSession attribute(String name, Object value) {
|
||||||
|
if (value == null) attributes.remove(name);
|
||||||
|
else attributes.put(name, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a previously stored attribute, cast to the caller's expected type.
|
||||||
|
*
|
||||||
|
* @param name the attribute name
|
||||||
|
* @param <T> the expected attribute type
|
||||||
|
* @return the stored value, or {@code null} if absent
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> T attribute(String name) {
|
||||||
|
return (T) attributes.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a text message to the peer.
|
||||||
|
*
|
||||||
|
* @param text the text to send
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
*/
|
||||||
|
public ChannelFuture send(String text) {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
return channel.writeAndFlush(new TextWebSocketFrame(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the given value to JSON and sends it as a text message.
|
||||||
|
*
|
||||||
|
* @param value the object to serialize and send
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
* @throws RuntimeException if JSON serialization fails
|
||||||
|
*/
|
||||||
|
public ChannelFuture sendJson(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(value);
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
ByteBuf buf = channel.alloc().buffer(bytes.length).writeBytes(bytes);
|
||||||
|
return channel.writeAndFlush(new TextWebSocketFrame(true, 0, buf));
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new RuntimeException("JSON serialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a binary message to the peer.
|
||||||
|
*
|
||||||
|
* @param data the bytes to send
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
*/
|
||||||
|
public ChannelFuture sendBinary(byte[] data) {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(data);
|
||||||
|
return channel.writeAndFlush(new BinaryWebSocketFrame(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a WebSocket ping frame to the peer (e.g. as a keep-alive).
|
||||||
|
*
|
||||||
|
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||||
|
* channel is no longer active
|
||||||
|
*/
|
||||||
|
public ChannelFuture ping() {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
return channel.writeAndFlush(new PingWebSocketFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the connection with the normal-closure status code {@code 1000} and no reason.
|
||||||
|
*
|
||||||
|
* @return a future completing when the close frame has been written
|
||||||
|
*/
|
||||||
|
public ChannelFuture close() {
|
||||||
|
return close(1000, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the connection with an explicit status code and reason, closing the channel once
|
||||||
|
* the close frame has been written.
|
||||||
|
*
|
||||||
|
* @param code the WebSocket close status code
|
||||||
|
* @param reason the human-readable close reason
|
||||||
|
* @return a future completing when the close frame has been written; an already-succeeded
|
||||||
|
* future if the channel is no longer active
|
||||||
|
*/
|
||||||
|
public ChannelFuture close(int code, String reason) {
|
||||||
|
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||||
|
return channel.writeAndFlush(new CloseWebSocketFrame(code, reason))
|
||||||
|
.addListener(ChannelFutureListener.CLOSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.coph.nextusweb.server;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.cores.CorsConfig;
|
||||||
|
import dev.coph.nextusweb.server.cores.CorsHandler;
|
||||||
|
import dev.coph.nextusweb.server.ratelimit.RateLimitConfig;
|
||||||
|
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
||||||
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketConfig;
|
||||||
|
import dev.coph.nextusweb.server.websocket.WebSocketRouter;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class HttpServerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void builderReturnsConfiguredServer() {
|
||||||
|
Router router = new Router();
|
||||||
|
HttpServer server = HttpServer.builder(0, router);
|
||||||
|
assertNotNull(server);
|
||||||
|
assertSame(server, server.withCorsHandler(new CorsHandler(CorsConfig.permissive())));
|
||||||
|
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build());
|
||||||
|
try {
|
||||||
|
assertSame(server, server.withRateLimitGate(gate));
|
||||||
|
assertSame(server, server.withWebSockets(new WebSocketRouter()));
|
||||||
|
assertSame(server, server.withWebSockets(new WebSocketRouter(), WebSocketConfig.defaults()));
|
||||||
|
} finally {
|
||||||
|
gate.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package dev.coph.nextusweb.server.annotation;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import dev.coph.nextusweb.server.router.Router;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AnnotationScannerTest {
|
||||||
|
|
||||||
|
@Controller("/api")
|
||||||
|
static class GoodController {
|
||||||
|
AtomicBoolean called = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
@GET("/hello")
|
||||||
|
public void hello(Request req, Response res) {
|
||||||
|
called.set(true);
|
||||||
|
res.text("hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST("post")
|
||||||
|
public void post(Request req, Response res) {}
|
||||||
|
|
||||||
|
@Route(method = "PUT", path = "/put")
|
||||||
|
public void put(Request req, Response res) {}
|
||||||
|
|
||||||
|
@CUSTOM(method = "OPTIONS", value = "/opt")
|
||||||
|
public void opt(Request req, Response res) {}
|
||||||
|
|
||||||
|
public void notAnnotated(Request req, Response res) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class NoControllerAnnotation {
|
||||||
|
@GET("/x")
|
||||||
|
public void x(Request req, Response res) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("nopref")
|
||||||
|
static class PrefixNoSlash {
|
||||||
|
@GET("/a")
|
||||||
|
public void a(Request req, Response res) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class BadSignature {
|
||||||
|
@GET("/bad")
|
||||||
|
public String bad(String s) { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static class WrongReturnType {
|
||||||
|
@GET("/bad")
|
||||||
|
public String bad(Request req, Response res) { return "no"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registersAllAnnotatedRoutesWithPrefix() {
|
||||||
|
Router router = new Router();
|
||||||
|
GoodController ctrl = new GoodController();
|
||||||
|
AnnotationScanner.register(router, ctrl);
|
||||||
|
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/api/hello"));
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.POST, "/api/post"));
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.PUT, "/api/put"));
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class,
|
||||||
|
router.resolve(HttpMethod.valueOf("OPTIONS"), "/api/opt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registrationWithoutControllerAnnotationUsesEmptyPrefix() {
|
||||||
|
Router router = new Router();
|
||||||
|
AnnotationScanner.register(router, new NoControllerAnnotation());
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prefixWithoutLeadingSlashIsNormalized() {
|
||||||
|
Router router = new Router();
|
||||||
|
AnnotationScanner.register(router, new PrefixNoSlash());
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/nopref/a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invokingRegisteredHandlerCallsTheControllerMethod() throws Exception {
|
||||||
|
Router router = new Router();
|
||||||
|
GoodController ctrl = new GoodController();
|
||||||
|
AnnotationScanner.register(router, ctrl);
|
||||||
|
|
||||||
|
Router.Resolution res = router.resolve(HttpMethod.GET, "/api/hello");
|
||||||
|
Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res);
|
||||||
|
|
||||||
|
Response resp = new Response();
|
||||||
|
m.handler().handle(null, resp);
|
||||||
|
assertTrue(ctrl.called.get());
|
||||||
|
assertEquals(200, resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badSignatureThrows() {
|
||||||
|
Router router = new Router();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> AnnotationScanner.register(router, new BadSignature()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongReturnTypeThrows() {
|
||||||
|
Router router = new Router();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> AnnotationScanner.register(router, new WrongReturnType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller("/users")
|
||||||
|
static class CrudController {
|
||||||
|
java.util.concurrent.atomic.AtomicInteger getCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||||
|
java.util.concurrent.atomic.AtomicInteger putCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||||
|
java.util.concurrent.atomic.AtomicInteger deleteCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||||
|
|
||||||
|
@GET("/")
|
||||||
|
public void list(Request req, Response res) { getCalls.incrementAndGet(); }
|
||||||
|
|
||||||
|
@PUT("/")
|
||||||
|
public void replace(Request req, Response res) { putCalls.incrementAndGet(); }
|
||||||
|
|
||||||
|
@DELETE("/")
|
||||||
|
public void wipe(Request req, Response res) { deleteCalls.incrementAndGet(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleMethodsOnSamePathRouteToDistinctHandlers() throws Exception {
|
||||||
|
Router router = new Router();
|
||||||
|
CrudController ctrl = new CrudController();
|
||||||
|
AnnotationScanner.register(router, ctrl);
|
||||||
|
|
||||||
|
var get = assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.GET, "/users/"));
|
||||||
|
var put = assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.PUT, "/users/"));
|
||||||
|
var del = assertInstanceOf(Router.Resolution.Match.class, router.resolve(HttpMethod.DELETE, "/users/"));
|
||||||
|
|
||||||
|
get.handler().handle(null, new Response());
|
||||||
|
put.handler().handle(null, new Response());
|
||||||
|
del.handler().handle(null, new Response());
|
||||||
|
|
||||||
|
assertEquals(1, ctrl.getCalls.get());
|
||||||
|
assertEquals(1, ctrl.putCalls.get());
|
||||||
|
assertEquals(1, ctrl.deleteCalls.get());
|
||||||
|
|
||||||
|
var post = router.resolve(HttpMethod.POST, "/users/");
|
||||||
|
var mna = assertInstanceOf(Router.Resolution.MethodNotAllowed.class, post);
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.GET));
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.PUT));
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.DELETE));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package dev.coph.nextusweb.server.annotation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AnnotationsTest {
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
static class CtrlDefault {}
|
||||||
|
|
||||||
|
@Controller("/api")
|
||||||
|
static class CtrlWithValue {}
|
||||||
|
|
||||||
|
static class Routes {
|
||||||
|
@GET("/g") public void g() {}
|
||||||
|
@POST("/p") public void p() {}
|
||||||
|
@PUT("/u") public void u() {}
|
||||||
|
@PATCH("/pa") public void pa() {}
|
||||||
|
@DELETE("/d") public void d() {}
|
||||||
|
@CUSTOM(method = "OPTIONS", value = "/o") public void o() {}
|
||||||
|
@Route(method = "TRACE", path = "/t") public void t() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void controllerDefaultValueIsEmpty() {
|
||||||
|
Controller c = CtrlDefault.class.getAnnotation(Controller.class);
|
||||||
|
assertNotNull(c);
|
||||||
|
assertEquals("", c.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void controllerValueIsCarried() {
|
||||||
|
Controller c = CtrlWithValue.class.getAnnotation(Controller.class);
|
||||||
|
assertNotNull(c);
|
||||||
|
assertEquals("/api", c.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void controllerHasRuntimeRetentionAndTypeTarget() {
|
||||||
|
Retention r = Controller.class.getAnnotation(Retention.class);
|
||||||
|
Target t = Controller.class.getAnnotation(Target.class);
|
||||||
|
assertEquals(RetentionPolicy.RUNTIME, r.value());
|
||||||
|
assertArrayEquals(new ElementType[]{ElementType.TYPE}, t.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routeMethodAnnotationsCarryValues() throws Exception {
|
||||||
|
assertEquals("/g", Routes.class.getDeclaredMethod("g").getAnnotation(GET.class).value());
|
||||||
|
assertEquals("/p", Routes.class.getDeclaredMethod("p").getAnnotation(POST.class).value());
|
||||||
|
assertEquals("/u", Routes.class.getDeclaredMethod("u").getAnnotation(PUT.class).value());
|
||||||
|
assertEquals("/pa", Routes.class.getDeclaredMethod("pa").getAnnotation(PATCH.class).value());
|
||||||
|
assertEquals("/d", Routes.class.getDeclaredMethod("d").getAnnotation(DELETE.class).value());
|
||||||
|
|
||||||
|
CUSTOM custom = Routes.class.getDeclaredMethod("o").getAnnotation(CUSTOM.class);
|
||||||
|
assertEquals("OPTIONS", custom.method());
|
||||||
|
assertEquals("/o", custom.value());
|
||||||
|
|
||||||
|
Route route = Routes.class.getDeclaredMethod("t").getAnnotation(Route.class);
|
||||||
|
assertEquals("TRACE", route.method());
|
||||||
|
assertEquals("/t", route.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allMethodAnnotationsTargetMethods() {
|
||||||
|
Class<?>[] anns = {GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, CUSTOM.class, Route.class};
|
||||||
|
for (Class<?> a : anns) {
|
||||||
|
Target t = a.getAnnotation(Target.class);
|
||||||
|
Retention r = a.getAnnotation(Retention.class);
|
||||||
|
assertNotNull(t, a.getSimpleName() + " missing @Target");
|
||||||
|
assertNotNull(r, a.getSimpleName() + " missing @Retention");
|
||||||
|
assertEquals(RetentionPolicy.RUNTIME, r.value());
|
||||||
|
assertArrayEquals(new ElementType[]{ElementType.METHOD}, t.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AuthConfigTest {
|
||||||
|
|
||||||
|
/** A distinct authenticator instance, identifiable by reference, that authenticates nobody. */
|
||||||
|
private Authenticator marker() {
|
||||||
|
return req -> null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ruleForReturnsNullWhenUnprotected() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.protect("/admin")
|
||||||
|
.build();
|
||||||
|
assertNull(cfg.ruleFor("/public"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactPathBeatsPrefix() {
|
||||||
|
Authenticator exactAuth = marker();
|
||||||
|
Authenticator prefixAuth = marker();
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.protect("/api/health", exactAuth)
|
||||||
|
.protectPrefix("/api/", prefixAuth)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AuthConfig.Rule rule = cfg.ruleFor("/api/health");
|
||||||
|
assertNotNull(rule);
|
||||||
|
assertSame(exactAuth, rule.authenticator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longerPrefixWins() {
|
||||||
|
Authenticator shortAuth = marker();
|
||||||
|
Authenticator longAuth = marker();
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.protectPrefix("/api/", shortAuth)
|
||||||
|
.protectPrefix("/api/v2/", longAuth)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertSame(longAuth, cfg.ruleFor("/api/v2/users").authenticator());
|
||||||
|
assertSame(shortAuth, cfg.ruleFor("/api/v1/users").authenticator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectUsesRequiredMode() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker()).protect("/admin").build();
|
||||||
|
assertEquals(AuthConfig.Mode.REQUIRED, cfg.ruleFor("/admin").mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionalUsesOptionalMode() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker()).optional("/feed").build();
|
||||||
|
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/feed").mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEverywhereAppliesGlobalRequiredRule() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.requireEverywhere()
|
||||||
|
.build();
|
||||||
|
AuthConfig.Rule rule = cfg.ruleFor("/anything");
|
||||||
|
assertNotNull(rule);
|
||||||
|
assertEquals(AuthConfig.Mode.REQUIRED, rule.mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionalEverywhereAppliesGlobalOptionalRule() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.optionalEverywhere()
|
||||||
|
.build();
|
||||||
|
AuthConfig.Rule rule = cfg.ruleFor("/anything");
|
||||||
|
assertNotNull(rule);
|
||||||
|
assertEquals(AuthConfig.Mode.OPTIONAL, rule.mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void specificRuleBeatsGlobal() {
|
||||||
|
Authenticator specific = marker();
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.optionalEverywhere()
|
||||||
|
.protect("/admin", specific)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
AuthConfig.Rule adminRule = cfg.ruleFor("/admin");
|
||||||
|
assertEquals(AuthConfig.Mode.REQUIRED, adminRule.mode());
|
||||||
|
assertSame(specific, adminRule.authenticator());
|
||||||
|
// Everything else still falls through to the optional global rule.
|
||||||
|
assertEquals(AuthConfig.Mode.OPTIONAL, cfg.ruleFor("/other").mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultAuthenticatorUsedWhenNotOverridden() {
|
||||||
|
Authenticator def = marker();
|
||||||
|
AuthConfig cfg = AuthConfig.builder(def)
|
||||||
|
.protect("/admin")
|
||||||
|
.protectPrefix("/api/")
|
||||||
|
.build();
|
||||||
|
assertSame(def, cfg.ruleFor("/admin").authenticator());
|
||||||
|
assertSame(def, cfg.ruleFor("/api/x").authenticator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengeIsStored() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker())
|
||||||
|
.protect("/admin")
|
||||||
|
.challenge("Basic realm=\"api\"")
|
||||||
|
.build();
|
||||||
|
assertEquals("Basic realm=\"api\"", cfg.challenge());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengeDefaultsToNull() {
|
||||||
|
AuthConfig cfg = AuthConfig.builder(marker()).build();
|
||||||
|
assertNull(cfg.challenge());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void builderRejectsNullDefaultAuthenticator() {
|
||||||
|
assertThrows(NullPointerException.class, () -> AuthConfig.builder(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.HttpVersion;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AuthGateTest {
|
||||||
|
|
||||||
|
private Request request(String apiKey) {
|
||||||
|
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||||
|
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||||
|
if (apiKey != null) raw.headers().set("X-API-Key", apiKey);
|
||||||
|
return new Request(raw, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Authenticator apiKeyAuth() {
|
||||||
|
return Authenticator.apiKey("X-API-Key",
|
||||||
|
key -> key.equals("valid") ? Principal.of("user-1") : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unprotectedPathProceedsWithoutPrincipal() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protectPrefix("/admin")
|
||||||
|
.build());
|
||||||
|
Request req = request(null);
|
||||||
|
assertNull(gate.authenticate(req, "/public"));
|
||||||
|
assertNull(req.principal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedPathRejectsMissingCredentials() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protectPrefix("/admin")
|
||||||
|
.build());
|
||||||
|
Response rejection = gate.authenticate(request(null), "/admin/users");
|
||||||
|
assertNotNull(rejection);
|
||||||
|
assertEquals(401, rejection.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedPathRejectsInvalidCredentials() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protect("/admin")
|
||||||
|
.build());
|
||||||
|
Response rejection = gate.authenticate(request("wrong"), "/admin");
|
||||||
|
assertNotNull(rejection);
|
||||||
|
assertEquals(401, rejection.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedPathAttachesPrincipalOnSuccess() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protect("/admin")
|
||||||
|
.build());
|
||||||
|
Request req = request("valid");
|
||||||
|
assertNull(gate.authenticate(req, "/admin"));
|
||||||
|
assertNotNull(req.principal());
|
||||||
|
assertEquals("user-1", req.principal().id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionalPathProceedsAnonymouslyButAttachesWhenPresent() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.optionalPrefix("/feed")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Request anon = request(null);
|
||||||
|
assertNull(gate.authenticate(anon, "/feed"));
|
||||||
|
assertNull(anon.principal());
|
||||||
|
|
||||||
|
Request authed = request("valid");
|
||||||
|
assertNull(gate.authenticate(authed, "/feed"));
|
||||||
|
assertEquals("user-1", authed.principal().id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengeHeaderAddedToUnauthorized() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protect("/admin")
|
||||||
|
.challenge("ApiKey realm=\"api\"")
|
||||||
|
.build());
|
||||||
|
Response rejection = gate.authenticate(request(null), "/admin");
|
||||||
|
assertEquals("ApiKey realm=\"api\"", rejection.headers().get("WWW-Authenticate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticatorErrorYields500() {
|
||||||
|
Authenticator boom = req -> {
|
||||||
|
throw new IllegalStateException("db down");
|
||||||
|
};
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(boom).protect("/admin").build());
|
||||||
|
Response rejection = gate.authenticate(request(null), "/admin");
|
||||||
|
assertNotNull(rejection);
|
||||||
|
assertEquals(500, rejection.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactPathAuthenticatorIsUsedOverPrefix() {
|
||||||
|
// The prefix authenticator never authenticates; the exact-path one accepts the "valid"
|
||||||
|
// key. The exact rule must win so the request on the exact path can succeed.
|
||||||
|
Authenticator prefixDeny = req -> null;
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.protect("/api/health", apiKeyAuth())
|
||||||
|
.protectPrefix("/api/", prefixDeny)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Request exact = request("valid");
|
||||||
|
assertNull(gate.authenticate(exact, "/api/health"));
|
||||||
|
assertEquals("user-1", exact.principal().id());
|
||||||
|
|
||||||
|
// A sibling path under the prefix uses the (always-denying) prefix authenticator.
|
||||||
|
Response rejection = gate.authenticate(request("valid"), "/api/other");
|
||||||
|
assertNotNull(rejection);
|
||||||
|
assertEquals(401, rejection.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEverywhereRejectsAnyPathWithoutCredentials() {
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(apiKeyAuth())
|
||||||
|
.requireEverywhere()
|
||||||
|
.build());
|
||||||
|
assertEquals(401, gate.authenticate(request(null), "/whatever").status());
|
||||||
|
assertNull(gate.authenticate(request("valid"), "/whatever"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyOfAuthenticatorAcceptsEitherCredential() {
|
||||||
|
Authenticator combined = Authenticator.anyOf(
|
||||||
|
apiKeyAuth(),
|
||||||
|
Authenticator.cookie("sid", s -> s.equals("sess") ? Principal.of("cookie-user") : null));
|
||||||
|
AuthGate gate = new AuthGate(AuthConfig.builder(combined).protect("/admin").build());
|
||||||
|
|
||||||
|
Request viaKey = request("valid");
|
||||||
|
assertNull(gate.authenticate(viaKey, "/admin"));
|
||||||
|
assertEquals("user-1", viaKey.principal().id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.HttpVersion;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AuthenticatorTest {
|
||||||
|
|
||||||
|
private Request request(String header, String value) {
|
||||||
|
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||||
|
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||||
|
if (header != null) raw.headers().set(header, value);
|
||||||
|
return new Request(raw, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apiKeyResolvesViaValidator() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.apiKey("X-API-Key",
|
||||||
|
key -> key.equals("good") ? Principal.of("svc") : null);
|
||||||
|
|
||||||
|
assertEquals("svc", auth.authenticate(request("X-API-Key", "good")).id());
|
||||||
|
assertNull(auth.authenticate(request("X-API-Key", "bad")));
|
||||||
|
assertNull(auth.authenticate(request(null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieResolvesViaValidator() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.cookie("sid",
|
||||||
|
sid -> sid.equals("abc") ? Principal.of("u1") : null);
|
||||||
|
|
||||||
|
assertEquals("u1", auth.authenticate(request("Cookie", "sid=abc")).id());
|
||||||
|
assertNull(auth.authenticate(request("Cookie", "sid=zzz")));
|
||||||
|
assertNull(auth.authenticate(request(null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void basicDecodesCredentials() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.basic(
|
||||||
|
(user, pass) -> user.equals("alice") && pass.equals("s3cret") ? Principal.of(user) : null);
|
||||||
|
|
||||||
|
String header = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("alice:s3cret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertEquals("alice", auth.authenticate(request("Authorization", header)).id());
|
||||||
|
|
||||||
|
String wrong = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("alice:nope".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertNull(auth.authenticate(request("Authorization", wrong)));
|
||||||
|
assertNull(auth.authenticate(request("Authorization", "Basic not-base64!!")));
|
||||||
|
assertNull(auth.authenticate(request(null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyOfReturnsFirstMatch() throws Exception {
|
||||||
|
Authenticator key = Authenticator.apiKey("X-API-Key",
|
||||||
|
k -> k.equals("k") ? Principal.of("byKey") : null);
|
||||||
|
Authenticator cookie = Authenticator.cookie("sid",
|
||||||
|
s -> Principal.of("byCookie"));
|
||||||
|
Authenticator combined = Authenticator.anyOf(key, cookie);
|
||||||
|
|
||||||
|
assertEquals("byKey", combined.authenticate(request("X-API-Key", "k")).id());
|
||||||
|
assertEquals("byCookie", combined.authenticate(request("Cookie", "sid=x")).id());
|
||||||
|
assertNull(combined.authenticate(request(null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bearerDecodesToken() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.bearer(
|
||||||
|
token -> token.equals("tok123") ? Principal.of("u") : null);
|
||||||
|
|
||||||
|
assertEquals("u", auth.authenticate(request("Authorization", "Bearer tok123")).id());
|
||||||
|
assertNull(auth.authenticate(request("Authorization", "Bearer wrong")));
|
||||||
|
assertNull(auth.authenticate(request("Authorization", "Bearer ")));
|
||||||
|
assertNull(auth.authenticate(request("Authorization", "Basic abc")));
|
||||||
|
assertNull(auth.authenticate(request(null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authSchemesAreCaseInsensitive() throws Exception {
|
||||||
|
Authenticator bearer = Authenticator.bearer(t -> Principal.of("b"));
|
||||||
|
assertEquals("b", bearer.authenticate(request("Authorization", "bearer tok")).id());
|
||||||
|
|
||||||
|
Authenticator basic = Authenticator.basic((u, p) -> Principal.of(u));
|
||||||
|
String header = "basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("alice:pw".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertEquals("alice", basic.authenticate(request("Authorization", header)).id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void basicReturnsNullWhenNoColon() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u));
|
||||||
|
String header = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("nocolon".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertNull(auth.authenticate(request("Authorization", header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void basicAllowsEmptyPassword() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.basic((u, p) -> Principal.of(u + ":" + p));
|
||||||
|
String header = "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString("user:".getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertEquals("user:", auth.authenticate(request("Authorization", header)).id());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apiKeyEmptyHeaderTreatedAsAbsent() throws Exception {
|
||||||
|
Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never"));
|
||||||
|
assertNull(auth.authenticate(request("X-API-Key", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constantTimeEqualsMatchesIdenticalValues() {
|
||||||
|
assertTrue(Authenticator.constantTimeEquals("s3cr3t", "s3cr3t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constantTimeEqualsRejectsDifferentValuesAndNulls() {
|
||||||
|
assertFalse(Authenticator.constantTimeEquals("s3cr3t", "s3cr3T"));
|
||||||
|
assertFalse(Authenticator.constantTimeEquals("short", "longer-value"));
|
||||||
|
assertFalse(Authenticator.constantTimeEquals(null, "x"));
|
||||||
|
assertFalse(Authenticator.constantTimeEquals("x", null));
|
||||||
|
assertFalse(Authenticator.constantTimeEquals(null, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package dev.coph.nextusweb.server.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class PrincipalTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofIdOnlyHasNoRolesOrClaims() {
|
||||||
|
Principal p = Principal.of("user-1");
|
||||||
|
assertEquals("user-1", p.id());
|
||||||
|
assertTrue(p.roles().isEmpty());
|
||||||
|
assertTrue(p.claims().isEmpty());
|
||||||
|
assertFalse(p.hasRole("admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ofWithRolesExposesRolesAndHasRole() {
|
||||||
|
Principal p = Principal.of("user-2", Set.of("admin", "ops"));
|
||||||
|
assertEquals("user-2", p.id());
|
||||||
|
assertEquals(Set.of("admin", "ops"), p.roles());
|
||||||
|
assertTrue(p.hasRole("admin"));
|
||||||
|
assertTrue(p.hasRole("ops"));
|
||||||
|
assertFalse(p.hasRole("guest"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rolesAreDefensivelyCopiedAndImmutable() {
|
||||||
|
Set<String> source = new HashSet<>(Set.of("admin"));
|
||||||
|
Principal p = Principal.of("user-3", source);
|
||||||
|
|
||||||
|
// Mutating the source after construction must not affect the principal.
|
||||||
|
source.add("sneaky");
|
||||||
|
assertEquals(Set.of("admin"), p.roles());
|
||||||
|
|
||||||
|
// The exposed set must be unmodifiable.
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> p.roles().add("x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void customImplementationIsSupported() {
|
||||||
|
Principal custom = new Principal() {
|
||||||
|
@Override
|
||||||
|
public String id() {
|
||||||
|
return "svc";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> roles() {
|
||||||
|
return Set.of("service");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assertEquals("svc", custom.id());
|
||||||
|
assertTrue(custom.hasRole("service"));
|
||||||
|
assertTrue(custom.claims().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package dev.coph.nextusweb.server.cores;
|
||||||
|
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class CorsConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void permissiveBuildsWithExpectedDefaults() {
|
||||||
|
CorsConfig c = CorsConfig.permissive();
|
||||||
|
assertTrue(c.allowAnyOrigin());
|
||||||
|
assertFalse(c.allowCredentials());
|
||||||
|
assertEquals(3600, c.maxAgeSeconds());
|
||||||
|
assertTrue(c.allowedMethods().contains(HttpMethod.GET));
|
||||||
|
assertTrue(c.allowedMethods().contains(HttpMethod.OPTIONS));
|
||||||
|
assertTrue(c.allowedHeaders().contains("Authorization"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isOriginAllowedHandlesNullAndWildcard() {
|
||||||
|
assertFalse(CorsConfig.permissive().isOriginAllowed(null));
|
||||||
|
assertTrue(CorsConfig.permissive().isOriginAllowed("https://anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactOriginMatchOnly() {
|
||||||
|
CorsConfig c = CorsConfig.builder()
|
||||||
|
.allowedOrigins("https://a.com")
|
||||||
|
.build();
|
||||||
|
assertTrue(c.isOriginAllowed("https://a.com"));
|
||||||
|
assertFalse(c.isOriginAllowed("https://b.com"));
|
||||||
|
assertFalse(c.isOriginAllowed(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wildcardWithCredentialsIsRejected() {
|
||||||
|
assertThrows(IllegalStateException.class, () -> CorsConfig.builder()
|
||||||
|
.anyOrigin()
|
||||||
|
.allowCredentials(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposedAndAllowedHeadersAreCopied() {
|
||||||
|
CorsConfig c = CorsConfig.builder()
|
||||||
|
.allowedHeaders("A", "B")
|
||||||
|
.exposedHeaders("X")
|
||||||
|
.allowedMethods(HttpMethod.GET)
|
||||||
|
.allowCredentials(true)
|
||||||
|
.maxAgeSeconds(60)
|
||||||
|
.allowedOrigins("http://a")
|
||||||
|
.build();
|
||||||
|
assertEquals(2, c.allowedHeaders().size());
|
||||||
|
assertTrue(c.exposedHeaders().contains("X"));
|
||||||
|
assertTrue(c.allowCredentials());
|
||||||
|
assertEquals(60, c.maxAgeSeconds());
|
||||||
|
assertFalse(c.allowAnyOrigin());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dev.coph.nextusweb.server.cores;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import io.netty.handler.codec.http.DefaultHttpHeaders;
|
||||||
|
import io.netty.handler.codec.http.HttpHeaders;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class CorsHandlerTest {
|
||||||
|
|
||||||
|
private CorsHandler permissiveHandler() {
|
||||||
|
return new CorsHandler(CorsConfig.permissive());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersDoesNothingForNullOrigin() {
|
||||||
|
Response res = new Response();
|
||||||
|
permissiveHandler().applyHeaders(null, res);
|
||||||
|
assertNull(res.headers().get("Access-Control-Allow-Origin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersDoesNothingForDisallowedOrigin() {
|
||||||
|
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||||
|
.allowedOrigins("https://allow")
|
||||||
|
.allowedMethods(HttpMethod.GET).build());
|
||||||
|
Response res = new Response();
|
||||||
|
h.applyHeaders("https://other", res);
|
||||||
|
assertNull(res.headers().get("Access-Control-Allow-Origin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersWritesWildcardWhenAnyOriginAndNoCreds() {
|
||||||
|
Response res = new Response();
|
||||||
|
permissiveHandler().applyHeaders("https://x.com", res);
|
||||||
|
assertEquals("*", res.headers().get("Access-Control-Allow-Origin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersWritesOriginAndVaryWhenSpecific() {
|
||||||
|
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||||
|
.allowedOrigins("https://a")
|
||||||
|
.allowedMethods(HttpMethod.GET)
|
||||||
|
.allowCredentials(true)
|
||||||
|
.exposedHeaders("X-Custom").build());
|
||||||
|
Response res = new Response();
|
||||||
|
h.applyHeaders("https://a", res);
|
||||||
|
assertEquals("https://a", res.headers().get("Access-Control-Allow-Origin"));
|
||||||
|
assertEquals("Origin", res.headers().get("Vary"));
|
||||||
|
assertEquals("true", res.headers().get("Access-Control-Allow-Credentials"));
|
||||||
|
assertEquals("X-Custom", res.headers().get("Access-Control-Expose-Headers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isPreflightTrueOnlyForOptionsWithRequestMethod() {
|
||||||
|
HttpHeaders hs = new DefaultHttpHeaders();
|
||||||
|
hs.set("Access-Control-Request-Method", "GET");
|
||||||
|
assertTrue(permissiveHandler().isPreflight(HttpMethod.OPTIONS, hs));
|
||||||
|
assertFalse(permissiveHandler().isPreflight(HttpMethod.GET, hs));
|
||||||
|
HttpHeaders empty = new DefaultHttpHeaders();
|
||||||
|
assertFalse(permissiveHandler().isPreflight(HttpMethod.OPTIONS, empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlePreflightReturns403ForDisallowedOrigin() {
|
||||||
|
CorsHandler h = new CorsHandler(CorsConfig.builder()
|
||||||
|
.allowedOrigins("https://allow")
|
||||||
|
.allowedMethods(HttpMethod.GET).build());
|
||||||
|
Response res = h.handlePreflight("https://other", new DefaultHttpHeaders());
|
||||||
|
assertEquals(403, res.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlePreflightWritesAllowAndMaxAge() {
|
||||||
|
Response res = permissiveHandler().handlePreflight("https://x.com", new DefaultHttpHeaders());
|
||||||
|
assertEquals(204, res.status());
|
||||||
|
assertNotNull(res.headers().get("Access-Control-Allow-Methods"));
|
||||||
|
assertEquals("3600", res.headers().get("Access-Control-Max-Age"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlePreflightEchoesRequestedHeadersIfNoneConfigured() {
|
||||||
|
CorsConfig cfg = CorsConfig.builder()
|
||||||
|
.anyOrigin()
|
||||||
|
.allowedMethods(HttpMethod.GET)
|
||||||
|
.build();
|
||||||
|
CorsHandler h = new CorsHandler(cfg);
|
||||||
|
HttpHeaders req = new DefaultHttpHeaders();
|
||||||
|
req.set("Access-Control-Request-Headers", "x-foo");
|
||||||
|
Response res = h.handlePreflight("https://x.com", req);
|
||||||
|
assertEquals("x-foo", res.headers().get("Access-Control-Allow-Headers"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.coph.nextusweb.server.json;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class JsonMapperTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapperIsAvailable() {
|
||||||
|
assertNotNull(JsonMapper.MAPPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapperRoundTripsSimpleValues() {
|
||||||
|
var node = JsonMapper.MAPPER.valueToTree(java.util.Map.of("a", 1));
|
||||||
|
assertTrue(node.isObject());
|
||||||
|
assertEquals(1, node.get("a").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapperReadsTree() {
|
||||||
|
JsonNode n = JsonMapper.MAPPER.readTree("{\"k\":\"v\"}");
|
||||||
|
assertTrue(n.has("k"));
|
||||||
|
assertNotNull(n.get("k"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapperSerializesToBytes() {
|
||||||
|
byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(java.util.Map.of("a", "b"));
|
||||||
|
String s = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
assertTrue(s.contains("\"a\""));
|
||||||
|
assertTrue(s.contains("\"b\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package dev.coph.nextusweb.server.net;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ClientIpTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesSocketIpWhenNoForwardedHeader() {
|
||||||
|
assertEquals("203.0.113.5",
|
||||||
|
ClientIp.resolve("203.0.113.5", null, TrustedProxies.all()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresForwardedHeaderWhenPeerNotTrusted() {
|
||||||
|
// A direct (untrusted) client cannot spoof its IP via X-Forwarded-For.
|
||||||
|
assertEquals("203.0.113.5",
|
||||||
|
ClientIp.resolve("203.0.113.5", "1.2.3.4", TrustedProxies.none()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesForwardedHeaderWhenPeerTrusted() {
|
||||||
|
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
assertEquals("1.2.3.4",
|
||||||
|
ClientIp.resolve("10.0.0.1", "1.2.3.4", trusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsFirstUntrustedHopFromTheRight() {
|
||||||
|
// Chain: realClient, edgeProxy, internalProxy(=peer). Both proxies are trusted, so the
|
||||||
|
// resolved client is the first untrusted entry walking from the right.
|
||||||
|
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
String xff = "9.9.9.9, 10.0.0.9, 10.0.0.8";
|
||||||
|
assertEquals("9.9.9.9",
|
||||||
|
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spoofedLeadingEntriesAreIgnored() {
|
||||||
|
// Attacker prepends a fake hop; since the genuine client hop (8.8.8.8) is the first
|
||||||
|
// untrusted from the right, the forged "1.1.1.1" is never returned.
|
||||||
|
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
String xff = "1.1.1.1, 8.8.8.8, 10.0.0.8";
|
||||||
|
assertEquals("8.8.8.8",
|
||||||
|
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allHopsTrustedFallsBackToLeftmost() {
|
||||||
|
TrustedProxies trusted = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
String xff = "10.0.0.7, 10.0.0.8";
|
||||||
|
assertEquals("10.0.0.7",
|
||||||
|
ClientIp.resolve("10.0.0.8", xff, trusted));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package dev.coph.nextusweb.server.net;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class TrustedProxiesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noneTrustsNothing() {
|
||||||
|
TrustedProxies tp = TrustedProxies.none();
|
||||||
|
assertFalse(tp.isTrusted("127.0.0.1"));
|
||||||
|
assertFalse(tp.isTrusted("10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allTrustsEverything() {
|
||||||
|
TrustedProxies tp = TrustedProxies.all();
|
||||||
|
assertTrue(tp.isTrusted("8.8.8.8"));
|
||||||
|
assertTrue(tp.isTrusted("::1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesIpv4Cidr() {
|
||||||
|
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
assertTrue(tp.isTrusted("10.1.2.3"));
|
||||||
|
assertTrue(tp.isTrusted("10.255.255.255"));
|
||||||
|
assertFalse(tp.isTrusted("11.0.0.1"));
|
||||||
|
assertFalse(tp.isTrusted("192.168.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesBareHostAsSingleAddress() {
|
||||||
|
TrustedProxies tp = TrustedProxies.of("127.0.0.1");
|
||||||
|
assertTrue(tp.isTrusted("127.0.0.1"));
|
||||||
|
assertFalse(tp.isTrusted("127.0.0.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesIpv6Cidr() {
|
||||||
|
TrustedProxies tp = TrustedProxies.of("fd00::/8");
|
||||||
|
assertTrue(tp.isTrusted("fd12:3456::1"));
|
||||||
|
assertFalse(tp.isTrusted("fe80::1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentFamilyDoesNotMatch() {
|
||||||
|
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
assertFalse(tp.isTrusted("::1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidAddressIsNotTrusted() {
|
||||||
|
TrustedProxies tp = TrustedProxies.of("10.0.0.0/8");
|
||||||
|
assertFalse(tp.isTrusted("not-an-ip"));
|
||||||
|
assertFalse(tp.isTrusted(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidCidr() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("10.0.0.0/40"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> TrustedProxies.of("garbage"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class FixedWindowLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsUpToLimitThenDenies() {
|
||||||
|
FixedWindowLimiter lim = new FixedWindowLimiter(3, 1000);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
RateLimiter.Result r = lim.tryAcquire("k", 0);
|
||||||
|
assertFalse(r.allowed());
|
||||||
|
assertEquals(3, r.limit());
|
||||||
|
assertTrue(r.retryAfterMillis() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newWindowResetsCount() {
|
||||||
|
FixedWindowLimiter lim = new FixedWindowLimiter(1, 100);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||||
|
long windowNs = 100L * 1_000_000L;
|
||||||
|
assertTrue(lim.tryAcquire("k", windowNs).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentKeysAreIndependent() {
|
||||||
|
FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000);
|
||||||
|
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupDoesNotThrow() {
|
||||||
|
FixedWindowLimiter lim = new FixedWindowLimiter(1, 1000);
|
||||||
|
lim.tryAcquire("k", System.nanoTime());
|
||||||
|
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.HttpVersion;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class KeyResolverTest {
|
||||||
|
|
||||||
|
private Request request() {
|
||||||
|
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||||
|
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||||
|
return new Request(raw, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Request requestWith(String header, String value) {
|
||||||
|
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||||
|
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||||
|
raw.headers().set(header, value);
|
||||||
|
return new Request(raw, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientIpReturnsResolvedIpVerbatim() {
|
||||||
|
assertEquals("10.0.0.1", KeyResolver.clientIp().resolve(request(), "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headerResolverUsesHeaderValue() {
|
||||||
|
Request r = requestWith("X-API-Key", "secret123");
|
||||||
|
assertEquals("h:secret123", KeyResolver.header("X-API-Key").resolve(r, "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headerResolverFallsBackToClientIp() {
|
||||||
|
assertEquals("ip:10.0.0.1", KeyResolver.header("X-API-Key").resolve(request(), "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieResolverUsesCookieValue() {
|
||||||
|
Request r = requestWith("Cookie", "sid=abc; other=x");
|
||||||
|
assertEquals("c:abc", KeyResolver.cookie("sid").resolve(r, "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieResolverFallsBackToClientIp() {
|
||||||
|
assertEquals("ip:10.0.0.1", KeyResolver.cookie("sid").resolve(request(), "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void principalResolverUsesPrincipalId() {
|
||||||
|
Request r = request();
|
||||||
|
r.principal(Principal.of("user-42", Set.of("admin")));
|
||||||
|
assertEquals("p:user-42", KeyResolver.principal().resolve(r, "10.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void principalResolverFallsBackToClientIpWhenAnonymous() {
|
||||||
|
assertEquals("ip:10.0.0.1", KeyResolver.principal().resolve(request(), "10.0.0.1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class LeakyBucketLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fillsUpToCapacityThenDenies() {
|
||||||
|
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 2);
|
||||||
|
long now = 0;
|
||||||
|
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||||
|
RateLimiter.Result r = lim.tryAcquire("k", now);
|
||||||
|
assertFalse(r.allowed());
|
||||||
|
assertEquals(2, r.limit());
|
||||||
|
assertTrue(r.retryAfterMillis() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void leakReducesLevelAndAllowsAgain() {
|
||||||
|
LeakyBucketLimiter lim = new LeakyBucketLimiter(10, 1);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||||
|
long oneSec = 1_000_000_000L;
|
||||||
|
assertTrue(lim.tryAcquire("k", oneSec).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentKeysAreIndependent() {
|
||||||
|
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1);
|
||||||
|
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupDoesNotThrow() {
|
||||||
|
LeakyBucketLimiter lim = new LeakyBucketLimiter(1, 1);
|
||||||
|
lim.tryAcquire("k", System.nanoTime());
|
||||||
|
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RateLimitConfigTest {
|
||||||
|
|
||||||
|
private RateLimiter alwaysAllow() {
|
||||||
|
return (k, now) -> RateLimiter.Result.allow(1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyResolver keyer() {
|
||||||
|
return (req, remote) -> "x";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyConfigReturnsEmptyList() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder().build();
|
||||||
|
assertTrue(cfg.rulesFor("/anything").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void globalOnlyReturnsOneRule() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.global(alwaysAllow(), keyer())
|
||||||
|
.build();
|
||||||
|
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/x");
|
||||||
|
assertEquals(1, rules.size());
|
||||||
|
assertEquals("global", rules.getFirst().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactPathTrumpsPrefixRule() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.forPath("/a/b", alwaysAllow(), keyer())
|
||||||
|
.forPrefix("/a/", alwaysAllow(), keyer())
|
||||||
|
.build();
|
||||||
|
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/a/b");
|
||||||
|
assertEquals(1, rules.size());
|
||||||
|
assertEquals("/a/b", rules.getFirst().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prefixRuleMatchesWhenNoExact() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.forPrefix("/api/", alwaysAllow(), keyer())
|
||||||
|
.build();
|
||||||
|
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/api/users");
|
||||||
|
assertEquals(1, rules.size());
|
||||||
|
assertEquals("/api/*", rules.getFirst().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longerPrefixWinsOverShorter() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.forPrefix("/api/", alwaysAllow(), keyer())
|
||||||
|
.forPrefix("/api/v2/", alwaysAllow(), keyer())
|
||||||
|
.build();
|
||||||
|
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/api/v2/users");
|
||||||
|
assertEquals(1, rules.size());
|
||||||
|
assertEquals("/api/v2/*", rules.getFirst().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void globalIsAlwaysIncludedAlongsideMatchedRule() {
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.global(alwaysAllow(), keyer())
|
||||||
|
.forPath("/x", alwaysAllow(), keyer())
|
||||||
|
.build();
|
||||||
|
List<RateLimitConfig.Rule> rules = cfg.rulesFor("/x");
|
||||||
|
assertEquals(2, rules.size());
|
||||||
|
assertEquals("global", rules.get(0).name());
|
||||||
|
assertEquals("/x", rules.get(1).name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allLimitersCollectsEveryDistinctLimiter() {
|
||||||
|
// Distinct concrete instances (non-capturing lambdas would be the same JVM singleton).
|
||||||
|
RateLimiter a = new FixedWindowLimiter(1, 1000);
|
||||||
|
RateLimiter b = new FixedWindowLimiter(1, 1000);
|
||||||
|
RateLimiter c = new FixedWindowLimiter(1, 1000);
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.global(a, keyer())
|
||||||
|
.forPath("/x", b, keyer())
|
||||||
|
.forPrefix("/api/", c, keyer())
|
||||||
|
.build();
|
||||||
|
assertEquals(3, cfg.allLimiters().size());
|
||||||
|
assertTrue(cfg.allLimiters().containsAll(List.of(a, b, c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allLimitersDeduplicatesSharedInstance() {
|
||||||
|
RateLimiter shared = alwaysAllow();
|
||||||
|
RateLimitConfig cfg = RateLimitConfig.builder()
|
||||||
|
.global(shared, keyer())
|
||||||
|
.forPath("/x", shared, keyer())
|
||||||
|
.forPrefix("/api/", shared, keyer())
|
||||||
|
.build();
|
||||||
|
assertEquals(1, cfg.allLimiters().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Request;
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.HttpVersion;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RateLimitGateTest {
|
||||||
|
|
||||||
|
private Request req() {
|
||||||
|
FullHttpRequest raw = new DefaultFullHttpRequest(
|
||||||
|
HttpVersion.HTTP_1_1, HttpMethod.GET, "/", Unpooled.EMPTY_BUFFER);
|
||||||
|
return new Request(raw, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkReturnsNullWhenNoRulesMatch() {
|
||||||
|
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder().build());
|
||||||
|
assertNull(gate.check(req(), "/anything", "1.1.1.1"));
|
||||||
|
gate.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkAllowsWhenWithinLimit() {
|
||||||
|
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder()
|
||||||
|
.global(new FixedWindowLimiter(2, 1000), KeyResolver.clientIp())
|
||||||
|
.build());
|
||||||
|
RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1");
|
||||||
|
assertNotNull(r);
|
||||||
|
assertTrue(r.allowed());
|
||||||
|
gate.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void checkDeniesWhenAnyRuleDenies() {
|
||||||
|
RateLimitGate gate = new RateLimitGate(RateLimitConfig.builder()
|
||||||
|
.global(new FixedWindowLimiter(1, 1000), KeyResolver.clientIp())
|
||||||
|
.build());
|
||||||
|
gate.check(req(), "/x", "1.1.1.1");
|
||||||
|
RateLimiter.Result r = gate.check(req(), "/x", "1.1.1.1");
|
||||||
|
assertNotNull(r);
|
||||||
|
assertFalse(r.allowed());
|
||||||
|
gate.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersIsNoOpForNull() {
|
||||||
|
Response res = new Response();
|
||||||
|
RateLimitGate.applyHeaders(null, res);
|
||||||
|
assertNull(res.headers().get("X-RateLimit-Limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersWritesLimitAndRemaining() {
|
||||||
|
Response res = new Response();
|
||||||
|
RateLimitGate.applyHeaders(RateLimiter.Result.allow(5, 10), res);
|
||||||
|
assertEquals("10", res.headers().get("X-RateLimit-Limit"));
|
||||||
|
assertEquals("5", res.headers().get("X-RateLimit-Remaining"));
|
||||||
|
assertNull(res.headers().get("Retry-After"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersWritesRetryAfterWhenDenied() {
|
||||||
|
Response res = new Response();
|
||||||
|
RateLimitGate.applyHeaders(RateLimiter.Result.deny(10, 2500), res);
|
||||||
|
assertEquals("3", res.headers().get("Retry-After"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyHeadersClampsNegativeRemainingToZero() {
|
||||||
|
Response res = new Response();
|
||||||
|
RateLimitGate.applyHeaders(new RateLimiter.Result(true, -5, 10, 0), res);
|
||||||
|
assertEquals("0", res.headers().get("X-RateLimit-Remaining"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RateLimiterResultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowFactoryProducesAllowed() {
|
||||||
|
RateLimiter.Result r = RateLimiter.Result.allow(5, 10);
|
||||||
|
assertTrue(r.allowed());
|
||||||
|
assertEquals(5, r.remaining());
|
||||||
|
assertEquals(10, r.limit());
|
||||||
|
assertEquals(0, r.retryAfterMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void denyFactoryProducesDenied() {
|
||||||
|
RateLimiter.Result r = RateLimiter.Result.deny(10, 250);
|
||||||
|
assertFalse(r.allowed());
|
||||||
|
assertEquals(0, r.remaining());
|
||||||
|
assertEquals(10, r.limit());
|
||||||
|
assertEquals(250, r.retryAfterMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SlidingWindowLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allowsUpToLimitThenDenies() {
|
||||||
|
SlidingWindowLimiter lim = new SlidingWindowLimiter(2, 1000);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterFullWindowAllowsAgain() {
|
||||||
|
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 100);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
long twoWindows = 200L * 1_000_000L;
|
||||||
|
assertTrue(lim.tryAcquire("k", twoWindows).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentKeysAreIndependent() {
|
||||||
|
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000);
|
||||||
|
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupDoesNotThrow() {
|
||||||
|
SlidingWindowLimiter lim = new SlidingWindowLimiter(1, 1000);
|
||||||
|
lim.tryAcquire("k", System.nanoTime());
|
||||||
|
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.coph.nextusweb.server.ratelimit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class TokenBucketLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void burstUpToCapacityIsAllowed() {
|
||||||
|
TokenBucketLimiter lim = new TokenBucketLimiter(1, 3);
|
||||||
|
long now = 0;
|
||||||
|
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("k", now).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyBucketIsDeniedAndRetryAfterIsPositive() {
|
||||||
|
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
RateLimiter.Result r = lim.tryAcquire("k", 0);
|
||||||
|
assertFalse(r.allowed());
|
||||||
|
assertTrue(r.retryAfterMillis() > 0);
|
||||||
|
assertEquals(1, r.limit());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refillAllowsAcquireAfterTime() {
|
||||||
|
TokenBucketLimiter lim = new TokenBucketLimiter(10, 1);
|
||||||
|
assertTrue(lim.tryAcquire("k", 0).allowed());
|
||||||
|
assertFalse(lim.tryAcquire("k", 0).allowed());
|
||||||
|
long oneSecLater = 1_000_000_000L;
|
||||||
|
assertTrue(lim.tryAcquire("k", oneSecLater).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentKeysAreIndependent() {
|
||||||
|
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||||
|
assertTrue(lim.tryAcquire("a", 0).allowed());
|
||||||
|
assertTrue(lim.tryAcquire("b", 0).allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupDoesNotThrow() {
|
||||||
|
TokenBucketLimiter lim = new TokenBucketLimiter(1, 1);
|
||||||
|
lim.tryAcquire("k", System.nanoTime());
|
||||||
|
assertDoesNotThrow(() -> lim.cleanup(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package dev.coph.nextusweb.server.router;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.auth.Principal;
|
||||||
|
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||||
|
import io.netty.buffer.Unpooled;
|
||||||
|
import io.netty.handler.codec.http.DefaultFullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.FullHttpRequest;
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import io.netty.handler.codec.http.HttpVersion;
|
||||||
|
import io.netty.util.CharsetUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RequestTest {
|
||||||
|
|
||||||
|
private FullHttpRequest build(HttpMethod method, String uri, String body) {
|
||||||
|
var content = body == null
|
||||||
|
? Unpooled.EMPTY_BUFFER
|
||||||
|
: Unpooled.copiedBuffer(body, CharsetUtil.UTF_8);
|
||||||
|
return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
record Payload(String name, int age) {}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pathParamReturnsFromMap() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/u/1", null), Map.of("id", "1"));
|
||||||
|
assertEquals("1", req.pathParam("id"));
|
||||||
|
assertNull(req.pathParam("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryParamReturnsFirstValue() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2&b=foo", null), Map.of());
|
||||||
|
assertEquals("1", req.queryParam("a"));
|
||||||
|
assertEquals("foo", req.queryParam("b"));
|
||||||
|
assertNull(req.queryParam("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryParamsReturnsAllValues() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/?a=1&a=2", null), Map.of());
|
||||||
|
assertEquals(java.util.List.of("1", "2"), req.queryParams("a"));
|
||||||
|
assertTrue(req.queryParams("missing").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headerReturnsValue() {
|
||||||
|
FullHttpRequest raw = build(HttpMethod.GET, "/", null);
|
||||||
|
raw.headers().set("X-Foo", "bar");
|
||||||
|
Request req = new Request(raw, Map.of());
|
||||||
|
assertEquals("bar", req.header("X-Foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bodyReturnsContentAsString() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", "hello"), Map.of());
|
||||||
|
assertEquals("hello", req.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void methodAndPathExpose() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/a/b?q=1", null), Map.of());
|
||||||
|
assertEquals(HttpMethod.POST, req.method());
|
||||||
|
assertEquals("/a/b", req.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonReturnsNullNodeForEmptyBody() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", null), Map.of());
|
||||||
|
var node = req.json();
|
||||||
|
assertNotNull(node);
|
||||||
|
assertTrue(node.isNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonParsesObject() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", "{\"a\":1}"), Map.of());
|
||||||
|
var node = req.json();
|
||||||
|
assertTrue(node.has("a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonThrowsBadRequestOnInvalidJson() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
|
||||||
|
assertThrows(BadRequestException.class, req::json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonAsDeserializes() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", "{\"name\":\"x\",\"age\":42}"), Map.of());
|
||||||
|
Payload p = req.jsonAs(Payload.class);
|
||||||
|
assertEquals("x", p.name());
|
||||||
|
assertEquals(42, p.age());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonAsThrowsBadRequestOnInvalid() {
|
||||||
|
Request req = new Request(build(HttpMethod.POST, "/", "not-json"), Map.of());
|
||||||
|
assertThrows(BadRequestException.class, () -> req.jsonAs(Payload.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieParsesNamedCookie() {
|
||||||
|
FullHttpRequest raw = build(HttpMethod.GET, "/", null);
|
||||||
|
raw.headers().set("Cookie", "sid=abc123; theme=dark");
|
||||||
|
Request req = new Request(raw, Map.of());
|
||||||
|
assertEquals("abc123", req.cookie("sid"));
|
||||||
|
assertEquals("dark", req.cookie("theme"));
|
||||||
|
assertNull(req.cookie("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieReturnsNullWhenNoCookieHeader() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||||
|
assertNull(req.cookie("sid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesSetGetAndRemove() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||||
|
assertNull(req.attribute("k"));
|
||||||
|
req.attribute("k", "v");
|
||||||
|
assertEquals("v", req.<String>attribute("k"));
|
||||||
|
req.attribute("k", null);
|
||||||
|
assertNull(req.attribute("k"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientIpRoundTrips() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||||
|
assertNull(req.clientIp());
|
||||||
|
req.clientIp("203.0.113.9");
|
||||||
|
assertEquals("203.0.113.9", req.clientIp());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void principalRoundTripsAndDrivesIsAuthenticated() {
|
||||||
|
Request req = new Request(build(HttpMethod.GET, "/", null), Map.of());
|
||||||
|
assertFalse(req.isAuthenticated());
|
||||||
|
assertNull(req.principal());
|
||||||
|
req.principal(Principal.of("user-7"));
|
||||||
|
assertTrue(req.isAuthenticated());
|
||||||
|
assertEquals("user-7", req.principal().id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.coph.nextusweb.server.router;
|
||||||
|
|
||||||
|
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ResponseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultStatusIs200AndEmptyBody() {
|
||||||
|
Response res = new Response();
|
||||||
|
assertEquals(200, res.status());
|
||||||
|
assertArrayEquals(new byte[0], res.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusIsFluent() {
|
||||||
|
Response res = new Response().status(404);
|
||||||
|
assertEquals(404, res.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headerSetsValue() {
|
||||||
|
Response res = new Response().header("X-Foo", "bar");
|
||||||
|
assertEquals("bar", res.headers().get("X-Foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void textSetsBodyAndContentType() {
|
||||||
|
Response res = new Response().text("hello");
|
||||||
|
assertEquals("hello", new String(res.body(), StandardCharsets.UTF_8));
|
||||||
|
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("text/plain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonStringSetsBodyAndContentType() {
|
||||||
|
Response res = new Response().json("{\"a\":1}");
|
||||||
|
assertEquals("{\"a\":1}", new String(res.body(), StandardCharsets.UTF_8));
|
||||||
|
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jsonObjectSerializesValue() {
|
||||||
|
Response res = new Response().json(Map.of("k", "v"));
|
||||||
|
String s = new String(res.body(), StandardCharsets.UTF_8);
|
||||||
|
assertTrue(s.contains("\"k\""));
|
||||||
|
assertTrue(s.contains("\"v\""));
|
||||||
|
assertTrue(res.headers().get(HttpHeaderNames.CONTENT_TYPE).startsWith("application/json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package dev.coph.nextusweb.server.router;
|
||||||
|
|
||||||
|
import io.netty.handler.codec.http.HttpMethod;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RouterTest {
|
||||||
|
|
||||||
|
private final Router.Handler noop = (req, res) -> {};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRegistersAndResolvesExactPath() {
|
||||||
|
Router r = new Router().get("/hello", noop);
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postPutDeleteRegister() {
|
||||||
|
Router r = new Router()
|
||||||
|
.post("/p", noop)
|
||||||
|
.put("/u", noop)
|
||||||
|
.delete("/d", noop);
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.POST, "/p"));
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.PUT, "/u"));
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.DELETE, "/d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notFoundForUnknownPath() {
|
||||||
|
Router r = new Router().get("/a", noop);
|
||||||
|
assertInstanceOf(Router.Resolution.NotFound.class, r.resolve(HttpMethod.GET, "/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void methodNotAllowedWhenPathMatchesDifferentMethod() {
|
||||||
|
Router r = new Router().get("/a", noop);
|
||||||
|
Router.Resolution res = r.resolve(HttpMethod.POST, "/a");
|
||||||
|
Router.Resolution.MethodNotAllowed mna = assertInstanceOf(Router.Resolution.MethodNotAllowed.class, res);
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.GET));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pathParamsAreExtracted() {
|
||||||
|
Router r = new Router().get("/u/{id}", noop);
|
||||||
|
Router.Resolution res = r.resolve(HttpMethod.GET, "/u/42");
|
||||||
|
Router.Resolution.Match m = assertInstanceOf(Router.Resolution.Match.class, res);
|
||||||
|
assertEquals("42", m.pathParams().get("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wildcardMatches() {
|
||||||
|
Router r = new Router().get("/files/*", noop);
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/files/anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void useAddsMiddlewareReturned() {
|
||||||
|
AtomicInteger count = new AtomicInteger();
|
||||||
|
Router r = new Router().use((req, res) -> count.incrementAndGet());
|
||||||
|
assertEquals(1, r.middlewares().size());
|
||||||
|
r.middlewares().getFirst().accept(null, null);
|
||||||
|
assertEquals(1, count.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerWorksWithCustomMethod() {
|
||||||
|
Router r = new Router().register(HttpMethod.valueOf("OPTIONS"), "/x", noop);
|
||||||
|
assertInstanceOf(Router.Resolution.Match.class,
|
||||||
|
r.resolve(HttpMethod.valueOf("OPTIONS"), "/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlerInvocationWorks() throws Exception {
|
||||||
|
AtomicInteger called = new AtomicInteger();
|
||||||
|
Router r = new Router().get("/x", (req, res) -> called.incrementAndGet());
|
||||||
|
var match = (Router.Resolution.Match) r.resolve(HttpMethod.GET, "/x");
|
||||||
|
match.handler().handle(null, null);
|
||||||
|
assertEquals(1, called.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void samePathWithDifferentMethodsResolvesToDistinctHandlers() throws Exception {
|
||||||
|
AtomicInteger getCalls = new AtomicInteger();
|
||||||
|
AtomicInteger putCalls = new AtomicInteger();
|
||||||
|
AtomicInteger deleteCalls = new AtomicInteger();
|
||||||
|
|
||||||
|
Router r = new Router()
|
||||||
|
.get("/user", (req, res) -> getCalls.incrementAndGet())
|
||||||
|
.put("/user", (req, res) -> putCalls.incrementAndGet())
|
||||||
|
.delete("/user", (req, res) -> deleteCalls.incrementAndGet());
|
||||||
|
|
||||||
|
var get = assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/user"));
|
||||||
|
var put = assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.PUT, "/user"));
|
||||||
|
var del = assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.DELETE, "/user"));
|
||||||
|
|
||||||
|
get.handler().handle(null, null);
|
||||||
|
put.handler().handle(null, null);
|
||||||
|
del.handler().handle(null, null);
|
||||||
|
|
||||||
|
assertEquals(1, getCalls.get());
|
||||||
|
assertEquals(1, putCalls.get());
|
||||||
|
assertEquals(1, deleteCalls.get());
|
||||||
|
assertNotSame(get.handler(), put.handler());
|
||||||
|
assertNotSame(put.handler(), del.handler());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void samePathUnregisteredMethodReturnsMethodNotAllowedWithAllAllowed() {
|
||||||
|
Router r = new Router()
|
||||||
|
.get("/user", noop)
|
||||||
|
.put("/user", noop)
|
||||||
|
.delete("/user", noop);
|
||||||
|
|
||||||
|
var res = r.resolve(HttpMethod.POST, "/user");
|
||||||
|
var mna = assertInstanceOf(Router.Resolution.MethodNotAllowed.class, res);
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.GET));
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.PUT));
|
||||||
|
assertTrue(mna.allowedMethods().contains(HttpMethod.DELETE));
|
||||||
|
assertFalse(mna.allowedMethods().contains(HttpMethod.POST));
|
||||||
|
assertEquals(3, mna.allowedMethods().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registeringSameMethodAndPathTwiceOverwritesHandler() throws Exception {
|
||||||
|
AtomicInteger first = new AtomicInteger();
|
||||||
|
AtomicInteger second = new AtomicInteger();
|
||||||
|
|
||||||
|
Router r = new Router()
|
||||||
|
.get("/user", (req, res) -> first.incrementAndGet())
|
||||||
|
.get("/user", (req, res) -> second.incrementAndGet());
|
||||||
|
|
||||||
|
var match = (Router.Resolution.Match) r.resolve(HttpMethod.GET, "/user");
|
||||||
|
match.handler().handle(null, null);
|
||||||
|
|
||||||
|
assertEquals(0, first.get());
|
||||||
|
assertEquals(1, second.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void samePathWithParamAndMultipleMethodsKeepsParamsAndHandlers() throws Exception {
|
||||||
|
AtomicInteger getCalls = new AtomicInteger();
|
||||||
|
AtomicInteger putCalls = new AtomicInteger();
|
||||||
|
|
||||||
|
Router r = new Router()
|
||||||
|
.get("/user/{id}", (req, res) -> getCalls.incrementAndGet())
|
||||||
|
.put("/user/{id}", (req, res) -> putCalls.incrementAndGet());
|
||||||
|
|
||||||
|
var get = assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.GET, "/user/42"));
|
||||||
|
var put = assertInstanceOf(Router.Resolution.Match.class, r.resolve(HttpMethod.PUT, "/user/42"));
|
||||||
|
|
||||||
|
assertEquals("42", get.pathParams().get("id"));
|
||||||
|
assertEquals("42", put.pathParams().get("id"));
|
||||||
|
|
||||||
|
get.handler().handle(null, null);
|
||||||
|
put.handler().handle(null, null);
|
||||||
|
assertEquals(1, getCalls.get());
|
||||||
|
assertEquals(1, putCalls.get());
|
||||||
|
assertNotSame(get.handler(), put.handler());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.coph.nextusweb.server.router.exception;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class BadRequestExceptionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void messageIsCarried() {
|
||||||
|
BadRequestException e = new BadRequestException("oops");
|
||||||
|
assertEquals("oops", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isRuntimeException() {
|
||||||
|
assertInstanceOf(RuntimeException.class, new BadRequestException("x"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package dev.coph.nextusweb.server.security;
|
||||||
|
|
||||||
|
import dev.coph.nextusweb.server.router.Response;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SecurityHeadersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsApplyConservativeHeaders() {
|
||||||
|
Response res = new Response();
|
||||||
|
SecurityHeaders.defaults().apply(res, false);
|
||||||
|
|
||||||
|
assertEquals("nosniff", res.headers().get("X-Content-Type-Options"));
|
||||||
|
assertEquals("DENY", res.headers().get("X-Frame-Options"));
|
||||||
|
assertEquals("no-referrer", res.headers().get("Referrer-Policy"));
|
||||||
|
assertNull(res.headers().get("Content-Security-Policy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hstsIsOmittedOnInsecureConnections() {
|
||||||
|
Response res = new Response();
|
||||||
|
SecurityHeaders.defaults().apply(res, false);
|
||||||
|
assertNull(res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hstsIsEmittedOnSecureConnections() {
|
||||||
|
Response res = new Response();
|
||||||
|
SecurityHeaders.defaults().apply(res, true);
|
||||||
|
assertEquals("max-age=31536000", res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hstsRendersIncludeSubDomainsAndPreload() {
|
||||||
|
SecurityHeaders sh = SecurityHeaders.builder()
|
||||||
|
.hsts(Duration.ofDays(365), true, true)
|
||||||
|
.build();
|
||||||
|
Response res = new Response();
|
||||||
|
sh.apply(res, true);
|
||||||
|
assertEquals("max-age=31536000; includeSubDomains; preload",
|
||||||
|
res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noHstsDisablesTheHeaderEvenWhenSecure() {
|
||||||
|
SecurityHeaders sh = SecurityHeaders.builder().noHsts().build();
|
||||||
|
Response res = new Response();
|
||||||
|
sh.apply(res, true);
|
||||||
|
assertNull(res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingHandlerHeadersAreNotOverwritten() {
|
||||||
|
Response res = new Response();
|
||||||
|
res.header("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
|
||||||
|
SecurityHeaders.defaults().apply(res, true);
|
||||||
|
|
||||||
|
assertEquals("SAMEORIGIN", res.headers().get("X-Frame-Options"));
|
||||||
|
// Other headers the handler did not set are still added.
|
||||||
|
assertEquals("nosniff", res.headers().get("X-Content-Type-Options"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingHstsHeaderIsNotOverwritten() {
|
||||||
|
Response res = new Response();
|
||||||
|
res.header("Strict-Transport-Security", "max-age=60");
|
||||||
|
SecurityHeaders.defaults().apply(res, true);
|
||||||
|
assertEquals("max-age=60", res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledHeadersAreOmitted() {
|
||||||
|
SecurityHeaders sh = SecurityHeaders.builder()
|
||||||
|
.contentTypeOptions(false)
|
||||||
|
.frameOptions(null)
|
||||||
|
.referrerPolicy(" ")
|
||||||
|
.noHsts()
|
||||||
|
.build();
|
||||||
|
Response res = new Response();
|
||||||
|
sh.apply(res, true);
|
||||||
|
|
||||||
|
assertNull(res.headers().get("X-Content-Type-Options"));
|
||||||
|
assertNull(res.headers().get("X-Frame-Options"));
|
||||||
|
assertNull(res.headers().get("Referrer-Policy"));
|
||||||
|
assertNull(res.headers().get("Strict-Transport-Security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contentSecurityPolicyAndCustomHeaderAreApplied() {
|
||||||
|
SecurityHeaders sh = SecurityHeaders.builder()
|
||||||
|
.contentSecurityPolicy("default-src 'self'")
|
||||||
|
.header("Permissions-Policy", "geolocation=()")
|
||||||
|
.build();
|
||||||
|
Response res = new Response();
|
||||||
|
sh.apply(res, false);
|
||||||
|
|
||||||
|
assertEquals("default-src 'self'", res.headers().get("Content-Security-Policy"));
|
||||||
|
assertEquals("geolocation=()", res.headers().get("Permissions-Policy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dev.coph.nextusweb.server.tls;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class TlsConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromPemWithMissingFilesThrowsIllegalState() {
|
||||||
|
File missingCert = new File("does-not-exist-cert.pem");
|
||||||
|
File missingKey = new File("does-not-exist-key.pem");
|
||||||
|
assertThrows(IllegalStateException.class, () -> TlsConfig.fromPem(missingCert, missingKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fromSslContextRejectsNull() {
|
||||||
|
assertThrows(NullPointerException.class, () -> TlsConfig.fromSslContext(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsHasExpectedValues() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.defaults();
|
||||||
|
assertEquals(65_536, c.maxFramePayloadLength());
|
||||||
|
assertEquals(1_048_576, c.maxAggregatedMessageSize());
|
||||||
|
assertEquals(Duration.ofSeconds(60), c.idleTimeout());
|
||||||
|
assertFalse(c.allowAnyOrigin());
|
||||||
|
assertTrue(c.allowedOrigins().isEmpty());
|
||||||
|
assertNull(c.subprotocolsCsv());
|
||||||
|
assertTrue(c.compression());
|
||||||
|
assertFalse(c.checkStartsWith());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isOriginAllowedRespectsList() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.builder()
|
||||||
|
.allowedOrigins("https://a", "https://b")
|
||||||
|
.build();
|
||||||
|
assertTrue(c.isOriginAllowed("https://a"));
|
||||||
|
assertTrue(c.isOriginAllowed("https://b"));
|
||||||
|
assertFalse(c.isOriginAllowed("https://c"));
|
||||||
|
assertFalse(c.isOriginAllowed(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyOriginAllowsEverythingExceptNullCheck() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.builder().anyOrigin().build();
|
||||||
|
assertTrue(c.allowAnyOrigin());
|
||||||
|
assertTrue(c.isOriginAllowed("https://anything"));
|
||||||
|
assertTrue(c.isOriginAllowed(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidFramePayloadLengthRejected() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> WebSocketConfig.builder().maxFramePayloadLength(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidAggregatedMessageSizeRejected() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> WebSocketConfig.builder().maxAggregatedMessageSize(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noIdleTimeoutSetsNull() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.builder().noIdleTimeout().build();
|
||||||
|
assertNull(c.idleTimeout());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void subprotocolsCsvJoins() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.builder().subprotocols("a", "b").build();
|
||||||
|
String csv = c.subprotocolsCsv();
|
||||||
|
assertNotNull(csv);
|
||||||
|
assertTrue(csv.contains("a"));
|
||||||
|
assertTrue(csv.contains("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compressionAndCheckStartsWithFlags() {
|
||||||
|
WebSocketConfig c = WebSocketConfig.builder()
|
||||||
|
.compression(false)
|
||||||
|
.checkStartsWith(true)
|
||||||
|
.build();
|
||||||
|
assertFalse(c.compression());
|
||||||
|
assertTrue(c.checkStartsWith());
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import io.netty.channel.ChannelHandler;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketFrameHandlerFactoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createReturnsChannelHandler() {
|
||||||
|
ChannelHandler h = WebSocketFrameHandlerFactory.create(
|
||||||
|
new WebSocketHandler() {},
|
||||||
|
"/ws",
|
||||||
|
Map.of("a", "b"));
|
||||||
|
assertNotNull(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import io.netty.channel.DefaultChannelId;
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketGroupTest {
|
||||||
|
|
||||||
|
private EmbeddedChannel uniqueChannel() {
|
||||||
|
return new EmbeddedChannel(DefaultChannelId.newInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebSocketSession session(EmbeddedChannel ch) {
|
||||||
|
return new WebSocketSession(ch, "/ws", Map.of(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultConstructorHasAnonymousName() {
|
||||||
|
assertEquals("anonymous", new WebSocketGroup().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void namedConstructorRetainsName() {
|
||||||
|
assertEquals("chat", new WebSocketGroup("chat").name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addAndRemoveAdjustSize() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel ch = uniqueChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
g.add(s);
|
||||||
|
assertEquals(1, g.size());
|
||||||
|
g.remove(s);
|
||||||
|
assertEquals(0, g.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastSendsTextToAll() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
EmbeddedChannel b = uniqueChannel();
|
||||||
|
g.add(session(a)).add(session(b));
|
||||||
|
|
||||||
|
g.broadcast("hi");
|
||||||
|
a.runPendingTasks();
|
||||||
|
b.runPendingTasks();
|
||||||
|
Object fa = a.readOutbound();
|
||||||
|
Object fb = b.readOutbound();
|
||||||
|
TextWebSocketFrame ta = assertInstanceOf(TextWebSocketFrame.class, fa);
|
||||||
|
TextWebSocketFrame tb = assertInstanceOf(TextWebSocketFrame.class, fb);
|
||||||
|
assertEquals("hi", ta.text());
|
||||||
|
assertEquals("hi", tb.text());
|
||||||
|
ta.release();
|
||||||
|
tb.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastJsonSendsTextFrames() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
g.add(session(a));
|
||||||
|
|
||||||
|
g.broadcastJson(Map.of("k", "v"));
|
||||||
|
a.runPendingTasks();
|
||||||
|
Object out = a.readOutbound();
|
||||||
|
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||||
|
assertTrue(frame.text().contains("\"k\""));
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastBinarySendsBinaryFrames() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
g.add(session(a));
|
||||||
|
|
||||||
|
g.broadcastBinary(new byte[]{1, 2, 3});
|
||||||
|
a.runPendingTasks();
|
||||||
|
Object out = a.readOutbound();
|
||||||
|
BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out);
|
||||||
|
assertEquals(3, frame.content().readableBytes());
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void broadcastExceptSkipsExcludedSession() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
EmbeddedChannel b = uniqueChannel();
|
||||||
|
WebSocketSession sa = session(a);
|
||||||
|
WebSocketSession sb = session(b);
|
||||||
|
g.add(sa).add(sb);
|
||||||
|
|
||||||
|
g.broadcastExcept(sa, "hello");
|
||||||
|
a.runPendingTasks();
|
||||||
|
b.runPendingTasks();
|
||||||
|
assertNull(a.readOutbound());
|
||||||
|
Object out = b.readOutbound();
|
||||||
|
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||||
|
assertEquals("hello", frame.text());
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeAllClosesUnderlyingChannels() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
g.add(session(a));
|
||||||
|
g.closeAll();
|
||||||
|
a.runPendingTasks();
|
||||||
|
assertFalse(a.isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fluentMethodsReturnGroup() {
|
||||||
|
WebSocketGroup g = new WebSocketGroup("g");
|
||||||
|
EmbeddedChannel a = uniqueChannel();
|
||||||
|
WebSocketSession s = session(a);
|
||||||
|
assertSame(g, g.add(s));
|
||||||
|
assertSame(g, g.broadcast("x"));
|
||||||
|
assertSame(g, g.broadcastBinary(new byte[]{1}));
|
||||||
|
assertSame(g, g.broadcastJson(Map.of("a", 1)));
|
||||||
|
assertSame(g, g.broadcastExcept(null, "y"));
|
||||||
|
assertSame(g, g.remove(s));
|
||||||
|
assertSame(g, g.closeAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketHandlerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultMethodsDoNotThrow() {
|
||||||
|
WebSocketHandler handler = new WebSocketHandler() {};
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession session = new WebSocketSession(ch, "/ws", Map.of(), null);
|
||||||
|
assertDoesNotThrow(() -> handler.onOpen(session));
|
||||||
|
assertDoesNotThrow(() -> handler.onMessage(session, "msg"));
|
||||||
|
assertDoesNotThrow(() -> handler.onBinary(session, new byte[]{1}));
|
||||||
|
assertDoesNotThrow(() -> handler.onClose(session, 1000, "ok"));
|
||||||
|
assertDoesNotThrow(() -> handler.onError(session, new RuntimeException("e")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketRouterTest {
|
||||||
|
|
||||||
|
private final WebSocketHandler handler = new WebSocketHandler() {};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesExactPath() {
|
||||||
|
WebSocketRouter r = new WebSocketRouter().on("/ws", handler);
|
||||||
|
WebSocketRouter.Resolution res = r.resolve("/ws");
|
||||||
|
assertNotNull(res);
|
||||||
|
assertSame(handler, res.handler());
|
||||||
|
assertTrue(res.pathParams().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullForUnknown() {
|
||||||
|
WebSocketRouter r = new WebSocketRouter().on("/ws", handler);
|
||||||
|
assertNull(r.resolve("/missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractsPathParameters() {
|
||||||
|
WebSocketRouter r = new WebSocketRouter().on("/rooms/{id}", handler);
|
||||||
|
WebSocketRouter.Resolution res = r.resolve("/rooms/abc");
|
||||||
|
assertNotNull(res);
|
||||||
|
assertEquals("abc", res.pathParams().get("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onIsFluent() {
|
||||||
|
WebSocketRouter r = new WebSocketRouter();
|
||||||
|
assertSame(r, r.on("/x", handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void interiorNodeWithoutHandlerReturnsNull() {
|
||||||
|
WebSocketRouter r = new WebSocketRouter().on("/a/b", handler);
|
||||||
|
assertNull(r.resolve("/a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dev.coph.nextusweb.server.websocket;
|
||||||
|
|
||||||
|
import io.netty.channel.embedded.EmbeddedChannel;
|
||||||
|
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
|
||||||
|
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||||
|
import io.netty.util.CharsetUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class WebSocketSessionTest {
|
||||||
|
|
||||||
|
private WebSocketSession session(EmbeddedChannel ch) {
|
||||||
|
return new WebSocketSession(ch, "/ws/{id}", Map.of("id", "42"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idIsAssignedAndNonNull() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
assertNotNull(s.id());
|
||||||
|
assertFalse(s.id().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pathAndPathParamExpose() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
assertEquals("/ws/{id}", s.path());
|
||||||
|
assertEquals("42", s.pathParam("id"));
|
||||||
|
assertNull(s.pathParam("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isOpenWhileChannelActive() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
assertTrue(s.isOpen());
|
||||||
|
ch.close();
|
||||||
|
assertFalse(s.isOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void channelGetterReturnsChannel() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
assertSame(ch, s.channel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesSetAndRetrieve() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.attribute("k", "v");
|
||||||
|
assertEquals("v", s.<String>attribute("k"));
|
||||||
|
s.attribute("k", null);
|
||||||
|
assertNull(s.<String>attribute("k"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendWritesTextFrame() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.send("hi");
|
||||||
|
Object out = ch.readOutbound();
|
||||||
|
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||||
|
assertEquals("hi", frame.text());
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendJsonProducesTextFrame() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.sendJson(Map.of("a", "b"));
|
||||||
|
Object out = ch.readOutbound();
|
||||||
|
TextWebSocketFrame frame = assertInstanceOf(TextWebSocketFrame.class, out);
|
||||||
|
String payload = frame.content().toString(CharsetUtil.UTF_8);
|
||||||
|
assertTrue(payload.contains("\"a\""));
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendBinaryProducesBinaryFrame() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.sendBinary(new byte[]{1, 2, 3});
|
||||||
|
Object out = ch.readOutbound();
|
||||||
|
BinaryWebSocketFrame frame = assertInstanceOf(BinaryWebSocketFrame.class, out);
|
||||||
|
assertEquals(3, frame.content().readableBytes());
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pingProducesPingFrame() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.ping();
|
||||||
|
Object out = ch.readOutbound();
|
||||||
|
assertInstanceOf(PingWebSocketFrame.class, out);
|
||||||
|
((PingWebSocketFrame) out).release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeProducesCloseFrameAndClosesChannel() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
s.close(1001, "going-away");
|
||||||
|
Object out = ch.readOutbound();
|
||||||
|
CloseWebSocketFrame frame = assertInstanceOf(CloseWebSocketFrame.class, out);
|
||||||
|
assertEquals(1001, frame.statusCode());
|
||||||
|
assertEquals("going-away", frame.reasonText());
|
||||||
|
frame.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendOnInactiveChannelDoesNotThrow() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
ch.close();
|
||||||
|
assertDoesNotThrow(() -> s.send("ignored"));
|
||||||
|
assertDoesNotThrow(() -> s.sendBinary(new byte[]{1}));
|
||||||
|
assertDoesNotThrow(() -> s.ping());
|
||||||
|
assertDoesNotThrow(() -> s.sendJson(Map.of("a", 1)));
|
||||||
|
assertDoesNotThrow(() -> s.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void remoteAddressReturnsNullForUnconnectedEmbeddedChannel() {
|
||||||
|
EmbeddedChannel ch = new EmbeddedChannel();
|
||||||
|
WebSocketSession s = session(ch);
|
||||||
|
assertDoesNotThrow(s::remoteAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user