diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..688bc5d0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.mvn/ +mvnw +mvnw.cmd +.editorconfig +.gitignore +*.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..760b7795 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5d70067f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI Pipeline +on: + push: + branches: + - main + + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Java 25 + uses: actions/setup-java@v5.2.0 + with: + distribution: 'temurin' + java-version: '25' + cache: 'maven' + + - name: Run tests + run: ./mvnw -B test + + - name: Run spotless checks + run: ./mvnw -B spotless:check \ No newline at end of file diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 00000000..3852b850 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,47 @@ +name: Publish Docker Image +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repo + uses: actions/checkout@v6.0.2 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker BuildX + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/project-webserver-juv25d + tags: type=ref,event=tag + labels: org.opencontainers.image.source=${{ github.repository }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + diff --git a/.gitignore b/.gitignore index 6ac465db..7efc58d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ /.idea/ +/META-INF diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..d38f8868 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..47288f33 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM maven:3.9-eclipse-temurin-25 AS build + +WORKDIR /app + +COPY pom.xml pom.xml +RUN mvn dependency:go-offline -B + +COPY src ./src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine + +WORKDIR /app + +# might need to update this later when we have our explicit class names +COPY --from=build /app/target/app.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index d2be0162..86871819 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,406 @@ -# πŸš€ Create Your First Java Program +# πŸš€ Java HTTP Server – Team juv25d -Java has evolved to become more beginner-friendly. This guide walks you through creating a simple program that prints β€œHello World,” using both the classic syntax and the new streamlined approach introduced in Java 21. +A lightweight, modular HTTP server built from scratch in Java. + +This project demonstrates how web servers and backend frameworks work internally β€” without using Spring, Tomcat, or other high-level frameworks. + +The server is distributed as a Docker image via GitHub Container Registry (GHCR). + +--- + +# πŸ“Œ Project Purpose + +The goal of this project is to deeply understand: + +- How HTTP works +- How requests are parsed +- How responses are constructed +- How middleware (filters) operate +- How backend frameworks structure request lifecycles +- How static file serving works +- How architectural decisions are documented (ADR) +- How Java services are containerized with Docker + +This is an educational backend architecture project. + +--- + +# βš™ Requirements + +- Java 21+ (uses Virtual Threads via Project Loom) +- Docker (for running the official container image) + +--- + +# πŸ— Architecture Overview + +## Request Lifecycle + +``` +Client + ↓ +ServerSocket + ↓ +ConnectionHandler (Virtual Thread) + ↓ +Pipeline + ↓ +FilterChain + ↓ +Plugin + ↓ +HttpResponseWriter + ↓ +Client +``` + +--- + +## 🧩 Core Components + +### Server +- Listens on a configurable port +- Accepts incoming socket connections +- Spawns a virtual thread per request (`Thread.ofVirtual()`) + +### ConnectionHandler +- Parses the HTTP request using `HttpParser` +- Creates a default `HttpResponse` +- Executes the `Pipeline` + +### Pipeline +- Holds global filters +- Holds route-specific filters +- Creates and executes a `FilterChain` +- Executes the active plugin + +### Filters +Used for cross-cutting concerns such as: +- Logging +- Authentication +- Rate limiting +- Validation +- Compression +- Security headers + +### Plugin +Responsible for generating the final HTTP response. + +### HttpParser +Custom HTTP request parser that: +- Parses request line +- Parses headers +- Handles `Content-Length` +- Extracts path and query parameters + +### HttpResponseWriter +Responsible for: +- Writing status line +- Writing headers +- Automatically setting `Content-Length` +- Writing response body + +--- + +# 🐳 Running the Server (Official Method) + +The official way to run the server is via Docker using GitHub Container Registry. + +Docker must be installed and running. + +--- + +## Step 1 – Login to GHCR + +```bash +docker login ghcr.io -u +``` + +Use your GitHub Personal Access Token (classic) as password. + +--- + +## Step 2 – Pull the latest image + +```bash +docker pull ghcr.io/ithsjava25/project-webserver-juv25d:latest +``` + +--- + +## Step 3 – Run the container + +```bash +docker run -p 8080:8080 ghcr.io/ithsjava25/project-webserver-juv25d:latest +``` --- -## ✨ Classic Java Approach +## Step 4 – Open in browser + +``` +http://localhost:8080 +``` + +The server runs on port **8080**. -Traditionally, Java requires a class with a `main` method as the entry point: +--- + +# πŸ›  Running in Development (IDE) + +For development purposes, you can run the server directly from your IDE: + +1. Open the project. +2. Run the class: + +``` +org.juv25d.App +``` + +3. Open: + +``` +http://localhost:8080 +``` + +Note: The project is packaged as a fat JAR using the Maven Shade Plugin, so you can run it with `java -jar target/app.jar`. + +--- + +# 🌐 Static File Serving + +The `StaticFilesPlugin` serves files from: + +``` +src/main/resources/static/ +``` + +### Example Mapping + +| File | URL | +|------|------| +| index.html | `/` | +| css/styles.css | `/css/styles.css` | +| js/app.js | `/js/app.js` | + +### Security Features + +- Path traversal prevention +- MIME type detection +- 404 handling +- 403 handling +- Clean URLs (no `/static/` prefix) + +For full architectural reasoning, see: + +➑ `docs/adr/ADR-001-static-file-serving-architecture.md` + +--- + +# πŸ”„ Creating a Filter + +Filters intercept requests before they reach the plugin. + +A filter can: + +- Inspect or modify `HttpRequest` +- Inspect or modify `HttpResponse` +- Stop the chain (e.g., return 403) +- Continue processing by calling `chain.doFilter(req, res)` + +--- + +## Filter Interface + +```java +public interface Filter { + void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException; +} +``` + +--- + +## Example: LoggingFilter ```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World"); +public class LoggingFilter implements Filter { + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + System.out.println(req.method() + " " + req.path()); + chain.doFilter(req, res); } } ``` -This works across all Java versions and forms the foundation of most Java programs. +--- + +## Registering a Global Filter + +```java +pipeline.addGlobalFilter(new LoggingFilter(), 100); +``` + +Lower order values execute first. --- -## πŸ†• Java 25: Unnamed Class with Instance Main Method +# 🎯 Route-Specific Filters + +Route filters only execute when the request path matches a pattern. -In newer versions like **Java 25**, you can use **Unnamed Classes** and an **Instance Main Method**, which allows for a much cleaner syntax: +### Supported Patterns + +- `/api/*` β†’ matches paths starting with `/api/` +- `/login` β†’ exact match +- `/admin/*` β†’ wildcard support (prefix-based) + +--- + +## Example ```java -void main() { - System.out.println("Hello World"); +pipeline.addRouteFilter(new JwtAuthFilter(), 100, "/api/*"); +``` + +--- + +## Execution Flow + +``` +Client β†’ Filter 1 β†’ Filter 2 β†’ ... β†’ Plugin β†’ Response β†’ Client +``` + +--- + +# 🧠 Creating a Plugin + +Plugins generate the final HTTP response. + +They run after all filters have completed. + +--- + +## Plugin Interface + +```java +public interface Plugin { + void handle(HttpRequest req, HttpResponse res) throws IOException; } ``` -### πŸ’‘ Why is this cool? +--- -- βœ… No need for a `public class` declaration -- βœ… No `static` keyword required -- βœ… Great for quick scripts and learning +## Example: HelloPlugin -To compile and run this, use: +```java +public class HelloPlugin implements Plugin { -```bash -java --source 25 HelloWorld.java + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + res.setStatusCode(200); + res.setStatusText("OK"); + res.setHeader("Content-Type", "text/plain"); + res.setBody("Hello from juv25d server".getBytes()); + } +} ``` --- -## πŸ“š Learn More +## Registering a Plugin + +```java +pipeline.setPlugin(new HelloPlugin()); +``` + +--- + +# βš™ Configuration + +Configuration is loaded from: + +``` +application-properties.yml +``` + +Example: + +```yaml +server: + port: 8080 + root-dir: static + +logging: + level: INFO +``` + +--- + +# πŸ“¦ Features + +- Custom HTTP request parser (`HttpParser`) +- Custom HTTP response writer (`HttpResponseWriter`) +- Mutable HTTP response model +- Filter chain architecture +- Plugin system +- Static file serving +- MIME type resolution +- Path traversal protection +- Virtual threads (Project Loom) +- YAML configuration (SnakeYAML) +- Dockerized distribution +- Published container image (GHCR) + +--- + +# πŸ“š Documentation & Architecture Decisions + +Additional technical documentation is available in the `docs/` directory. + +## Architecture Decision Records (ADR) + +Contains architectural decisions and their reasoning. + +``` +docs/adr/ +``` + +Main index: + +``` +docs/adr/README.md +``` + +Includes: + +- Static file serving architecture +- ADR template +- Future architecture decisions + +--- + +## Technical Notes + +Advanced filter configuration examples: + +``` +docs/notes/ +``` + +--- + +# πŸŽ“ Educational Value + +This project demonstrates: + +- How web servers work internally +- How middleware pipelines are implemented +- How static file serving works +- How architectural decisions are documented +- How Java services are containerized and distributed + +--- + +# πŸ‘₯ Team juv25d -This feature is part of Java’s ongoing effort to streamline syntax. You can explore deeper in [Baeldung’s guide to Unnamed Classes and Instance Main Methods](https://www.baeldung.com/java-21-unnamed-class-instance-main). +Built as a learning project to deeply understand HTTP, backend systems, and modular server architecture. diff --git a/docs/adr/ADR-001-static-file-serving-architecture.md b/docs/adr/ADR-001-static-file-serving-architecture.md new file mode 100644 index 00000000..6ad14b04 --- /dev/null +++ b/docs/adr/ADR-001-static-file-serving-architecture.md @@ -0,0 +1,249 @@ +# ADR-001: Static File Serving Architecture + +**Date:** 2026-02-11 +**Status:** Proposed +**Deciders:** Team juv25d +**Technical Story:** Issue #18 - GET handling for static files + +--- + +## Context + +Our HTTP server needs the ability to serve static files (HTML, CSS, JavaScript, images, etc.) to support building complete web applications. Currently, our server can parse HTTP requests and send responses, but has no mechanism to serve files from the filesystem. + +### Problem Statement + +We need to implement a static file serving mechanism that: +- Serves files from a designated directory structure +- Maps URLs to filesystem paths safely +- Handles different file types with appropriate Content-Type headers +- Provides reasonable error handling (404, 500, etc.) +- Follows familiar conventions for ease of use + +### Assumptions + +- Static files will be bundled with the application at build time +- Files will be served from the classpath/resources directory +- We're building a development/learning server (not production-grade like Nginx) +- Performance requirements are moderate (not handling thousands of requests/second) + +### Constraints + +- Must work with our existing `HttpParser`, `HttpResponse`, and `HttpResponseWriter` classes +- Should integrate cleanly with the `SocketServer` connection handling +- Must run inside a Docker container with resources directory available +- Team is learning HTTP and web server concepts - architecture should be educational + +--- + +## Decision + +We will implement a **SpringBoot-style static file serving architecture** with the following design: + +### Chosen Solution + +**1. Directory Structure:** +``` +src/main/resources/ +└── static/ + β”œβ”€β”€ index.html + β”œβ”€β”€ css/ + β”‚ └── styles.css + β”œβ”€β”€ js/ + β”‚ └── app.js + └── images/ + └── logo.png +``` + +**2. URL Mapping:** +- Files in `/resources/static/` are served at the root path +- Example: `/resources/static/css/styles.css` β†’ `GET /css/styles.css` +- Root path `/` automatically serves `index.html` if it exists + +**3. Core Components:** + +``` +StaticFileHandler +β”œβ”€β”€ Validates request path (security) +β”œβ”€β”€ Maps URL to resource path +β”œβ”€β”€ Reads file from classpath +β”œβ”€β”€ Determines MIME type +└── Creates HttpResponse with proper headers + +MimeTypeResolver +└── Maps file extensions to Content-Type headers + +Security validator +└── Prevents directory traversal attacks +``` + +**4. Security Measures:** +- Path normalization to prevent `../` attacks +- Whitelist only files within `/static/` directory +- Reject paths containing `..`, absolute paths, or suspicious patterns +- Return 403 Forbidden for security violations + +**5. Error Handling:** +- 404 Not Found: File doesn't exist +- 403 Forbidden: Security violation detected +- 500 Internal Server Error: I/O errors + +**6. MIME Type Handling:** +- Simple extension-based mapping (.html β†’ text/html, .css β†’ text/css, etc.) +- Default to `application/octet-stream` for unknown types +- Support common web file types (HTML, CSS, JS, PNG, JPG, SVG, etc.) + +### Why This Solution? + +1. **Familiar to developers:** SpringBoot convention is widely known and documented +2. **Simple mental model:** Root path maps to `/static/` - easy to understand +3. **Classpath-based:** Works well with JAR packaging and Docker containers +4. **Educational:** Clear separation of concerns teaches good architecture +5. **Extensible:** Easy to add features later (caching, compression, etc.) + +--- + +## Consequences + +### Positive Consequences + +- **Developer Experience:** Developers familiar with SpringBoot will immediately understand the structure +- **Security by Design:** Explicit security validation prevents common vulnerabilities +- **Clean URLs:** No `/static/` prefix in URLs keeps them clean +- **Easy Testing:** Classpath resources are easy to test with JUnit +- **Docker-Friendly:** Resources directory is included in the container image +- **Clear Responsibility:** `StaticFileHandler` has a single, well-defined purpose + +### Negative Consequences / Trade-offs + +- **No Dynamic Content:** This approach only handles static files (but that's the requirement) +- **No Caching:** Every request reads from disk (acceptable for learning project) +- **Limited Performance:** Not optimized for high-traffic scenarios +- **Memory Usage:** Entire files loaded into memory before sending +- **No Range Requests:** Cannot handle partial content requests (HTTP 206) + +### Risks + +- **Large Files:** Loading very large files into memory could cause issues + - *Mitigation:* Document file size limitations, implement streaming later if needed + +- **MIME Type Accuracy:** Simple extension mapping might not always be correct + - *Mitigation:* Cover most common web file types, extend mapping as needed + +- **Classpath Resources:** Files must be in classpath at runtime + - *Mitigation:* Clear documentation about where to place files + +--- + +## Alternatives Considered + +### Alternative 1: Filesystem-based serving (outside classpath) + +**Description:** Serve files from a configurable filesystem directory outside the application. + +**Pros:** +- Files can be updated without rebuilding +- More flexible for deployment +- Easier to handle very large files + +**Cons:** +- More complex configuration +- Path handling is platform-dependent +- Docker volume mounting adds complexity +- Harder to test (need actual filesystem) + +**Why not chosen:** Adds unnecessary complexity for a learning project. Classpath resources are simpler and work well in Docker. + +### Alternative 2: Embedded file map (all files in memory) + +**Description:** Load all static files into a HashMap at startup. + +**Pros:** +- Fastest possible serving (no I/O) +- Very simple lookup logic +- Predictable memory usage + +**Cons:** +- Cannot add files without restart +- High memory usage for many/large files +- Startup time increases +- Not representative of real web servers + +**Why not chosen:** Not scalable and doesn't teach realistic server behavior. Reading from resources is fast enough. + +### Alternative 3: Show /static/ in URLs + +**Description:** Map `/static/file.html` β†’ `/resources/static/file.html` + +**Pros:** +- More explicit about what's being served +- Easier to implement (direct path mapping) +- Clear separation from dynamic routes + +**Cons:** +- Less clean URLs +- Not how SpringBoot or most frameworks work +- Exposing internal structure in URLs + +**Why not chosen:** Doesn't follow common web conventions. Clean URLs are expected behavior. + +--- + +## Implementation Notes + +### Phase 1: Core Implementation +1. Create `StaticFileHandler` class +2. Implement path validation and security checks +3. Create `MimeTypeResolver` utility +4. Integrate with existing `SocketServer` / connection handling +5. Add unit tests for all components + +### Phase 2: Integration +6. Create example static files in `/resources/static/` +7. Update `SocketServer` to use `StaticFileHandler` for GET requests +8. Test with browser +9. Document usage in README + +### Phase 3: Polish +10. Add logging for security violations +11. Create custom 404 error page +12. Add metrics/logging for file serving + +### Example Usage (Future): + +```java +// In connection handler: +if (request.method().equals("GET")) { + HttpResponse response = StaticFileHandler.handleRequest(request); + HttpResponseWriter.write(outputStream, response); +} +``` + +### File Structure After Implementation: + +``` +src/main/resources/static/ +β”œβ”€β”€ index.html (served at GET /) +β”œβ”€β”€ about.html (served at GET /about.html) +β”œβ”€β”€ css/ +β”‚ └── styles.css (served at GET /css/styles.css) +└── js/ + └── app.js (served at GET /js/app.js) +``` + +--- + +## References + +- [Issue #18: GET handling for static files](https://github.com/your-repo/issues/18) +- [SpringBoot Static Content Documentation](https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.servlet.spring-mvc.static-content) +- [OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal) +- [MDN HTTP Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) +- [Common MIME Types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) + +--- + +## Related ADRs + +- ADR-002: (Future) Caching Strategy for Static Files +- ADR-003: (Future) Routing Architecture for Dynamic Handlers diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..4a20561c --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,93 @@ +# Architectural Decision Records (ADR) + +This directory contains all Architectural Decision Records for the JavaHttpServer project. + +## What is an ADR? + +An ADR is a document that captures an important architectural decision made along with its context and consequences. This helps the team: + +- Understand why certain design choices were made +- Onboard new team members faster +- Avoid repeating past discussions +- Track the evolution of the system + +## ADR Format + +We use the format described in [Joel Parker Henderson's ADR repository](https://github.com/joelparkerhenderson/architecture-decision-record). + +Each ADR includes: +- **Context:** Why the decision is needed +- **Decision:** What choice was made +- **Consequences:** Trade-offs, pros, and cons + +## Creating a New ADR + +1. Copy `TEMPLATE.md` to a new file named `ADR-XXX-brief-title.md` (e.g., `ADR-001-use-maven-for-build.md`) +2. Fill in all sections of the template +3. Discuss with the team before marking as "Accepted" +4. Commit the ADR to the repository + +## Naming Convention + +- Files are named: `ADR-XXX-descriptive-kebab-case-title.md` +- XXX is a zero-padded sequential number (001, 002, etc.) +- Titles should be brief but descriptive + +## ADR Status + +An ADR can have one of the following statuses: + +- **Proposed:** Under discussion +- **Accepted:** Decision has been made and is active +- **Deprecated:** No longer relevant but kept for historical context +- **Superseded:** Replaced by another ADR (reference the new ADR) + +## Index of ADRs + +| ADR | Title | Status | Date | +|-----|-------|--------|------| +| [001](ADR-001-static-file-serving-architecture.md) | Static File Serving Architecture | Accepted | 2026-02-11 | + +--- + +## Best Practices + +### When to Create an ADR + +Create an ADR when: +- Making a significant architectural choice +- Choosing between multiple viable technical solutions +- Making a decision that will be hard to reverse +- Implementing a pattern that the whole team should follow +- Resolving a technical dispute + +### When NOT to Create an ADR + +Don't create ADRs for: +- Minor code style preferences (use linting/formatting tools) +- Trivial implementation details +- Temporary workarounds +- Decisions that are easily reversible + +### Writing Good ADRs + +**DO:** +- Be specific and concrete +- Include timestamps for time-sensitive information +- Explain the "why" clearly +- Consider alternatives thoroughly +- Keep it focused on one decision + +**DON'T:** +- Change existing ADRs (amend or create new ones instead) +- Make them too long (aim for 1-2 pages) +- Skip the "Consequences" section +- Leave out the context + +--- + +## Resources + +- [ADR GitHub Organization](https://adr.github.io/) +- [Joel Parker Henderson's ADR repo](https://github.com/joelparkerhenderson/architecture-decision-record) +- [Documenting Architecture Decisions by Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) diff --git a/docs/adr/TEMPLATE.md b/docs/adr/TEMPLATE.md new file mode 100644 index 00000000..88447b81 --- /dev/null +++ b/docs/adr/TEMPLATE.md @@ -0,0 +1,80 @@ +# ADR-XXX: [Title of Decision] + +**Date:** YYYY-MM-DD +**Status:** [Proposed | Accepted | Deprecated | Superseded] +**Deciders:** [Names of people involved] +**Technical Story:** [Link to issue/ticket if applicable] + +--- + +## Context + +What is the issue we're seeing that motivates this decision or change? + +### Problem Statement +[Describe the problem clearly] + +### Assumptions +[Any assumptions being made] + +### Constraints +[Technical, time, resource constraints] + +--- + +## Decision + +What is the change that we're proposing and/or doing? + +### Chosen Solution +[Describe the solution] + +### Why This Solution? +[Key reasons for choosing this approach] + +--- + +## Consequences + +What becomes easier or more difficult to do because of this change? + +### Positive Consequences +- [benefit 1] +- [benefit 2] + +### Negative Consequences / Trade-offs +- [drawback 1] +- [drawback 2] + +### Risks +- [risk 1 and mitigation] + +--- + +## Alternatives Considered + +### Alternative 1: [Name] +- **Description:** [Brief description] +- **Pros:** [List advantages] +- **Cons:** [List disadvantages] +- **Why not chosen:** [Reason] + +### Alternative 2: [Name] +- **Description:** [Brief description] +- **Pros:** [List advantages] +- **Cons:** [List disadvantages] +- **Why not chosen:** [Reason] + +--- + +## Implementation Notes + +[Any specific implementation details, migration paths, or action items] + +--- + +## References + +- [Link to documentation] +- [Link to related ADRs] +- [Link to discussion/PR] diff --git a/docs/notes/pipeline-usage.md b/docs/notes/pipeline-usage.md new file mode 100644 index 00000000..670822c5 --- /dev/null +++ b/docs/notes/pipeline-usage.md @@ -0,0 +1,69 @@ + // Global filters: Applied to every request + // These filters run on all routers regardless of path + + // Access log filter + // pipeline.addGlobalFilter(new AccessLogFilter(), 100); + + // File format compression filter + // pipeline.addGlobalFilter(new CompressionFilter(), 200); + + //Filter by IP + // pipeline.addGlobalFilter(new IpFilter(), 300); + + + // Add more global filters here + // Example: Security headers, CORS, Request ID, etc. + // pipeline.addGlobalFilter(new SecurityHeadersFilter(), 400); + // pipeline.addGlobalFilter(new CorsFilter(), 500); + // pipeline.addGlobalFilter(new RequestIdFilter(), 600); + + // ROUTE-SPECIFIC FILTERS - Only for matching paths + // These filters only execute when the request path matches the pattern + // Patterns: + // - "/api/*" = any path starting with /api/ + // - "/login" = exact match only + // - "/admin/**" = recursive wildcard (if implemented) + + + // Rate limiting filter + // pipeline.addRouteFilter(new RateLimitFilter(5, 60000), 100, "/login"); + + // Apply to API endpoints (generous rate limit) + // pipeline.addRouteFilter(new RateLimitFilter(1000, 60000), 100, "/api/*"); + + // Apply to static files (high rate limit) + // pipeline.addRouteFilter(new RateLimitFilter(5000, 60000), 100, "/static/*"); + + // Cancels requests that take too long to process + // pipeline.addRouteFilter(new TimeoutFilter(5000), 200, "/reports/*"); + + // Apply to export endpoints (large data) + // pipeline.addRouteFilter(new TimeoutFilter(30000), 200, "/api/export/*"); + + // Apply to default API endpoints + // pipeline.addRouteFilter(new TimeoutFilter(10000), 200, "/api/*"); + + + // Authentication & Authorization + // pipeline.addRouteFilter(new JwtAuthFilter(), 100, "/api/*"); + // pipeline.addRouteFilter(new BasicAuthFilter(), 100, "/admin/*"); + // pipeline.addRouteFilter(new ApiKeyFilter(), 100, "/partner/*"); + + // Request Validation + // pipeline.addRouteFilter(new BodyValidationFilter(), 300, "/api/*"); + // pipeline.addRouteFilter(new ContentTypeFilter(), 150, "/api/*"); + + // Cache Control + // pipeline.addRouteFilter(new CacheFilter(), 400, "/static/*"); + // pipeline.addRouteFilter(new EtagFilter(), 450, "/api/*"); + + // Request Transformation + // pipeline.addRouteFilter(new BodyParserFilter(), 50, "/api/*"); + // pipeline.addRouteFilter(new MultipartParserFilter(), 60, "/upload"); + + // Plugin: Final request handler, application logic, executed after all filters pass. + // Replace HelloPlugin with your actual application plugin + + // Filter initialization + // Calls init() on all registered filters + // Some filters might need setup (loading config, connecting to DB, etc) diff --git a/mvnw b/mvnw new file mode 100755 index 00000000..bd8896bf --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..5761d948 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 6b7ade11..34bfbeba 100644 --- a/pom.xml +++ b/pom.xml @@ -4,16 +4,17 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.example - JavaTemplate + org.juv25d + JavaHttpServer 1.0-SNAPSHOT - 23 + 25 UTF-8 6.0.2 3.27.7 5.21.0 + @@ -34,6 +35,29 @@ ${mockito.version} test + + org.yaml + snakeyaml + 2.5 + + + com.bucket4j + bucket4j_jdk17-core + 8.16.1 + compile + + + org.testcontainers + testcontainers + 2.0.3 + test + + + org.testcontainers + junit-jupiter + 1.21.4 + test + @@ -52,35 +76,30 @@ maven-install-plugin 3.1.4 - - org.apache.maven.plugins - maven-jar-plugin - 3.5.0 - org.apache.maven.plugins maven-resources-plugin 3.4.0 - org.apache.maven.plugins - maven-dependency-plugin - 3.9.0 - - - - properties - - - + org.apache.maven.plugins + maven-dependency-plugin + 3.9.0 + + + + properties + + + org.apache.maven.plugins maven-surefire-plugin 3.5.4 - - @{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off - + + @{argLine} -Xshare:off + org.apache.maven.plugins @@ -118,6 +137,68 @@ + + com.diffplug.spotless + spotless-maven-plugin + 3.2.1 + + + + src/main/java/**/*.java + src/test/java/**/*.java + + + + + + + + + + org.pitest + pitest-maven + 1.22.0 + + + + org.pitest + pitest-junit5-plugin + 1.2.2 + + + + + + org.juv25d.* + + + org.juv25d.* + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + app + false + false + + + org.juv25d.App + + + + + + diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java deleted file mode 100644 index 165e5cd5..00000000 --- a/src/main/java/org/example/App.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.example; - -public class App { - public static void main(String[] args) { - System.out.println("Hello There!"); - } -} diff --git a/src/main/java/org/juv25d/App.java b/src/main/java/org/juv25d/App.java new file mode 100644 index 00000000..5aa62d4f --- /dev/null +++ b/src/main/java/org/juv25d/App.java @@ -0,0 +1,70 @@ +package org.juv25d; + +import org.juv25d.filter.*; +import org.juv25d.logging.ServerLogging; +import org.juv25d.http.HttpParser; +import org.juv25d.plugin.NotFoundPlugin; // New import +import org.juv25d.plugin.StaticFilesPlugin; +import org.juv25d.router.SimpleRouter; // New import +import org.juv25d.util.ConfigLoader; + +import java.util.List; + +import java.util.Set; +import java.util.logging.Logger; + +public class App { + public static void main(String[] args) { + ConfigLoader config = ConfigLoader.getInstance(); + Logger logger = ServerLogging.getLogger(); + HttpParser httpParser = new HttpParser(); + + Pipeline pipeline = new Pipeline(); + + pipeline.addGlobalFilter(new SecurityHeadersFilter(), 0); + + // Configure redirect rules + List redirectRules = List.of( + new RedirectRule("/old-page", "/new-page", 301), + new RedirectRule("/temp", "https://example.com/temporary", 302), + new RedirectRule("/docs/*", "/documentation/", 301) + ); + pipeline.addGlobalFilter(new RedirectFilter(redirectRules), 0); + + // IP filter is enabled but configured with open access during development + // White/blacklist can be tightened when specific IP restrictions are decided + pipeline.addGlobalFilter(new IpFilter( + Set.of(), + Set.of() + ), 0); + + pipeline.addGlobalFilter(new LoggingFilter(), 0); + + if (config.isRateLimitingEnabled()) { + pipeline.addGlobalFilter(new RateLimitingFilter( + config.getRequestsPerMinute(), + config.getBurstCapacity() + ), 0); + } + + // Initialize and configure SimpleRouter + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", new StaticFilesPlugin()); // Register StaticFilesPlugin for the root path + router.registerPlugin("/*", new StaticFilesPlugin()); // Register StaticFilesPlugin for all paths + router.registerPlugin("/notfound", new NotFoundPlugin()); // Example: Register NotFoundPlugin for a specific path + + pipeline.setRouter(router); // Set the router in the pipeline + + DefaultConnectionHandlerFactory handlerFactory = + new DefaultConnectionHandlerFactory(httpParser, logger, pipeline); + + Server server = new Server( + config.getPort(), + logger, + handlerFactory, + pipeline + ); + + server.start(); + } +} diff --git a/src/main/java/org/juv25d/ConnectionHandler.java b/src/main/java/org/juv25d/ConnectionHandler.java new file mode 100644 index 00000000..cf33252f --- /dev/null +++ b/src/main/java/org/juv25d/ConnectionHandler.java @@ -0,0 +1,65 @@ +package org.juv25d; + +import org.juv25d.http.HttpParser; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.http.HttpResponseWriter; + +import java.io.IOException; +import java.net.Socket; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ConnectionHandler implements Runnable { + private final Socket socket; + private final HttpParser httpParser; + private final Logger logger; + private final Pipeline pipeline; + + public ConnectionHandler(Socket socket, HttpParser httpParser, Logger logger, Pipeline pipeline) { + this.socket = socket; + this.httpParser = httpParser; + this.logger = logger; + this.pipeline = pipeline; + } + + @Override + public void run() { + String connectionId = java.util.UUID.randomUUID().toString().substring(0, 8); + org.juv25d.logging.LogContext.setConnectionId(connectionId); + try (socket) { + var in = socket.getInputStream(); + var out = socket.getOutputStream(); + + HttpRequest parsed = httpParser.parse(in); + String remoteIp = socket.getInetAddress().getHostAddress(); + + HttpRequest request = new HttpRequest( + parsed.method(), + parsed.path(), + parsed.queryString(), + parsed.httpVersion(), + parsed.headers(), + parsed.body(), + remoteIp + ); + + HttpResponse response = new HttpResponse( + 200, + "OK", + java.util.Map.of(), + new byte[0] + ); + + var chain = pipeline.createChain(request); + chain.doFilter(request, response); + + HttpResponseWriter.write(out, response); + + } catch (IOException e) { + logger.log(Level.SEVERE, "Error while handling request", e); + } finally { + org.juv25d.logging.LogContext.clear(); + } + } +} diff --git a/src/main/java/org/juv25d/ConnectionHandlerFactory.java b/src/main/java/org/juv25d/ConnectionHandlerFactory.java new file mode 100644 index 00000000..750707f0 --- /dev/null +++ b/src/main/java/org/juv25d/ConnectionHandlerFactory.java @@ -0,0 +1,7 @@ +package org.juv25d; + +import java.net.Socket; + +public interface ConnectionHandlerFactory { + Runnable create(Socket socket, Pipeline pipeline); +} diff --git a/src/main/java/org/juv25d/DefaultConnectionHandlerFactory.java b/src/main/java/org/juv25d/DefaultConnectionHandlerFactory.java new file mode 100644 index 00000000..39474dcc --- /dev/null +++ b/src/main/java/org/juv25d/DefaultConnectionHandlerFactory.java @@ -0,0 +1,23 @@ +package org.juv25d; + +import org.juv25d.http.HttpParser; + +import java.net.Socket; +import java.util.logging.Logger; + +public class DefaultConnectionHandlerFactory implements ConnectionHandlerFactory{ + private final HttpParser httpParser; + private final Logger logger; + private final Pipeline pipeline; + + public DefaultConnectionHandlerFactory(HttpParser httpParser, Logger logger, Pipeline pipeline) { + this.httpParser = httpParser; + this.logger = logger; + this.pipeline = pipeline; + } + + @Override + public Runnable create(Socket socket, Pipeline pipeline) { + return new ConnectionHandler(socket, httpParser, logger, pipeline); + } +} diff --git a/src/main/java/org/juv25d/FilterRegistration.java b/src/main/java/org/juv25d/FilterRegistration.java new file mode 100644 index 00000000..1ede5679 --- /dev/null +++ b/src/main/java/org/juv25d/FilterRegistration.java @@ -0,0 +1,12 @@ +package org.juv25d; + +import org.juv25d.filter.Filter; + +public record FilterRegistration(Filter filter, int order, String pattern) + implements Comparable { + + @Override + public int compareTo(FilterRegistration o) { + return Integer.compare(this.order, o.order); + } +} diff --git a/src/main/java/org/juv25d/Pipeline.java b/src/main/java/org/juv25d/Pipeline.java new file mode 100644 index 00000000..c226db8d --- /dev/null +++ b/src/main/java/org/juv25d/Pipeline.java @@ -0,0 +1,74 @@ +package org.juv25d; + +import org.juv25d.filter.Filter; +import org.juv25d.filter.FilterChainImpl; +import org.juv25d.http.HttpRequest; +import org.juv25d.router.Router; // New import + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +public class Pipeline { + + private final List globalFilters = new CopyOnWriteArrayList<>(); + private final Map> routeFilters = new ConcurrentHashMap<>(); + private volatile List sortedGlobalFilters = List.of(); + private volatile Router router; // Changed from Plugin plugin; + + public void addGlobalFilter(Filter filter, int order) { + globalFilters.add(new FilterRegistration(filter, order, null)); + sortedGlobalFilters = globalFilters.stream() + .sorted() + .map(FilterRegistration::filter) + .collect(Collectors.toUnmodifiableList()); + } + + public void addRouteFilter(Filter filter, int order, String pattern) { + routeFilters.computeIfAbsent(pattern, k -> new CopyOnWriteArrayList<>()) + .add(new FilterRegistration(filter, order, pattern)); + } + + public void setRouter(Router router) { + if (router == null) { + throw new IllegalArgumentException("Router cannot be null"); + } + this.router = router; + } + + public FilterChainImpl createChain(HttpRequest request) { + List filters = new ArrayList<>(); + filters.addAll(sortedGlobalFilters); + String path = request.path(); + List exactMatches = routeFilters.get(path); + if (exactMatches != null) { + exactMatches.stream() + .sorted() + .map(FilterRegistration::filter) + .forEach(filters::add); + } + + for (Map.Entry> entry : routeFilters.entrySet()) { + String pattern = entry.getKey(); + if (pattern.endsWith("*") && path.startsWith(pattern.substring(0, pattern.length() - 1))) { + entry.getValue().stream() + .sorted() + .map(FilterRegistration::filter) + .forEach(filters::add); + } + } + return new FilterChainImpl(filters, router); // Pass router instead of plugin + } + + public List getFilters() { + return Collections.unmodifiableList(sortedGlobalFilters); + } + + public Router getRouter() { // Renamed from getPlugin + return router; + } +} diff --git a/src/main/java/org/juv25d/Server.java b/src/main/java/org/juv25d/Server.java new file mode 100644 index 00000000..35296633 --- /dev/null +++ b/src/main/java/org/juv25d/Server.java @@ -0,0 +1,35 @@ +package org.juv25d; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.logging.Logger; + +public class Server { + private final int port; + private final Logger logger; + private final ConnectionHandlerFactory handlerFactory; + private final Pipeline pipeline; + + public Server(int port, Logger logger, ConnectionHandlerFactory handlerFactory, Pipeline pipeline) { + this.port = port; + this.logger = logger; + this.handlerFactory = handlerFactory; + this.pipeline = pipeline; + } + + public void start() { + try (ServerSocket serverSocket = new ServerSocket(port, 64)) { + logger.info("Server started at port: " + serverSocket.getLocalPort()); + + while (true) { + Socket socket = serverSocket.accept(); + Runnable handler = handlerFactory.create(socket, pipeline); + Thread.ofVirtual().start(handler); + } + + } catch (IOException e) { + throw new RuntimeException("Server error", e); + } + } +} diff --git a/src/main/java/org/juv25d/filter/Filter.java b/src/main/java/org/juv25d/filter/Filter.java new file mode 100644 index 00000000..0682b3f5 --- /dev/null +++ b/src/main/java/org/juv25d/filter/Filter.java @@ -0,0 +1,12 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public interface Filter { + default void init() {} + void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException; + default void destroy() {} +} diff --git a/src/main/java/org/juv25d/filter/FilterChain.java b/src/main/java/org/juv25d/filter/FilterChain.java new file mode 100644 index 00000000..0df68c7c --- /dev/null +++ b/src/main/java/org/juv25d/filter/FilterChain.java @@ -0,0 +1,10 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public interface FilterChain { + void doFilter(HttpRequest req, HttpResponse res) throws IOException; +} diff --git a/src/main/java/org/juv25d/filter/FilterChainImpl.java b/src/main/java/org/juv25d/filter/FilterChainImpl.java new file mode 100644 index 00000000..abb7c7cc --- /dev/null +++ b/src/main/java/org/juv25d/filter/FilterChainImpl.java @@ -0,0 +1,30 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.router.Router; // New import + +import java.io.IOException; +import java.util.List; + +public class FilterChainImpl implements FilterChain { + + private final List filters; + private final Router router; // Changed from Plugin plugin; + private int index = 0; + + public FilterChainImpl(List filters, Router router) { // Changed constructor parameter + this.filters = filters; + this.router = router; // Changed assignment + } + + @Override + public void doFilter(HttpRequest req, HttpResponse res) throws IOException { + if (index < filters.size()) { + Filter next = filters.get(index++); + next.doFilter(req, res, this); + } else { + router.resolve(req).handle(req, res); // Use router to resolve and handle + } + } +} diff --git a/src/main/java/org/juv25d/filter/IpFilter.java b/src/main/java/org/juv25d/filter/IpFilter.java new file mode 100644 index 00000000..15af306a --- /dev/null +++ b/src/main/java/org/juv25d/filter/IpFilter.java @@ -0,0 +1,54 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +public class IpFilter implements Filter { + + private final Set whitelist; + private final Set blacklist; + + public IpFilter(Set whitelist, Set blacklist) { + this.whitelist = whitelist; + this.blacklist = blacklist; + } + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + String clientIp = getClientIp(req); + + boolean allowed; + if (whitelist != null && !whitelist.isEmpty()) { + allowed = whitelist.contains(clientIp); + } else if (blacklist != null && !blacklist.isEmpty()){ + allowed = !blacklist.contains(clientIp); + } else { + allowed = true; + } + + if(!allowed){ + forbidden(res, clientIp); + return; + } + chain.doFilter(req, res); + } + + private String getClientIp(HttpRequest req){ + return req.remoteIp(); + } + + private void forbidden(HttpResponse res, String ip) { + byte[] body = ("403 Forbidden: IP not allowed (" + ip + ")\n") + .getBytes(StandardCharsets.UTF_8); + + res.setStatusCode(403); + res.setStatusText("Forbidden"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Content-Length", String.valueOf(body.length)); + res.setBody(body); + } +} diff --git a/src/main/java/org/juv25d/filter/LoggingFilter.java b/src/main/java/org/juv25d/filter/LoggingFilter.java new file mode 100644 index 00000000..2419873c --- /dev/null +++ b/src/main/java/org/juv25d/filter/LoggingFilter.java @@ -0,0 +1,18 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.logging.ServerLogging; + +import java.io.IOException; +import java.util.logging.Logger; + +public class LoggingFilter implements Filter { + private static final Logger logger = ServerLogging.getLogger(); + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + logger.info(req.method() + " " + req.path()); + chain.doFilter(req, res); + } +} diff --git a/src/main/java/org/juv25d/filter/RateLimitingFilter.java b/src/main/java/org/juv25d/filter/RateLimitingFilter.java new file mode 100644 index 00000000..dc9a38d7 --- /dev/null +++ b/src/main/java/org/juv25d/filter/RateLimitingFilter.java @@ -0,0 +1,128 @@ +package org.juv25d.filter; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.logging.ServerLogging; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * A filter that implements rate limiting for incoming HTTP requests. + * It uses a token bucket algorithm via Bucket4J to limit the number of requests per client IP. + */ +public class RateLimitingFilter implements Filter { + + private static final Logger logger = ServerLogging.getLogger(); + + private final Map buckets = new ConcurrentHashMap<>(); + + private final long capacity; + private final long refillTokens; + private final Duration refillPeriod; + + /** + * Constructs a new RateLimitingFilter. + * + * @param requestsPerMinute the number of requests allowed per minute for each IP + * @param burstCapacity the maximum number of requests that can be handled in a burst + * @throws IllegalArgumentException if requestsPerMinute or burstCapacity is not positive + */ + public RateLimitingFilter(long requestsPerMinute, long burstCapacity) { + if (requestsPerMinute <= 0) { + throw new IllegalArgumentException("requestsPerMinute must be positive"); + } + if (burstCapacity <= 0) { + throw new IllegalArgumentException("burstCapacity must be positive"); + } + + this.capacity = burstCapacity; + this.refillTokens = requestsPerMinute; + this.refillPeriod = Duration.ofMinutes(1); + + logger.info(String.format( + "RateLimitingFilter initialized - Limit: %d req/min, Burst: %d", + requestsPerMinute, burstCapacity + )); + } + + /** + * Applies the rate limiting logic to the incoming request. + * If the rate limit is exceeded, a 429 Too Many Requests response is sent. + * + * @param req the HTTP request + * @param res the HTTP response + * @param chain the filter chain + * @throws IOException if an I/O error occurs + */ + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + String clientIp = getClientIp(req); + + Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createBucket()); + + if (bucket.tryConsume(1)) { + chain.doFilter(req, res); + } else { + logRateLimitExceeded(clientIp, req.method(), req.path()); + sendTooManyRequests(res, clientIp); + } + } + + private String getClientIp(HttpRequest req) { + return req.remoteIp(); + } + + private Bucket createBucket() { + Bandwidth limit = Bandwidth.classic( + capacity, + Refill.intervally(refillTokens, refillPeriod)); + + return Bucket.builder() + .addLimit(limit) + .build(); + } + + /** + * Returns the number of currently tracked IP addresses. + * + * @return the number of tracked IP addresses + */ + public int getTrackedIpCount() { + return buckets.size(); + } + + private void logRateLimitExceeded(String ip, String method, String path) { + logger.warning(String.format( + "Rate limit exceeded - IP: %s, Method: %s, Path: %s", + ip, method, path + )); + } + + private void sendTooManyRequests(HttpResponse res, String ip) { + byte[] body = ("429 Too Many Requests: Rate limit exceeded for IP " + ip + "\n") + .getBytes(StandardCharsets.UTF_8); + + res.setStatusCode(429); + res.setStatusText("Too Many Requests"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.setHeader("Content-Length", String.valueOf(body.length)); + res.setHeader("Retry-After", "60"); + res.setBody(body); + } + + /** + * Clears all tracked rate limiting buckets. + */ + @Override + public void destroy() { + buckets.clear(); + } +} diff --git a/src/main/java/org/juv25d/filter/RedirectFilter.java b/src/main/java/org/juv25d/filter/RedirectFilter.java new file mode 100644 index 00000000..5be71e88 --- /dev/null +++ b/src/main/java/org/juv25d/filter/RedirectFilter.java @@ -0,0 +1,83 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; +import java.util.List; +import java.util.logging.Logger; + +/** + * Filter that handles URL redirects based on configurable rules. + * + * When a request matches a redirect rule: + * - The pipeline is stopped (no further processing) + * - A redirect response is returned with the appropriate Location header + * - Status code is either 301 (Moved Permanently) or 302 (Found/Temporary) + * + * Rules are evaluated in order - the first matching rule wins. + * + * Example usage: + *
+ * List rules = List.of(
+ *     new RedirectRule("/old-page", "/new-page", 301),
+ *     new RedirectRule("/temp", "https://example.com", 302),
+ *     new RedirectRule("/docs/*", "/documentation/", 301)
+ * );
+ * pipeline.addFilter(new RedirectFilter(rules));
+ * 
+ */ +public class RedirectFilter implements Filter { + private final List rules; + private final Logger logger; + + /** + * Creates a redirect filter with the given rules. + * + * @param rules List of redirect rules (evaluated in order) + */ + public RedirectFilter(List rules) { + this.rules = rules; + this.logger = Logger.getLogger(RedirectFilter.class.getName()); + } + + @Override + public void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) throws IOException { + String requestPath = request.path(); + + // Check each rule in order - first match wins + for (RedirectRule rule : rules) { + if (rule.matches(requestPath)) { + logger.info("Redirecting: " + requestPath + " -> " + rule.getTargetUrl() + + " (" + rule.getStatusCode() + ")"); + + performRedirect(response, rule); + + // Stop pipeline - don't call chain.doFilter() + return; + } + } + + // No matching rule - continue pipeline + chain.doFilter(request, response); + } + + /** + * Sets up the redirect response with appropriate headers. + * + * @param response The HTTP response to modify + * @param rule The redirect rule to apply + */ + private void performRedirect(HttpResponse response, RedirectRule rule) { + int statusCode = rule.getStatusCode(); + String statusText = statusCode == 301 ? "Moved Permanently" : "Found"; + + response.setStatusCode(statusCode); + response.setStatusText(statusText); + response.setHeader("Location", rule.getTargetUrl()); + response.setHeader("Content-Length", "0"); + + // Empty body for redirects + response.setBody(new byte[0]); + } +} diff --git a/src/main/java/org/juv25d/filter/RedirectRule.java b/src/main/java/org/juv25d/filter/RedirectRule.java new file mode 100644 index 00000000..ed6de85b --- /dev/null +++ b/src/main/java/org/juv25d/filter/RedirectRule.java @@ -0,0 +1,98 @@ +package org.juv25d.filter; + +import java.util.regex.Pattern; + +/** + * Represents a URL redirect rule. + * + * A redirect rule consists of: + * - sourcePath: The path to match (supports exact match or wildcards with *) + * - targetUrl: The URL to redirect to + * - statusCode: HTTP status code (301 for permanent, 302 for temporary) + * + * Examples: + * - new RedirectRule("/old-page", "/new-page", 301) + * - new RedirectRule("/temp", "https://example.com", 302) + * - new RedirectRule("/docs/*", "/documentation/", 301) + */ +public class RedirectRule { + private final String sourcePath; + private final String targetUrl; + private final int statusCode; + + /** + * Creates a redirect rule with exact path matching. + * + * @param sourcePath The path to match (e.g., "/old-page" or "/docs/*" for wildcard) + * @param targetUrl The URL to redirect to + * @param statusCode HTTP status code (301 or 302) + */ + public RedirectRule(String sourcePath, String targetUrl, int statusCode) { + validateStatusCode(statusCode); + this.sourcePath = sourcePath; + this.targetUrl = targetUrl; + this.statusCode = statusCode; + } + + /** + * Checks if the given request path matches this rule. + * + * Supports: + * - Exact matching: "/old-page" matches exactly "/old-page" + * - Wildcard matching: "/docs/*" matches "/docs/api", "/docs/guide", etc. + * + * @param requestPath The request path to check + * @return true if the path matches this rule + */ + public boolean matches(String requestPath) { + if (requestPath == null) { + return false; + } + + // Check for wildcard matching + if (sourcePath.contains("*")) { + // Split on wildcard, escape each literal segment, then rejoin with ".*" + String[] parts = sourcePath.split("\\*", -1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + sb.append(Pattern.quote(parts[i])); + if (i < parts.length - 1) { + sb.append(".*"); + } + } + return requestPath.matches(sb.toString()); + } + + // Exact match + return requestPath.equals(sourcePath); + } + + public String getSourcePath() { + return sourcePath; + } + + public String getTargetUrl() { + return targetUrl; + } + + public int getStatusCode() { + return statusCode; + } + + private void validateStatusCode(int statusCode) { + if (statusCode != 301 && statusCode != 302) { + throw new IllegalArgumentException( + "Status code must be 301 (Moved Permanently) or 302 (Found). Got: " + statusCode + ); + } + } + + @Override + public String toString() { + return "RedirectRule{" + + "sourcePath='" + sourcePath + '\'' + + ", targetUrl='" + targetUrl + '\'' + + ", statusCode=" + statusCode + + '}'; + } +} diff --git a/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java b/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java new file mode 100644 index 00000000..49bd8390 --- /dev/null +++ b/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java @@ -0,0 +1,31 @@ + +package org.juv25d.filter; + +import org.juv25d.filter.annotation.Global; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import java.io.IOException; + +/** + * Filter that adds security headers to every HTTP response. + * This helps protect against attacks such as Clickjacking and MIME sniffing. + */ +@Global(order = 0) +public class SecurityHeadersFilter implements Filter { + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + try { + chain.doFilter(req, res); + } finally { + + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "0"); + res.setHeader("Referrer-Policy", "no-referrer"); + + } + } + } + + diff --git a/src/main/java/org/juv25d/filter/annotation/Global.java b/src/main/java/org/juv25d/filter/annotation/Global.java new file mode 100644 index 00000000..bc0a843b --- /dev/null +++ b/src/main/java/org/juv25d/filter/annotation/Global.java @@ -0,0 +1,12 @@ +package org.juv25d.filter.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Global { + int order() default 0; +} diff --git a/src/main/java/org/juv25d/filter/annotation/Route.java b/src/main/java/org/juv25d/filter/annotation/Route.java new file mode 100644 index 00000000..9ad6856d --- /dev/null +++ b/src/main/java/org/juv25d/filter/annotation/Route.java @@ -0,0 +1,13 @@ +package org.juv25d.filter.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Route { + String[] value(); + int order() default 0; +} diff --git a/src/main/java/org/juv25d/handler/MimeTypeResolver.java b/src/main/java/org/juv25d/handler/MimeTypeResolver.java new file mode 100644 index 00000000..01b6877a --- /dev/null +++ b/src/main/java/org/juv25d/handler/MimeTypeResolver.java @@ -0,0 +1,69 @@ +package org.juv25d.handler; + +import java.util.HashMap; +import java.util.Map; + +/** + * Resolves MIME types based on file extensions. + * Used to set the correct Content-Type header for HTTP responses. + */ +public class MimeTypeResolver { + + private static final Map MIME_TYPES = new HashMap<>(); + + static { + // Text types + MIME_TYPES.put("html", "text/html"); + MIME_TYPES.put("htm", "text/html"); + MIME_TYPES.put("css", "text/css"); + MIME_TYPES.put("js", "application/javascript"); + MIME_TYPES.put("json", "application/json"); + MIME_TYPES.put("xml", "application/xml"); + MIME_TYPES.put("txt", "text/plain"); + + // Image types + MIME_TYPES.put("png", "image/png"); + MIME_TYPES.put("jpg", "image/jpeg"); + MIME_TYPES.put("jpeg", "image/jpeg"); + MIME_TYPES.put("gif", "image/gif"); + MIME_TYPES.put("svg", "image/svg+xml"); + MIME_TYPES.put("ico", "image/x-icon"); + MIME_TYPES.put("webp", "image/webp"); + + // Font types + MIME_TYPES.put("woff", "font/woff"); + MIME_TYPES.put("woff2", "font/woff2"); + MIME_TYPES.put("ttf", "font/ttf"); + MIME_TYPES.put("otf", "font/otf"); + + // Other common types + MIME_TYPES.put("pdf", "application/pdf"); + MIME_TYPES.put("zip", "application/zip"); + } + + private MimeTypeResolver() { + // Utility class - prevent instantiation + } + + /** + * Gets the MIME type for a given filename. + * + * @param filename the name of the file (e.g., "index.html", "styles.css") + * @return the MIME type (e.g., "text/html", "text/css") + * or "application/octet-stream" if unknown + */ + public static String getMimeType(String filename) { + if (filename == null || filename.isEmpty()) { + return "application/octet-stream"; + } + + int lastDot = filename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == filename.length() - 1) { + // No extension or dot is last character + return "application/octet-stream"; + } + + String extension = filename.substring(lastDot + 1).toLowerCase(); + return MIME_TYPES.getOrDefault(extension, "application/octet-stream"); + } +} diff --git a/src/main/java/org/juv25d/handler/StaticFileHandler.java b/src/main/java/org/juv25d/handler/StaticFileHandler.java new file mode 100644 index 00000000..0c28d56b --- /dev/null +++ b/src/main/java/org/juv25d/handler/StaticFileHandler.java @@ -0,0 +1,213 @@ +package org.juv25d.handler; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.logging.ServerLogging; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Handles serving static files from the /resources/static/ directory. + *

+ * URL mapping: + * - GET / β†’ /resources/static/index.html + * - GET /about.html β†’ /resources/static/about.html + * - GET /css/styles.css β†’ /resources/static/css/styles.css + *

+ * Security: Validates paths to prevent directory traversal attacks. + */ +public class StaticFileHandler { + + private static final Logger logger = ServerLogging.getLogger(); + private static final String STATIC_DIR = "/static/"; + + private StaticFileHandler() { + // Utility class - prevent instantiation + } + + /** + * Handles a static file request. + * + * @param request the HTTP request + * @return an HTTP response with the file content or an error response + */ + public static HttpResponse handle(HttpRequest request) { + String path = request.path(); + + // Only handle GET requests + if (!request.method().equalsIgnoreCase("GET")) { + return createErrorResponse(405, "Method Not Allowed"); + } + + // Validate path for security + if (!isPathSafe(path)) { + logger.warning("Security violation: Attempted path traversal with path: " + path); + return createErrorResponse(403, "Forbidden"); + } + + // Map URL path to resource path + String resourcePath = mapToResourcePath(path); + + logger.info("Attempting to serve static file: " + resourcePath); + + // Try to load and serve the file + try { + byte[] fileContent = loadResource(resourcePath); + String mimeType = MimeTypeResolver.getMimeType(resourcePath); + + // Add charset for text-based content types + if (mimeType.startsWith("text/") || + mimeType.contains("javascript") || + mimeType.contains("json")) { + mimeType += "; charset=utf-8"; + } + + Map headers = new HashMap<>(); + headers.put("Content-Type", mimeType); + + logger.info("Successfully served: " + resourcePath + " (" + mimeType + ")"); + return new HttpResponse(200, "OK", headers, fileContent); + + } catch (IOException e) { + logger.log(Level.WARNING, "File not found: " + resourcePath); + return createNotFoundResponse(path); + } + } + + /** + * Validates that the path is safe and doesn't contain directory traversal attempts. + * + * @param path the requested path + * @return true if the path is safe, false otherwise + */ + private static boolean isPathSafe(String path) { + if (path == null || path.isEmpty()) { + return false; + } + + // Reject paths with directory traversal attempts + if (path.contains("..") || path.contains("//") || path.contains("\\")) { + return false; + } + + // Reject absolute paths (should start with /) + if (!path.startsWith("/")) { + return false; + } + + return true; + } + + /** + * Maps a URL path to a resource path in /resources/static/. + *

+ * Examples: + * - "/" β†’ "/static/index.html" + * - "/about.html" β†’ "/static/about.html" + * - "/css/styles.css" β†’ "/static/css/styles.css" + * + * @param urlPath the URL path from the request + * @return the resource path + */ + private static String mapToResourcePath(String urlPath) { + // Handle root path - serve index.html + if (urlPath.equals("/")) { + return STATIC_DIR + "index.html"; + } + + // Remove leading slash and prepend /static/ + String cleanPath = urlPath.startsWith("/") ? urlPath.substring(1) : urlPath; + return STATIC_DIR + cleanPath; + } + + /** + * Loads a resource from the classpath. + * + * @param resourcePath the path to the resource (e.g., "/static/index.html") + * @return the file content as bytes + * @throws IOException if the resource cannot be loaded + */ + private static byte[] loadResource(String resourcePath) throws IOException { + InputStream inputStream = StaticFileHandler.class.getResourceAsStream(resourcePath); + + if (inputStream == null) { + throw new IOException("Resource not found: " + resourcePath); + } + + try (inputStream) { + return inputStream.readAllBytes(); + } + } + + /** + * Creates a 404 Not Found error response with HTML content. + */ + private static HttpResponse createNotFoundResponse(String path) { + String html = """ + + + + + 404 Not Found + + + +

404 - Not Found

+

The requested resource %s was not found on this server.

+ + + """.formatted(path); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "text/html; charset=utf-8"); + + return new HttpResponse(404, "Not Found", headers, html.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Creates a generic error response. + */ + private static HttpResponse createErrorResponse(int statusCode, String statusText) { + String html = """ + + + + + %d %s + + + +

%d - %s

+ + + """.formatted(statusCode, statusText, statusCode, statusText); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "text/html; charset=utf-8"); + + return new HttpResponse(statusCode, statusText, headers, html.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/org/juv25d/http/HttpParser.java b/src/main/java/org/juv25d/http/HttpParser.java new file mode 100644 index 00000000..c6aa742f --- /dev/null +++ b/src/main/java/org/juv25d/http/HttpParser.java @@ -0,0 +1,82 @@ +package org.juv25d.http; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.TreeMap; + +public class HttpParser { + + private static final String CONTENT_LENGTH = "Content-Length"; + + public HttpRequest parse(InputStream in) throws IOException { + + String requestLine = readLine(in); + if (requestLine == null || requestLine.isEmpty()) { + throw new IOException("The request is empty"); + } + + String[] parts = requestLine.split("\\s+"); + if (parts.length < 3) { + throw new IOException("Malformed request line: " + requestLine); + } + String method = parts[0]; + String fullPath = parts[1]; + String version = parts[2]; + + String path; + String query = null; + + int qIndex = fullPath.indexOf('?'); + if (qIndex >= 0) { + path = fullPath.substring(0, qIndex); + query = fullPath.substring(qIndex + 1); + } else { + path = fullPath; + } + + Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + String line; + while ((line = readLine(in)) != null && !line.isEmpty()) { + int colon = line.indexOf(':'); + if (colon <= 0) { + throw new IOException("Malformed header line: " + line); + } + String key = line.substring(0, colon).trim(); + String value = line.substring(colon + 1).trim(); + headers.put(key, value); + } + + byte[] body = new byte[0]; + if (headers.containsKey(CONTENT_LENGTH)) { + int length; + try { + length = Integer.parseInt(headers.get(CONTENT_LENGTH)); + } catch (NumberFormatException e) { + throw new IOException("Invalid Content-Length: " + headers.get(CONTENT_LENGTH), e); + } + if (length < 0) { + throw new IOException("Negative Content-Length: " + length); + } + body = in.readNBytes(length); + } + return new HttpRequest(method, path, query, version, headers, body, "UNKNOWN"); + } + + private String readLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int b; + while ((b = in.read()) != -1) { + if (b == '\n') { + break; + } + if (b != '\r') { + sb.append((char) b); + } + } + if (b == -1 && sb.isEmpty()) { + return null; + } + return sb.toString(); + } +} diff --git a/src/main/java/org/juv25d/http/HttpRequest.java b/src/main/java/org/juv25d/http/HttpRequest.java new file mode 100644 index 00000000..e76592af --- /dev/null +++ b/src/main/java/org/juv25d/http/HttpRequest.java @@ -0,0 +1,13 @@ +package org.juv25d.http; + +import java.util.Map; + +public record HttpRequest( + String method, + String path, + String queryString, + String httpVersion, + Map headers, + byte[] body, + String remoteIp +) {} diff --git a/src/main/java/org/juv25d/http/HttpResponse.java b/src/main/java/org/juv25d/http/HttpResponse.java new file mode 100644 index 00000000..805f6b12 --- /dev/null +++ b/src/main/java/org/juv25d/http/HttpResponse.java @@ -0,0 +1,61 @@ +package org.juv25d.http; + +import java.util.*; + +/** + * Represents an HTTP Response. + * Changed to be mutable to allow Filters and Plugins in the Pipeline + * to modify status, headers, and body during processing. + */ +public class HttpResponse { + + private int statusCode; + private String statusText; + private Map headers; + private byte[] body; + + public HttpResponse(){ + this.headers = new LinkedHashMap<>(); + this.body = new byte[0]; + } + + public HttpResponse(int statusCode, String statusText, Map headers, byte[] body) { + this.statusCode = statusCode; + this.statusText = statusText; + this.headers = new LinkedHashMap<>(headers); + this.body = body != null ? body.clone() : new byte[0]; + } + + public int statusCode() { + return statusCode; + } + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public String statusText() { + return statusText; + } + + public void setStatusText(String statusText) { + Objects.requireNonNull(statusText, "statusText must not be null"); + this.statusText = statusText; + } + + public Map headers() { + return headers; + } + + public void setHeader(String name, String value) { + headers.put(name, value); + } + + public byte[] body() { + return body != null ? body.clone() : new byte[0]; + } + + public void setBody(byte[] body) { + this.body = body != null ? body.clone() : new byte[0]; + } +} diff --git a/src/main/java/org/juv25d/http/HttpResponseWriter.java b/src/main/java/org/juv25d/http/HttpResponseWriter.java new file mode 100644 index 00000000..08aa7ab9 --- /dev/null +++ b/src/main/java/org/juv25d/http/HttpResponseWriter.java @@ -0,0 +1,50 @@ +package org.juv25d.http; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class HttpResponseWriter { + + private HttpResponseWriter() { + } + + // This method should be called by SocketServer/ConnectionHandler later + public static void write(OutputStream out, HttpResponse response) throws IOException { + writeStatusLine(out, response); + writeHeaders(out, response.headers(), response.body()); + writeBody(out, response.body()); + out.flush(); + } + + private static void writeStatusLine(OutputStream out, HttpResponse response) throws IOException { + String statusLine = + "HTTP/1.1 " + response.statusCode() + " " + response.statusText() + "\r\n"; + out.write(statusLine.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeHeaders( + OutputStream out, + Map headers, + byte[] body + ) throws IOException { + + for (Map.Entry header : headers.entrySet()) { + if (!header.getKey().equalsIgnoreCase("Content-Length")) { + String line = header.getKey() + ": " + header.getValue() + "\r\n"; + out.write(line.getBytes(StandardCharsets.UTF_8)); + } + } + + String contentLength = "Content-Length: " + body.length + "\r\n"; + out.write(contentLength.getBytes(StandardCharsets.UTF_8)); + + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + + + private static void writeBody(OutputStream out, byte[] body) throws IOException { + out.write(body); + } +} diff --git a/src/main/java/org/juv25d/logging/LogContext.java b/src/main/java/org/juv25d/logging/LogContext.java new file mode 100644 index 00000000..93977edf --- /dev/null +++ b/src/main/java/org/juv25d/logging/LogContext.java @@ -0,0 +1,17 @@ +package org.juv25d.logging; + +public class LogContext { + private static final ThreadLocal connectionId = new ThreadLocal<>(); + + public static void setConnectionId(String id) { + connectionId.set(id); + } + + public static String getConnectionId() { + return connectionId.get(); + } + + public static void clear() { + connectionId.remove(); + } +} diff --git a/src/main/java/org/juv25d/logging/ServerLogFormatter.java b/src/main/java/org/juv25d/logging/ServerLogFormatter.java new file mode 100644 index 00000000..1d1c8382 --- /dev/null +++ b/src/main/java/org/juv25d/logging/ServerLogFormatter.java @@ -0,0 +1,23 @@ +package org.juv25d.logging; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.logging.Formatter; +import java.util.logging.LogRecord; + +public class ServerLogFormatter extends Formatter { + private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public String format(LogRecord record) { + String connectionId = LogContext.getConnectionId(); + String idPart = (connectionId != null) ? " [" + connectionId + "]" : ""; + + return String.format("%s %s%s: %s%n", + ZonedDateTime.now(ZoneId.systemDefault()).format(dtf), + record.getLevel(), + idPart, + formatMessage(record)); + } +} diff --git a/src/main/java/org/juv25d/logging/ServerLogging.java b/src/main/java/org/juv25d/logging/ServerLogging.java new file mode 100644 index 00000000..b7fddcae --- /dev/null +++ b/src/main/java/org/juv25d/logging/ServerLogging.java @@ -0,0 +1,47 @@ +package org.juv25d.logging; + +import org.juv25d.util.ConfigLoader; + +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +public class ServerLogging { + private static final Logger logger = + Logger.getLogger(ServerLogging.class.getName()); + + static { + configure(logger); + } + + static void configure(Logger logger) { + logger.setUseParentHandlers(false); + + if (logger.getHandlers().length == 0) { + ConsoleHandler handler = new ConsoleHandler(); + handler.setFormatter(new ServerLogFormatter()); + logger.addHandler(handler); + } + + String levelName = System.getProperty( + "log.level", + System.getenv().getOrDefault("LOG_LEVEL", ConfigLoader.getInstance().getLogLevel()) + ); + + try { + Level level = Level.parse(levelName.toUpperCase()); + logger.setLevel(level); + } catch (IllegalArgumentException e) { + logger.setLevel(Level.INFO); + logger.warning("Invalid log level: '" + levelName + "', defaulting to INFO"); + } + } + + private ServerLogging() {} + + public static Logger getLogger() { + return logger; + } +} + diff --git a/src/main/java/org/juv25d/plugin/HelloPlugin.java b/src/main/java/org/juv25d/plugin/HelloPlugin.java new file mode 100644 index 00000000..d267bf33 --- /dev/null +++ b/src/main/java/org/juv25d/plugin/HelloPlugin.java @@ -0,0 +1,14 @@ +package org.juv25d.plugin; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public class HelloPlugin implements Plugin { + + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + // placeholder response logic + } +} diff --git a/src/main/java/org/juv25d/plugin/NotFoundPlugin.java b/src/main/java/org/juv25d/plugin/NotFoundPlugin.java new file mode 100644 index 00000000..e48637bc --- /dev/null +++ b/src/main/java/org/juv25d/plugin/NotFoundPlugin.java @@ -0,0 +1,15 @@ +package org.juv25d.plugin; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public class NotFoundPlugin implements Plugin { + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + res.setStatusCode(404); + res.setStatusText("Not Found"); + res.setBody("404 - Resource Not Found".getBytes()); + } +} \ No newline at end of file diff --git a/src/main/java/org/juv25d/plugin/Plugin.java b/src/main/java/org/juv25d/plugin/Plugin.java new file mode 100644 index 00000000..a2dceb5c --- /dev/null +++ b/src/main/java/org/juv25d/plugin/Plugin.java @@ -0,0 +1,10 @@ +package org.juv25d.plugin; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; + +public interface Plugin { + void handle (HttpRequest req, HttpResponse res) throws IOException; +} diff --git a/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java b/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java new file mode 100644 index 00000000..00ae8012 --- /dev/null +++ b/src/main/java/org/juv25d/plugin/StaticFilesPlugin.java @@ -0,0 +1,32 @@ +package org.juv25d.plugin; + +import org.juv25d.handler.StaticFileHandler; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; + +import java.io.IOException; +import java.util.Map; + +/** + * Plugin that serves static files using StaticFileHandler. + * Integrates with the Pipeline architecture to handle GET requests for static resources. + * + */ +public class StaticFilesPlugin implements Plugin { + + @Override + public void handle(HttpRequest request, HttpResponse response) throws IOException { + // Use StaticFileHandler to handle the request + HttpResponse staticResponse = StaticFileHandler.handle(request); + + // Copy the response from StaticFileHandler to the pipeline response + response.setStatusCode(staticResponse.statusCode()); + response.setStatusText(staticResponse.statusText()); + + for (Map.Entry header : staticResponse.headers().entrySet()) { + response.setHeader(header.getKey(), header.getValue()); + } + response.setBody(staticResponse.body()); + } +} + diff --git a/src/main/java/org/juv25d/router/Router.java b/src/main/java/org/juv25d/router/Router.java new file mode 100644 index 00000000..34534a30 --- /dev/null +++ b/src/main/java/org/juv25d/router/Router.java @@ -0,0 +1,19 @@ +package org.juv25d.router; + +import org.juv25d.http.HttpRequest; +import org.juv25d.plugin.Plugin; + +/** + * The Router interface defines a contract for components that resolve an incoming HTTP request + * to a specific Plugin instance responsible for handling that request. + */ +public interface Router { + + /** + * Resolves the given HttpRequest to a Plugin that can handle it. + * + * @param request The incoming HttpRequest. + * @return The Plugin instance responsible for handling the request. Must not be null. + */ + Plugin resolve(HttpRequest request); +} diff --git a/src/main/java/org/juv25d/router/SimpleRouter.java b/src/main/java/org/juv25d/router/SimpleRouter.java new file mode 100644 index 00000000..c6f7cd1d --- /dev/null +++ b/src/main/java/org/juv25d/router/SimpleRouter.java @@ -0,0 +1,69 @@ +package org.juv25d.router; + +import org.juv25d.http.HttpRequest; +import org.juv25d.plugin.NotFoundPlugin; +import org.juv25d.plugin.Plugin; + +import java.util.HashMap; +import java.util.Map; +import java.util.Comparator; + +/** + * A simple router implementation that maps request paths to specific Plugin instances. + * If no specific plugin is registered for a path, it defaults to a NotFoundPlugin. + */ +public class SimpleRouter implements Router { + + private final Map routes; + private final Plugin notFoundPlugin; + + public SimpleRouter() { + this.routes = new HashMap<>(); + this.notFoundPlugin = new NotFoundPlugin(); + } + + /** + * Registers a plugin for a specific path. + * + * @param path The path for which the plugin should handle requests. + * @param plugin The plugin to handle requests for the given path. + */ + public void registerPlugin(String path, Plugin plugin) { + routes.put(path, plugin); + } + + /** + * Resolves the given HttpRequest to a Plugin that can handle it. + * Resolution order: + * 1. Exact path match + * 2. Wildcard match (longest prefix wins) + * 3. NotFoundPlugin + * + * @param request The incoming HttpRequest. + * @return The Plugin instance responsible for handling the request. + */ + @Override + public Plugin resolve(HttpRequest request) { + String path = request.path(); + + // 1. Exact match + Plugin exactMatch = routes.get(path); + if (exactMatch != null) { + return exactMatch; + } + + // 2. Wildcard match (deterministic: longest prefix first) + return routes.entrySet().stream() + .filter(entry -> entry.getKey().endsWith("/*")) + .sorted(Comparator.comparingInt( + entry -> -entry.getKey().length() + )) // longest (most specific) first + .filter(entry -> { + String prefix = entry.getKey().substring(0, entry.getKey().length() - 1); + return path.startsWith(prefix); + }) + .map(Map.Entry::getValue) + .findFirst() + .orElse(notFoundPlugin); + } +} diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java new file mode 100644 index 00000000..ffd49e4b --- /dev/null +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -0,0 +1,90 @@ +package org.juv25d.util; + +import org.yaml.snakeyaml.Yaml; + +import java.io.InputStream; +import java.util.Map; + +public class ConfigLoader { + private static ConfigLoader instance; + private int port; + private String logLevel; + private String rootDirectory; + private long requestsPerMinute; + private long burstCapacity; + private boolean rateLimitingEnabled; + + private ConfigLoader() { + loadConfiguration(); + } + + public static synchronized ConfigLoader getInstance() { + if (instance == null) { + instance = new ConfigLoader(); + } + return instance; + } + + private void loadConfiguration() { + Yaml yaml = new Yaml(); + + try (InputStream input = getClass().getClassLoader().getResourceAsStream("application-properties.yml")) { + if (input == null) { + throw new IllegalArgumentException("Did not find application-properties.yml"); + } + + Map config = yaml.load(input); + + // server + Map serverConfig = (Map) config.get("server"); + if (serverConfig != null) { + this.port = (Integer) serverConfig.getOrDefault("port", 8080); + this.rootDirectory = (String) serverConfig.getOrDefault("root-dir", "static"); + } + + // logging + Map loggingConfig = (Map) config.get("logging"); + if (loggingConfig != null) { + this.logLevel = (String) loggingConfig.get("level"); + } + + // rate-limiting + Map rateLimitingConfig = (Map) config.get("rate-limiting"); + if (rateLimitingConfig != null) { + this.rateLimitingEnabled = (Boolean) rateLimitingConfig.getOrDefault("enabled", true); + this.requestsPerMinute = ((Number) rateLimitingConfig.getOrDefault("requests-per-minute", 60L)).longValue(); + this.burstCapacity = ((Number) rateLimitingConfig.getOrDefault("burst-capacity", 100L)).longValue(); + } else { + // rate-limiting is disabled if not present in the config file. + this.rateLimitingEnabled = false; + } + + } catch (Exception e) { + throw new RuntimeException("Failed to load application config"); + } + } + + public int getPort() { + return port; + } + + public String getLogLevel() { + return logLevel; + } + + public String getRootDirectory() { + return rootDirectory; + } + + public long getRequestsPerMinute() { + return requestsPerMinute; + } + + public long getBurstCapacity() { + return burstCapacity; + } + + public boolean isRateLimitingEnabled() { + return rateLimitingEnabled; + } +} diff --git a/src/main/resources/application-properties.yml b/src/main/resources/application-properties.yml new file mode 100644 index 00000000..49d571a3 --- /dev/null +++ b/src/main/resources/application-properties.yml @@ -0,0 +1,11 @@ +server: + port: 8080 + root-dir: static + +logging: + level: INFO + +rate-limiting: + enabled: true + requests-per-minute: 60 + burst-capacity: 100 diff --git a/src/main/resources/static/README.md b/src/main/resources/static/README.md new file mode 100644 index 00000000..42b35656 --- /dev/null +++ b/src/main/resources/static/README.md @@ -0,0 +1,406 @@ +# πŸš€ Java HTTP Server – Team juv25d + +A lightweight, modular HTTP server built from scratch in Java. + +This project demonstrates how web servers and backend frameworks work internally β€” without using Spring, Tomcat, or other high-level frameworks. + +The server is distributed as a Docker image via GitHub Container Registry (GHCR). + +--- + +# πŸ“Œ Project Purpose + +The goal of this project is to deeply understand: + +- How HTTP works +- How requests are parsed +- How responses are constructed +- How middleware (filters) operate +- How backend frameworks structure request lifecycles +- How static file serving works +- How architectural decisions are documented (ADR) +- How Java services are containerized with Docker + +This is an educational backend architecture project. + +--- + +# βš™ Requirements + +- Java 21+ (uses Virtual Threads via Project Loom) +- Docker (for running the official container image) + +--- + +# πŸ— Architecture Overview + +## Request Lifecycle + +``` +Client + ↓ +ServerSocket + ↓ +ConnectionHandler (Virtual Thread) + ↓ +Pipeline + ↓ +FilterChain + ↓ +Plugin + ↓ +HttpResponseWriter + ↓ +Client +``` + +--- + +## 🧩 Core Components + +### Server +- Listens on a configurable port +- Accepts incoming socket connections +- Spawns a virtual thread per request (`Thread.ofVirtual()`) + +### ConnectionHandler +- Parses the HTTP request using `HttpParser` +- Creates a default `HttpResponse` +- Executes the `Pipeline` + +### Pipeline +- Holds global filters +- Holds route-specific filters +- Creates and executes a `FilterChain` +- Executes the active plugin + +### Filters +Used for cross-cutting concerns such as: +- Logging +- Authentication +- Rate limiting +- Validation +- Compression +- Security headers + +### Plugin +Responsible for generating the final HTTP response. + +### HttpParser +Custom HTTP request parser that: +- Parses request line +- Parses headers +- Handles `Content-Length` +- Extracts path and query parameters + +### HttpResponseWriter +Responsible for: +- Writing status line +- Writing headers +- Automatically setting `Content-Length` +- Writing response body + +--- + +# 🐳 Running the Server (Official Method) + +The official way to run the server is via Docker using GitHub Container Registry. + +Docker must be installed and running. + +--- + +## Step 1 – Login to GHCR + +```bash +docker login ghcr.io -u +``` + +Use your GitHub Personal Access Token (classic) as password. + +--- + +## Step 2 – Pull the latest image + +```bash +docker pull ghcr.io/ithsjava25/project-webserver-juv25d:latest +``` + +--- + +## Step 3 – Run the container + +```bash +docker run -p 3000:3000 ghcr.io/ithsjava25/project-webserver-juv25d:latest +``` + +--- + +## Step 4 – Open in browser + +``` +http://localhost:3000 +``` + +The server runs on port **3000**. + +--- + +# πŸ›  Running in Development (IDE) + +For development purposes, you can run the server directly from your IDE: + +1. Open the project. +2. Run the class: + +``` +org.juv25d.App +``` + +3. Open: + +``` +http://localhost:3000 +``` + +Note: The project is packaged as a fat JAR using the Maven Shade Plugin, so you can run it with `java -jar target/JavaHttpServer-1.0.2-beta.jar`. + +--- + +# 🌐 Static File Serving + +The `StaticFilesPlugin` serves files from: + +``` +src/main/resources/static/ +``` + +### Example Mapping + +| File | URL | +|------|------| +| index.html | `/` | +| css/styles.css | `/css/styles.css` | +| js/app.js | `/js/app.js` | + +### Security Features + +- Path traversal prevention +- MIME type detection +- 404 handling +- 403 handling +- Clean URLs (no `/static/` prefix) + +For full architectural reasoning, see: + +➑ `docs/adr/ADR-001-static-file-serving-architecture.md` + +--- + +# πŸ”„ Creating a Filter + +Filters intercept requests before they reach the plugin. + +A filter can: + +- Inspect or modify `HttpRequest` +- Inspect or modify `HttpResponse` +- Stop the chain (e.g., return 403) +- Continue processing by calling `chain.doFilter(req, res)` + +--- + +## Filter Interface + +```java +public interface Filter { + void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException; +} +``` + +--- + +## Example: LoggingFilter + +```java +public class LoggingFilter implements Filter { + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + System.out.println(req.method() + " " + req.path()); + chain.doFilter(req, res); + } +} +``` + +--- + +## Registering a Global Filter + +```java +pipeline.addGlobalFilter(new LoggingFilter(), 100); +``` + +Lower order values execute first. + +--- + +# 🎯 Route-Specific Filters + +Route filters only execute when the request path matches a pattern. + +### Supported Patterns + +- `/api/*` β†’ matches paths starting with `/api/` +- `/login` β†’ exact match +- `/admin/*` β†’ wildcard support (prefix-based) + +--- + +## Example + +```java +pipeline.addRouteFilter(new JwtAuthFilter(), 100, "/api/*"); +``` + +--- + +## Execution Flow + +``` +Client β†’ Filter 1 β†’ Filter 2 β†’ ... β†’ Plugin β†’ Response β†’ Client +``` + +--- + +# 🧠 Creating a Plugin + +Plugins generate the final HTTP response. + +They run after all filters have completed. + +--- + +## Plugin Interface + +```java +public interface Plugin { + void handle(HttpRequest req, HttpResponse res) throws IOException; +} +``` + +--- + +## Example: HelloPlugin + +```java +public class HelloPlugin implements Plugin { + + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + res.setStatusCode(200); + res.setStatusText("OK"); + res.setHeader("Content-Type", "text/plain"); + res.setBody("Hello from juv25d server".getBytes()); + } +} +``` + +--- + +## Registering a Plugin + +```java +pipeline.setPlugin(new HelloPlugin()); +``` + +--- + +# βš™ Configuration + +Configuration is loaded from: + +``` +application-properties.yml +``` + +Example: + +```yaml +server: + port: 3000 + root-dir: static + +logging: + level: INFO +``` + +--- + +# πŸ“¦ Features + +- Custom HTTP request parser (`HttpParser`) +- Custom HTTP response writer (`HttpResponseWriter`) +- Mutable HTTP response model +- Filter chain architecture +- Plugin system +- Static file serving +- MIME type resolution +- Path traversal protection +- Virtual threads (Project Loom) +- YAML configuration (SnakeYAML) +- Dockerized distribution +- Published container image (GHCR) + +--- + +# πŸ“š Documentation & Architecture Decisions + +Additional technical documentation is available in the `docs/` directory. + +## Architecture Decision Records (ADR) + +Contains architectural decisions and their reasoning. + +``` +docs/adr/ +``` + +Main index: + +``` +docs/adr/README.md +``` + +Includes: + +- Static file serving architecture +- ADR template +- Future architecture decisions + +--- + +## Technical Notes + +Advanced filter configuration examples: + +``` +docs/notes/ +``` + +--- + +# πŸŽ“ Educational Value + +This project demonstrates: + +- How web servers work internally +- How middleware pipelines are implemented +- How static file serving works +- How architectural decisions are documented +- How Java services are containerized and distributed + +--- + +# πŸ‘₯ Team juv25d + +Built as a learning project to deeply understand HTTP, backend systems, and modular server architecture. diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 00000000..d8e097bc --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,390 @@ +/* Reset and Base Styles */ +* { + --base-color: 23, 23, 46; + --base-color-white: #f7f7f7fc; + margin: 0; + padding: 0; + box-sizing: border-box; +} +html { + scrollbar-gutter: stable; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background-image: url("../images/tech-bg.jpg"); + background-attachment: fixed; + background-size: cover; + min-height: 100vh; + padding: 20px; + margin: 0; +} + +.nav-menu a { + display: block; + padding: 0.25rem 1rem 0.5rem 1rem; + text-decoration: none; + color: var(--base-color-white); + font-weight: bold; + font-family: system-ui; +} + +/* Container */ +.container { + max-width: 900px; + margin: 0 auto; + backdrop-filter: blur(4px); + background: linear-gradient(135deg, rgba(23, 23, 46, 0.6), rgba(23, 23, 46, 0.9), rgba(23, 23, 46, 0.6)); + color: white; + text-align: center; + border-radius: 10px; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 24px; + } + + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + overflow: hidden; +} +/* Nav menu */ + +.nav-menu.disable-anchors::before, +.nav-menu.disable-anchors::after { + transition: none !important; +} + +.disable-anchors a { + pointer-events: none; +} + +.nav-menu { + + width: fit-content; + margin: 0.4rem auto 1rem auto; + display: flex; + align-content: center; + justify-content: center; + isolation: isolate; + + anchor-name: --hovered-link; + + li:hover { + anchor-name: --hovered-link; + } + + &::before, + &::after { + content: ""; + position: absolute; + top: calc(anchor(bottom) - 6px); + left: calc(anchor(left) + 30px); + right: calc(anchor(right) + 30px); + bottom: calc(anchor(bottom)); + border-radius: 10px; + + position-anchor: --hovered-link; + + transition: 900ms + linear( + 0, + 0.029 1.6%, + 0.123 3.5%, + 0.651 10.6%, + 0.862 14.1%, + 1.002 17.7%, + 1.046 19.6%, + 1.074 21.6%, + 1.087 23.9%, + 1.086 26.6%, + 1.014 38.5%, + 0.994 46.3%, + 1 + ); + } + + &::before { + z-index: -1; + background: rgba(0, 0, 0, 0.2); + /*backdrop-filter: blur(2px);*/ + } + + &::after { + z-index: -2; + background-image: url("../images/tech-bg.jpg"); + background-attachment: fixed; + background-size: cover; + } + &:has(a:hover)::before, + &:has(a:hover)::after { + top: anchor(top); + left: anchor(left); + right: anchor(right); + bottom: anchor(bottom); + + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 50%; + } + } + + /* &:has(a:active)::before { + background: transparent; + } + + &:has(a:active)::before, + &:has(a:active)::after { + top: calc(anchor(top) - 6px); + left: calc(anchor(left) - 6px); + right: calc(anchor(right) - 6px); + bottom: calc(anchor(bottom) - 6px); + + transition: 50ms; + backdrop-filter: none; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 50%; + } + } + + &:has(a:active) > ul > li * { + color: black; + }*/ + & ul { + display: flex; + flex-direction: row; + padding: 0; + align-content: center; + justify-content: space-around; + list-style: none; + margin: 0; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 24px; + } + } + + & ul > li { + margin: auto 2rem; + } +} +/* Header */ +header { + background: transparent; + color: var(--base-color-white); + padding: 40px 30px; + text-align: center; + + border-top: solid #f7f7f717; + border-width: 1px; +} + +header h1 { + font-size: 2.5em; + margin-bottom: 10px; +} + +.subtitle { + font-size: 1.2em; + opacity: 0.9; +} + +/* Main Content */ +main { + padding: 40px 30px; + opacity: 1; + transition: opacity 200ms ease; +} + +main.fade-out { + opacity: 0; +} + + +.features { + isolation: isolate; + + anchor-name: --hovered-feature; + + li:hover { + anchor-name: --hovered-feature; + } + + &::after { + content: ""; + position: absolute; + top: calc(anchor(top) + 20px); + left: calc(anchor(left) + 4px); + right: calc(anchor(left) - 12px); + bottom: calc(anchor(bottom) + 20px); + + border-radius: 10px; + + position-anchor: --hovered-feature; + + transition: 700ms + linear(0, 0.012 0.9%, 0.05 2%, 0.411 9.2%, 0.517 11.8%, 0.611 14.6%, 0.694 17.7%, 0.765 21.1%, 0.824 24.8%, 0.872 28.9%, 0.91 33.4%, 0.939 38.4%, 0.977 50.9%, 0.994 68.4%, 1); + } + + &::after { + background-color: rgba(0, 150, 255, 0.3); + z-index: -1; + } + + &:has(li:hover)::after { + top: calc(anchor(top) + 10px); + left: calc(anchor(left) + 10px); + right: calc(anchor(left) - 16px); + bottom: calc(anchor(bottom) + 4px); + background-color: rgba(100, 200, 255, 0.7); + + border-radius: 10px; + } + + & ul { + display: flex; + flex-direction: column; + align-content: center; + justify-content: space-around; + list-style: none; + margin: 0; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 24px; + } + } + + & ul > li { + background: none; + color: var(--base-color-white); + padding: 24px 20px; + margin: 0; + border-radius: 5px; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 24px; + } + } +} + +section { + margin-bottom: 40px; +} + +section:last-child { + margin-bottom: 0; +} + +h2 { + color: white; + margin-bottom: 15px; + font-size: 1.8em; +} + +p { + margin-bottom: 15px; + font-size: 1.1em; +} + +/* Lists */ +ul { + list-style-position: inside; + text-align: left; + margin-left: 20px; + color: rgba(var(--base-color), 0.9); +} + +li { + margin-bottom: 10px; + font-size: 1.05em; + color: rgba(var(--base-color), 0.9); +} + +/* Welcome Section */ +.welcome { + text-align: center; + padding: 20px 0; +} + +/* Features Section */ +.features ul { + list-style: none; + margin-left: 0; +} + +/* .features li { + background: #f7f7f7; + padding: 12px 20px; + margin: 10px 0; + border-radius: 5px; + border-left: 4px solid #667eea; +} */ + +/* Footer */ +footer { + background: transparent; + color: var(--base-color-white); + padding: 40px 30px; + text-align: center; + border-radius: 10px; + border-top: solid #f7f7f717; + border-width: 1px; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 0 0 24px 24px; + } +} + +/* Markdown */ +.readme_content { + max-width: 700px; + margin: 0 auto; +} + +.readme_content li { + color: var(--base-color-white); +} + +pre { + background-color: #f7f7f777; + border-radius: 10px; + padding: 0.75rem; + text-wrap: auto; + margin: 1rem auto; + + @supports (corner-shape: squircle) { + corner-shape: squircle; + border-radius: 24px; + } +} + +code { + font-size: 14px; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + header h1 { + font-size: 2em; + } + + main { + padding: 20px 15px; + } + + h2 { + font-size: 1.5em; + } +} diff --git a/src/main/resources/static/images/tech-bg.jpg b/src/main/resources/static/images/tech-bg.jpg new file mode 100644 index 00000000..08a60b97 Binary files /dev/null and b/src/main/resources/static/images/tech-bg.jpg differ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 00000000..d34f9803 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + Java HTTP Server - Team juv25d + + + +
+ +
+

πŸš€ Java HTTP Server

+

Built by Team juv25d

+
+ +
+
+

Welcome to Our HTTP Server!

+

This server is built from scratch in Java as a learning project.

+
+ +

Features

+
+
    +
  • βœ… HTTP Request Parsing
  • +
  • βœ… HTTP Response Writing
  • +
  • βœ… Static File Serving
  • +
  • βœ… MIME Type Detection
  • +
  • βœ… Security (Path Traversal Prevention)
  • +
  • βœ… Logging
  • +
+
+
+ +
+

© 2026 Team juv25d - Learning HTTP Servers

+
+
+ + + + + + diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 00000000..41a2c2cb --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,100 @@ +// app.js - JavaScript for Java HTTP Server +// This file tests that JavaScript files are being served correctly + +console.log('βœ… JavaScript loaded successfully!'); + +// Log some server info when page loads +document.addEventListener("DOMContentLoaded", () => { + console.log('πŸš€ Server: Java HTTP Server'); + console.log('πŸ‘₯ Team: juv25d'); + console.log('πŸ“ Current path:', window.location.pathname); + console.log('✨ Static file serving is working!'); + + route(window.location.pathname); +}); + +window.addEventListener("popstate", () => { + navigate(window.location.pathname); +}); + + +const routes = { + "/index.html": () => {}, + "/readme.html": initReadme, +}; + +function route(path) { + const cleanPath = path.startsWith("/") ? path : "/" + path; + const handler = routes[cleanPath]; + if (handler) handler(); +} + + +const nav = document.querySelector(".nav-menu"); + +function navigate(href) { + nav.classList.add("disable-anchors"); + const main = document.getElementById("main-content"); + + main.classList.add("fade-out"); + + setTimeout(() => { + fetch(href) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then(html => { + const doc = new DOMParser().parseFromString(html, "text/html"); + const newMain = doc.querySelector("main"); + + if (!newMain) throw new Error("No
found in " + href); + + main.innerHTML = newMain.innerHTML; + history.pushState(null, "", href); + route(href); + + main.classList.remove("fade-out"); + + setTimeout(() => { + nav.classList.remove("disable-anchors"); + }, 150); + }) + .catch(err => { + console.error("Navigation failed:", err); + main.classList.remove("fade-out"); + nav.classList.remove("disable-anchors"); + }); + }, 200); +} + + +document.addEventListener("click", (e) => { + const link = e.target.closest("a"); + if (!link) return; + + const href = link.getAttribute("href"); + if (!href || !href.endsWith(".html")) return; + + e.preventDefault(); + navigate(href); +}); + + + +function initReadme() { + const container = document.getElementById("readme_content"); + if (!container) return; + + fetch("/README.md") + .then(res => { + if (!res.ok) throw new Error("Failed to load README.md"); + return res.text(); + }) + .then(md => { + container.innerHTML = DOMPurify.sanitize(marked.parse(md)); + }) + .catch(err => console.error("Failed to load README.md", err)); +} + + diff --git a/src/main/resources/static/js/marked.min.js b/src/main/resources/static/js/marked.min.js new file mode 100644 index 00000000..2260afef --- /dev/null +++ b/src/main/resources/static/js/marked.min.js @@ -0,0 +1,69 @@ +/** + * marked v15.0.12 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; + "use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(` +`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(` +`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,` +`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],` +`).split(` +`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(` +`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+` +`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(` +`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=` +`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=` +`+g}!T&&!g.trim()&&(T=!0),p+=G+` +`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=` +`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=` +`+s.raw,r.text+=` +`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+` +`;return s?'
'+(n?i:R(i,!0))+`
+`:"
"+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o +`+s+" +`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • +`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let i=0;i${s}`),` + +`+t+` +`+s+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${R(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='
    ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=`${n}{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let s="

    An error occurred:

    "+R(n.message+"",!0)+"
    ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex; + + if(__exports != exports)module.exports = exports;return module.exports})); diff --git a/src/main/resources/static/js/purify.min.js b/src/main/resources/static/js/purify.min.js new file mode 100644 index 00000000..65b3163d --- /dev/null +++ b/src/main/resources/static/js/purify.min.js @@ -0,0 +1,2 @@ +/*! @license DOMPurify 3.0.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=N(Array.prototype.forEach),m=N(Array.prototype.pop),f=N(Array.prototype.push),p=N(String.prototype.toLowerCase),d=N(String.prototype.toString),h=N(String.prototype.match),g=N(String.prototype.replace),T=N(String.prototype.indexOf),y=N(String.prototype.trim),E=N(RegExp.prototype.test),A=(_=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:p;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function S(t){const n=l(null);for(const[o,i]of e(t))void 0!==r(t,o)&&(n[o]=i);return n}function R(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return N(n.get);if("function"==typeof n.value)return N(n.value)}e=o(e)}return function(e){return console.warn("fallback value for",e),null}}const w=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),v=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),C=i(["#text"]),O=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","xmlns","slot"]),I=i(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),M=i(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),U=i(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),P=a(/\{\{[\w\W]*|[\w\W]*\}\}/gm),F=a(/<%[\w\W]*|[\w\W]*%>/gm),H=a(/\${[\w\W]*}/gm),z=a(/^data-[\-\w.\u00B7-\uFFFF]/),B=a(/^aria-[\-\w]+$/),W=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),G=a(/^(?:\w+script|data):/i),Y=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),j=a(/^html$/i);var q=Object.freeze({__proto__:null,MUSTACHE_EXPR:P,ERB_EXPR:F,TMPLIT_EXPR:H,DATA_ATTR:z,ARIA_ATTR:B,IS_ALLOWED_URI:W,IS_SCRIPT_OR_DATA:G,ATTR_WHITESPACE:Y,DOCTYPE_NAME:j});const X=function(){return"undefined"==typeof window?null:window},K=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};var V=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:X();const o=e=>t(e);if(o.version="3.0.6",o.removed=[],!n||!n.document||9!==n.document.nodeType)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:_,Node:N,Element:P,NodeFilter:F,NamedNodeMap:H=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:z,DOMParser:B,trustedTypes:G}=n,Y=P.prototype,V=R(Y,"cloneNode"),$=R(Y,"nextSibling"),Z=R(Y,"childNodes"),J=R(Y,"parentNode");if("function"==typeof _){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Q,ee="";const{implementation:te,createNodeIterator:ne,createDocumentFragment:oe,getElementsByTagName:re}=r,{importNode:ie}=a;let ae={};o.isSupported="function"==typeof e&&"function"==typeof J&&te&&void 0!==te.createHTMLDocument;const{MUSTACHE_EXPR:le,ERB_EXPR:ce,TMPLIT_EXPR:se,DATA_ATTR:ue,ARIA_ATTR:me,IS_SCRIPT_OR_DATA:fe,ATTR_WHITESPACE:pe}=q;let{IS_ALLOWED_URI:de}=q,he=null;const ge=b({},[...w,...D,...L,...x,...C]);let Te=null;const ye=b({},[...O,...I,...M,...U]);let Ee=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ae=null,_e=null,Ne=!0,be=!0,Se=!1,Re=!0,we=!1,De=!1,Le=!1,ve=!1,xe=!1,ke=!1,Ce=!1,Oe=!0,Ie=!1;const Me="user-content-";let Ue=!0,Pe=!1,Fe={},He=null;const ze=b({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Be=null;const We=b({},["audio","video","img","source","image","track"]);let Ge=null;const Ye=b({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),je="http://www.w3.org/1998/Math/MathML",qe="http://www.w3.org/2000/svg",Xe="http://www.w3.org/1999/xhtml";let Ke=Xe,Ve=!1,$e=null;const Ze=b({},[je,qe,Xe],d);let Je=null;const Qe=["application/xhtml+xml","text/html"],et="text/html";let tt=null,nt=null;const ot=r.createElement("form"),rt=function(e){return e instanceof RegExp||e instanceof Function},it=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!nt||nt!==e){if(e&&"object"==typeof e||(e={}),e=S(e),Je=Je=-1===Qe.indexOf(e.PARSER_MEDIA_TYPE)?et:e.PARSER_MEDIA_TYPE,tt="application/xhtml+xml"===Je?d:p,he="ALLOWED_TAGS"in e?b({},e.ALLOWED_TAGS,tt):ge,Te="ALLOWED_ATTR"in e?b({},e.ALLOWED_ATTR,tt):ye,$e="ALLOWED_NAMESPACES"in e?b({},e.ALLOWED_NAMESPACES,d):Ze,Ge="ADD_URI_SAFE_ATTR"in e?b(S(Ye),e.ADD_URI_SAFE_ATTR,tt):Ye,Be="ADD_DATA_URI_TAGS"in e?b(S(We),e.ADD_DATA_URI_TAGS,tt):We,He="FORBID_CONTENTS"in e?b({},e.FORBID_CONTENTS,tt):ze,Ae="FORBID_TAGS"in e?b({},e.FORBID_TAGS,tt):{},_e="FORBID_ATTR"in e?b({},e.FORBID_ATTR,tt):{},Fe="USE_PROFILES"in e&&e.USE_PROFILES,Ne=!1!==e.ALLOW_ARIA_ATTR,be=!1!==e.ALLOW_DATA_ATTR,Se=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Re=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,we=e.SAFE_FOR_TEMPLATES||!1,De=e.WHOLE_DOCUMENT||!1,xe=e.RETURN_DOM||!1,ke=e.RETURN_DOM_FRAGMENT||!1,Ce=e.RETURN_TRUSTED_TYPE||!1,ve=e.FORCE_BODY||!1,Oe=!1!==e.SANITIZE_DOM,Ie=e.SANITIZE_NAMED_PROPS||!1,Ue=!1!==e.KEEP_CONTENT,Pe=e.IN_PLACE||!1,de=e.ALLOWED_URI_REGEXP||W,Ke=e.NAMESPACE||Xe,Ee=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ee.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ee.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ee.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),we&&(be=!1),ke&&(xe=!0),Fe&&(he=b({},[...C]),Te=[],!0===Fe.html&&(b(he,w),b(Te,O)),!0===Fe.svg&&(b(he,D),b(Te,I),b(Te,U)),!0===Fe.svgFilters&&(b(he,L),b(Te,I),b(Te,U)),!0===Fe.mathMl&&(b(he,x),b(Te,M),b(Te,U))),e.ADD_TAGS&&(he===ge&&(he=S(he)),b(he,e.ADD_TAGS,tt)),e.ADD_ATTR&&(Te===ye&&(Te=S(Te)),b(Te,e.ADD_ATTR,tt)),e.ADD_URI_SAFE_ATTR&&b(Ge,e.ADD_URI_SAFE_ATTR,tt),e.FORBID_CONTENTS&&(He===ze&&(He=S(He)),b(He,e.FORBID_CONTENTS,tt)),Ue&&(he["#text"]=!0),De&&b(he,["html","head","body"]),he.table&&(b(he,["tbody"]),delete Ae.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');Q=e.TRUSTED_TYPES_POLICY,ee=Q.createHTML("")}else void 0===Q&&(Q=K(G,c)),null!==Q&&"string"==typeof ee&&(ee=Q.createHTML(""));i&&i(e),nt=e}},at=b({},["mi","mo","mn","ms","mtext"]),lt=b({},["foreignobject","desc","title","annotation-xml"]),ct=b({},["title","style","font","a","script"]),st=b({},D);b(st,L),b(st,v);const ut=b({},x);b(ut,k);const mt=function(e){let t=J(e);t&&t.tagName||(t={namespaceURI:Ke,tagName:"template"});const n=p(e.tagName),o=p(t.tagName);return!!$e[e.namespaceURI]&&(e.namespaceURI===qe?t.namespaceURI===Xe?"svg"===n:t.namespaceURI===je?"svg"===n&&("annotation-xml"===o||at[o]):Boolean(st[n]):e.namespaceURI===je?t.namespaceURI===Xe?"math"===n:t.namespaceURI===qe?"math"===n&<[o]:Boolean(ut[n]):e.namespaceURI===Xe?!(t.namespaceURI===qe&&!lt[o])&&(!(t.namespaceURI===je&&!at[o])&&(!ut[n]&&(ct[n]||!st[n]))):!("application/xhtml+xml"!==Je||!$e[e.namespaceURI]))},ft=function(e){f(o.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){e.remove()}},pt=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Te[e])if(xe||ke)try{ft(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},dt=function(e){let t=null,n=null;if(ve)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Je&&Ke===Xe&&(e=''+e+"");const o=Q?Q.createHTML(e):e;if(Ke===Xe)try{t=(new B).parseFromString(o,Je)}catch(e){}if(!t||!t.documentElement){t=te.createDocument(Ke,"template",null);try{t.documentElement.innerHTML=Ve?ee:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Ke===Xe?re.call(t,De?"html":"body")[0]:De?t.documentElement:i},ht=function(e){return ne.call(e.ownerDocument||e,e,F.SHOW_ELEMENT|F.SHOW_COMMENT|F.SHOW_TEXT,null)},gt=function(e){return e instanceof z&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof H)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Tt=function(e){return"function"==typeof N&&e instanceof N},yt=function(e,t,n){ae[e]&&u(ae[e],(e=>{e.call(o,t,n,nt)}))},Et=function(e){let t=null;if(yt("beforeSanitizeElements",e,null),gt(e))return ft(e),!0;const n=tt(e.nodeName);if(yt("uponSanitizeElement",e,{tagName:n,allowedTags:he}),e.hasChildNodes()&&!Tt(e.firstElementChild)&&E(/<[/\w]/g,e.innerHTML)&&E(/<[/\w]/g,e.textContent))return ft(e),!0;if(!he[n]||Ae[n]){if(!Ae[n]&&_t(n)){if(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n))return!1;if(Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))return!1}if(Ue&&!He[n]){const t=J(e)||e.parentNode,n=Z(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o)t.insertBefore(V(n[o],!0),$(e))}}return ft(e),!0}return e instanceof P&&!mt(e)?(ft(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!E(/<\/no(script|embed|frames)/i,e.innerHTML)?(we&&3===e.nodeType&&(t=e.textContent,u([le,ce,se],(e=>{t=g(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),yt("afterSanitizeElements",e,null),!1):(ft(e),!0)},At=function(e,t,n){if(Oe&&("id"===t||"name"===t)&&(n in r||n in ot))return!1;if(be&&!_e[t]&&E(ue,t));else if(Ne&&E(me,t));else if(!Te[t]||_e[t]){if(!(_t(e)&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,e)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(e))&&(Ee.attributeNameCheck instanceof RegExp&&E(Ee.attributeNameCheck,t)||Ee.attributeNameCheck instanceof Function&&Ee.attributeNameCheck(t))||"is"===t&&Ee.allowCustomizedBuiltInElements&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))))return!1}else if(Ge[t]);else if(E(de,g(n,pe,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Be[e]){if(Se&&!E(fe,g(n,pe,"")));else if(n)return!1}else;return!0},_t=function(e){return e.indexOf("-")>0},Nt=function(e){yt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Te};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=tt(a);let f="value"===a?c:y(c);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,yt("uponSanitizeAttribute",e,n),f=n.attrValue,n.forceKeepAttr)continue;if(pt(a,e),!n.keepAttr)continue;if(!Re&&E(/\/>/i,f)){pt(a,e);continue}we&&u([le,ce,se],(e=>{f=g(f,e," ")}));const p=tt(e.nodeName);if(At(p,s,f)){if(!Ie||"id"!==s&&"name"!==s||(pt(a,e),f=Me+f),Q&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(p,s)){case"TrustedHTML":f=Q.createHTML(f);break;case"TrustedScriptURL":f=Q.createScriptURL(f)}try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),m(o.removed)}catch(e){}}}yt("afterSanitizeAttributes",e,null)},bt=function e(t){let n=null;const o=ht(t);for(yt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)yt("uponSanitizeShadowNode",n,null),Et(n)||(n.content instanceof s&&e(n.content),Nt(n));yt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(Ve=!e,Ve&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Tt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Le||it(t),o.removed=[],"string"==typeof e&&(Pe=!1),Pe){if(e.nodeName){const t=tt(e.nodeName);if(!he[t]||Ae[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof N)n=dt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),1===r.nodeType&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!xe&&!we&&!De&&-1===e.indexOf("<"))return Q&&Ce?Q.createHTML(e):e;if(n=dt(e),!n)return xe?null:Ce?ee:""}n&&ve&&ft(n.firstChild);const c=ht(Pe?e:n);for(;i=c.nextNode();)Et(i)||(i.content instanceof s&&bt(i.content),Nt(i));if(Pe)return e;if(xe){if(ke)for(l=oe.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Te.shadowroot||Te.shadowrootmode)&&(l=ie.call(a,l,!0)),l}let m=De?n.outerHTML:n.innerHTML;return De&&he["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&E(j,n.ownerDocument.doctype.name)&&(m="\n"+m),we&&u([le,ce,se],(e=>{m=g(m,e," ")})),Q&&Ce?Q.createHTML(m):m},o.setConfig=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};it(e),Le=!0},o.clearConfig=function(){nt=null,Le=!1},o.isValidAttribute=function(e,t,n){nt||it({});const o=tt(e),r=tt(t);return At(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(ae[e]=ae[e]||[],f(ae[e],t))},o.removeHook=function(e){if(ae[e])return m(ae[e])},o.removeHooks=function(e){ae[e]&&(ae[e]=[])},o.removeAllHooks=function(){ae={}},o}();return V})); diff --git a/src/main/resources/static/readme.html b/src/main/resources/static/readme.html new file mode 100644 index 00000000..a675bb97 --- /dev/null +++ b/src/main/resources/static/readme.html @@ -0,0 +1,40 @@ + + + + + + Java HTTP Server - Team juv25d || Readme + + + +
    + +
    +

    🔍 ReadMe 🔎

    +

    By Team JUV25D

    +
    + +
    +
    +

    Welcome to our Read Me page

    +

    Here you will find all the information you need about this project's structure and code

    +
    +
    +
    +
    + +
    +

    © 2026 Team juv25d - Learning HTTP Servers

    +
    +
    + + + + + + diff --git a/src/test/java/org/example/AppIT.java b/src/test/java/org/example/AppIT.java deleted file mode 100644 index 9d1ca031..00000000 --- a/src/test/java/org/example/AppIT.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.example; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class AppIT { - @Test - void itTest() { - assertThat(false).isFalse(); - } -} diff --git a/src/test/java/org/juv25d/AppIT.java b/src/test/java/org/juv25d/AppIT.java new file mode 100644 index 00000000..c7e2e871 --- /dev/null +++ b/src/test/java/org/juv25d/AppIT.java @@ -0,0 +1,53 @@ +package org.juv25d; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +public class AppIT { + + @Container + @SuppressWarnings("resource") + public static GenericContainer server = new GenericContainer<>( + new ImageFromDockerfile("java-http-server-test") + .withFileFromPath(".", Paths.get(".")) + ).withExposedPorts(8080) + .waitingFor(Wait.forHttp("/").forStatusCode(200)); + + private final HttpClient client = HttpClient.newHttpClient(); + + @Test + void shouldReturnIndexHtml() throws Exception { + HttpResponse response = get("/"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains("πŸš€ Java HTTP Server"); + assertThat(response.headers().firstValue("Content-Type")).get().asString().contains("text/html"); + } + + @Test + void shouldReturn404ForNonExistentPage() throws Exception { + HttpResponse response = get("/not-found.html"); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(response.body()).contains("404"); + } + + private HttpResponse get(String path) throws Exception { + String url = "http://" + server.getHost() + ":" + server.getMappedPort(8080) + path; + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/test/java/org/example/AppTest.java b/src/test/java/org/juv25d/AppTest.java similarity index 89% rename from src/test/java/org/example/AppTest.java rename to src/test/java/org/juv25d/AppTest.java index d522a7e2..5e1d4d23 100644 --- a/src/test/java/org/example/AppTest.java +++ b/src/test/java/org/juv25d/AppTest.java @@ -1,4 +1,4 @@ -package org.example; +package org.juv25d; import org.junit.jupiter.api.Test; diff --git a/src/test/java/org/juv25d/PipelineTest.java b/src/test/java/org/juv25d/PipelineTest.java new file mode 100644 index 00000000..aefa880b --- /dev/null +++ b/src/test/java/org/juv25d/PipelineTest.java @@ -0,0 +1,28 @@ +package org.juv25d; + +import org.junit.jupiter.api.Test; +import org.juv25d.plugin.HelloPlugin; +import org.juv25d.router.SimpleRouter; // New import + +import static org.junit.jupiter.api.Assertions.*; + +class PipelineTest { + + @Test + void throwsExceptionWhenSettingNullRouter() { // Renamed test method + Pipeline pipeline = new Pipeline(); + assertThrows(IllegalArgumentException.class, () -> pipeline.setRouter(null)); // Changed to setRouter + } + + @Test + void customRouterIsUsed() { // Renamed test method + Pipeline pipeline = new Pipeline(); + SimpleRouter router = new SimpleRouter(); // Use SimpleRouter + HelloPlugin hello = new HelloPlugin(); + router.registerPlugin("/hello", hello); // Register a plugin + + pipeline.setRouter(router); // Changed to setRouter + + assertEquals(router, pipeline.getRouter()); // Changed to getRouter + } +} diff --git a/src/test/java/org/juv25d/filter/FilterChainImplTest.java b/src/test/java/org/juv25d/filter/FilterChainImplTest.java new file mode 100644 index 00000000..7db325dd --- /dev/null +++ b/src/test/java/org/juv25d/filter/FilterChainImplTest.java @@ -0,0 +1,130 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.plugin.Plugin; +import org.juv25d.router.SimpleRouter; // New import +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FilterChainImplTest { + + @Test + void filters_areCalledInOrderAndPluginLast() throws IOException { + + List calls = new ArrayList<>(); + + Filter f1 = (req, res, chain) -> { + calls.add("f1-before"); + chain.doFilter(req, res); + calls.add("f1-after"); + }; + + Filter f2 = (req, res, chain) -> { + calls.add("f2-before"); + chain.doFilter(req, res); + calls.add("f2-after"); + }; + + Plugin plugin = (req, res) -> calls.add("plugin"); + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", plugin); // Register the plugin with a path + + FilterChainImpl chain = new FilterChainImpl( + List.of(f1, f2), + router // Pass the router + ); + + HttpRequest req = new HttpRequest( + "GET", + "/", + null, // queryString + "HTTP/1.1", + Map.of(), + new byte[0], + "UNKNOWN" // remoteIp + ); + + chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0])); + + assertEquals( + List.of( + "f1-before", + "f2-before", + "plugin", + "f2-after", + "f1-after" + ), + calls + ); + } + + @Test + void filter_canStopChainExecution() throws IOException { + + List calls = new ArrayList<>(); + + Filter blockingFilter = (req, res, chain) -> { + calls.add("blocked"); + }; + + Plugin plugin = (req, res) -> calls.add("plugin"); + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", plugin); // Register the plugin with a path + + FilterChainImpl chain = new FilterChainImpl( + List.of(blockingFilter), + router // Pass the router + ); + + HttpRequest req = new HttpRequest( + "GET", + "/", + null, // queryString + "HTTP/1.1", + Map.of(), + new byte[0], + "UNKNOWN" // remoteIp + ); + + chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0])); + + assertEquals(List.of("blocked"), calls); + } + + @Test + void plugin_isCalledWhenNoFiltersExist() throws IOException { + + List calls = new ArrayList<>(); + + Plugin plugin = (req, res) -> calls.add("plugin"); + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", plugin); // Register the plugin with a path + + FilterChainImpl chain = new FilterChainImpl( + List.of(), + router // Pass the router + ); + + HttpRequest req = new HttpRequest( + "GET", + "/", + null, // queryString + "HTTP/1.1", + Map.of(), + new byte[0], + "UNKNOWN" // remoteIp + ); + + chain.doFilter(req, new HttpResponse(200, "OK", new HashMap<>(), new byte[0])); + + assertEquals(List.of("plugin"), calls); + } +} diff --git a/src/test/java/org/juv25d/filter/GlobalFilterTests.java b/src/test/java/org/juv25d/filter/GlobalFilterTests.java new file mode 100644 index 00000000..699440e6 --- /dev/null +++ b/src/test/java/org/juv25d/filter/GlobalFilterTests.java @@ -0,0 +1,68 @@ +package org.juv25d.filter; + +import org.junit.jupiter.api.Test; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.plugin.Plugin; +import org.juv25d.Pipeline; +import org.juv25d.router.SimpleRouter; // New import + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class GlobalFilterTests { + + @Test + void globalFilter_shouldExecute_forAnyRoute() throws Exception { + Pipeline pipeline = new Pipeline(); + + RecordingFilter global = new RecordingFilter("global"); + pipeline.addGlobalFilter(global, 1); + + // Configure SimpleRouter and set it in the pipeline + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", new NoOpPlugin()); // Register NoOpPlugin for the root path + pipeline.setRouter(router); // Set the router in the pipeline + + execute(pipeline, "/anything"); + + assertTrue(global.wasExecuted()); + } + + private void execute(Pipeline pipeline, String path) throws Exception { + HttpRequest request = mock(HttpRequest.class); + when(request.path()).thenReturn(path); + + HttpResponse response = mock(HttpResponse.class); + + var chain = pipeline.createChain(request); + chain.doFilter(request, response); + } + + static class RecordingFilter implements Filter { + private final String name; + private boolean executed = false; + + RecordingFilter(String name) { + this.name = name; + } + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + executed = true; + chain.doFilter(req, res); + } + + boolean wasExecuted() { + return executed; + } + } + + static class NoOpPlugin implements Plugin { + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException { + } + } +} diff --git a/src/test/java/org/juv25d/filter/LoggingFilterTest.java b/src/test/java/org/juv25d/filter/LoggingFilterTest.java new file mode 100644 index 00000000..74f28d89 --- /dev/null +++ b/src/test/java/org/juv25d/filter/LoggingFilterTest.java @@ -0,0 +1,61 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +class LoggingFilterTest { + + @Test + void callsNextFilterInChain() throws IOException { + + LoggingFilter filter = new LoggingFilter(); + HttpRequest req = mock(HttpRequest.class); + HttpResponse res = mock(HttpResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + } + + @Test + void logsHttpMethodAndPath() throws IOException { + LoggingFilter filter = new LoggingFilter(); + HttpRequest req = mock(HttpRequest.class); + HttpResponse res = mock(HttpResponse.class); + FilterChain chain = mock(FilterChain.class); + + when(req.method()).thenReturn("GET"); + when(req.path()).thenReturn("/test"); + + java.util.logging.Logger logger = org.juv25d.logging.ServerLogging.getLogger(); + java.util.List records = new java.util.ArrayList<>(); + java.util.logging.Handler handler = new java.util.logging.Handler() { + @Override + public void publish(java.util.logging.LogRecord record) { + records.add(record); + } + @Override + public void flush() {} + @Override + public void close() throws SecurityException {} + }; + logger.addHandler(handler); + + try { + filter.doFilter(req, res, chain); + + boolean found = records.stream() + .anyMatch(r -> r.getMessage().contains("GET /test")); + assertTrue(found, "Logger should have captured the method and path"); + } finally { + logger.removeHandler(handler); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java b/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java new file mode 100644 index 00000000..971b6de3 --- /dev/null +++ b/src/test/java/org/juv25d/filter/RateLimitingFilterTest.java @@ -0,0 +1,130 @@ +package org.juv25d.filter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +/** + * Tests for the {@link RateLimitingFilter} class. + */ +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterTest { + + @Mock + private HttpRequest req; + @Mock + private HttpResponse res; + @Mock + private FilterChain chain; + + /** + * Verifies that the filter allows requests when they are within the rate limit. + */ + @Test + void shouldAllowRequest_whenWithinRateLimit() throws IOException { + // Arrange + RateLimitingFilter filter = new RateLimitingFilter(60, 5); + when(req.remoteIp()).thenReturn("127.0.0.1"); + + // Act + filter.doFilter(req, res, chain); + + // Assert + verify(chain, times(1)).doFilter(req, res); + verifyNoMoreInteractions(chain); + verifyNoInteractions(res); + } + + /** + * Verifies that the filter blocks requests when the rate limit is exceeded. + */ + @Test + void shouldBlockRequest_whenExceedingRateLimit() throws IOException { + // Arrange + RateLimitingFilter filter = new RateLimitingFilter(60, 5); + when(req.remoteIp()).thenReturn("127.0.0.1"); + + // Act + for (int i = 0; i < 6; i++) { + filter.doFilter(req, res, chain); + } + + // Assert + verify(chain, times(5)).doFilter(req, res); + verifyNoMoreInteractions(chain); + verify(res).setStatusCode(429); + verify(res).setStatusText("Too Many Requests"); + verify(res).setHeader("Content-Type", "text/plain; charset=utf-8"); + verify(res).setHeader(eq("Content-Length"), any()); + verify(res).setHeader("Retry-After", "60"); + verify(res).setBody(any()); + } + + /** + * Verifies that rate limits are tracked independently for different client IPs. + */ + @Test + void shouldAllowRequests_fromDifferentIpsIndependently() throws IOException { + // Arrange + RateLimitingFilter filter = new RateLimitingFilter(60, 5); + HttpRequest req2 = mock(HttpRequest.class); + HttpResponse res2 = mock(HttpResponse.class); + when(req.remoteIp()).thenReturn("127.0.0.1"); + when(req2.remoteIp()).thenReturn("192.168.1.1"); + + // Act + for (int i = 0; i < 6; i++) { // Empty first bucket + filter.doFilter(req, res, chain); + } + for (int i = 0; i < 2; i++) { + filter.doFilter(req2, res2, chain); + } + + // Assert + verify(chain, times(7)).doFilter(any(), any()); + verify(res).setStatusCode(429); + verifyNoInteractions(res2); + } + + /** + * Verifies that the internal bucket map is cleared when the filter is destroyed. + */ + @Test + void shouldClearBuckets_onDestroy() throws IOException { + // Arrange + RateLimitingFilter filter = new RateLimitingFilter(60, 5); + when(req.remoteIp()).thenReturn("127.0.0.1"); + + filter.doFilter(req, res, chain); + assertThat(filter.getTrackedIpCount()).isEqualTo(1); + + // Act + filter.destroy(); + + // Assert + assertThat(filter.getTrackedIpCount()).isZero(); + } + + /** + * Verifies that the constructor throws an exception for invalid configuration values. + */ + @Test + void shouldThrowException_whenInvalidConfiguration() { + // Act & Assert + assertThatThrownBy(() -> new RateLimitingFilter(0, 5)) + .isInstanceOf(IllegalArgumentException.class); + + // Act & Assert + assertThatThrownBy(() -> new RateLimitingFilter(60, 0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/org/juv25d/filter/RedirectFilterTest.java b/src/test/java/org/juv25d/filter/RedirectFilterTest.java new file mode 100644 index 00000000..19288230 --- /dev/null +++ b/src/test/java/org/juv25d/filter/RedirectFilterTest.java @@ -0,0 +1,190 @@ +package org.juv25d.filter; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class RedirectFilterTest { + + private FilterChain mockChain; + + @BeforeEach + void setUp() { + mockChain = Mockito.mock(FilterChain.class); + } + + @Test + void shouldRedirect301ForMatchingPath() throws IOException { + // Given + List rules = List.of( + new RedirectRule("/old-page", "/new-page", 301) + ); + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/old-page"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then + assertThat(response.statusCode()).isEqualTo(301); + assertThat(response.statusText()).isEqualTo("Moved Permanently"); + assertThat(response.headers().get("Location")).isEqualTo("/new-page"); + assertThat(response.headers().get("Content-Length")).isEqualTo("0"); + assertThat(response.body()).isEmpty(); + + // Pipeline should be stopped + verify(mockChain, never()).doFilter(any(), any()); + } + + @Test + void shouldRedirect302ForTemporaryRedirect() throws IOException { + // Given + List rules = List.of( + new RedirectRule("/temp", "https://example.com/temporary", 302) + ); + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/temp"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then + assertThat(response.statusCode()).isEqualTo(302); + assertThat(response.statusText()).isEqualTo("Found"); + assertThat(response.headers().get("Location")).isEqualTo("https://example.com/temporary"); + + // Pipeline should be stopped + verify(mockChain, never()).doFilter(any(), any()); + } + + @Test + void shouldNotRedirectWhenNoMatchingRule() throws IOException { + // Given + List rules = List.of( + new RedirectRule("/old-page", "/new-page", 301) + ); + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/other-page"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then - pipeline should continue + verify(mockChain, times(1)).doFilter(request, response); + + // No redirect headers set + assertThat(response.headers().get("Location")).isNull(); + } + + @Test + void shouldMatchWildcardPattern() throws IOException { + // Given + List rules = List.of( + new RedirectRule("/docs/*", "/documentation/", 301) + ); + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/docs/api"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then + assertThat(response.statusCode()).isEqualTo(301); + assertThat(response.headers().get("Location")).isEqualTo("/documentation/"); + + verify(mockChain, never()).doFilter(any(), any()); + } + + @Test + void shouldMatchMultiplePathsWithWildcard() throws IOException { + // Given + List rules = List.of( + new RedirectRule("/docs/*", "/documentation/", 301) + ); + RedirectFilter filter = new RedirectFilter(rules); + + // Test multiple paths + testWildcardMatch(filter, "/docs/api"); + testWildcardMatch(filter, "/docs/guide"); + testWildcardMatch(filter, "/docs/tutorial/advanced"); + } + + @Test + void shouldEvaluateRulesInOrder() throws IOException { + // Given - first matching rule should win + List rules = List.of( + new RedirectRule("/page", "/first-target", 301), + new RedirectRule("/page", "/second-target", 302) // This won't be used + ); + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/page"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then - first rule should be applied + assertThat(response.statusCode()).isEqualTo(301); + assertThat(response.headers().get("Location")).isEqualTo("/first-target"); + } + + @Test + void shouldHandleEmptyRulesList() throws IOException { + // Given + List rules = List.of(); // No rules + RedirectFilter filter = new RedirectFilter(rules); + + HttpRequest request = createRequest("/any-page"); + HttpResponse response = new HttpResponse(); + + // When + filter.doFilter(request, response, mockChain); + + // Then - pipeline should continue + verify(mockChain, times(1)).doFilter(request, response); + } + + // Helper methods + + private void testWildcardMatch(RedirectFilter filter, String path) throws IOException { + HttpRequest request = createRequest(path); + HttpResponse response = new HttpResponse(); + FilterChain chain = Mockito.mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + assertThat(response.statusCode()).isEqualTo(301); + assertThat(response.headers().get("Location")).isEqualTo("/documentation/"); + verify(chain, never()).doFilter(any(), any()); + } + + private HttpRequest createRequest(String path) { + return new HttpRequest( + "GET", + path, + "", + "HTTP/1.1", + Map.of(), + new byte[0], + "127.0.0.1" + ); + } +} diff --git a/src/test/java/org/juv25d/filter/RouteFilterTests.java b/src/test/java/org/juv25d/filter/RouteFilterTests.java new file mode 100644 index 00000000..17886b25 --- /dev/null +++ b/src/test/java/org/juv25d/filter/RouteFilterTests.java @@ -0,0 +1,84 @@ +package org.juv25d.filter; + +import org.junit.jupiter.api.Test; +import org.juv25d.Pipeline; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.juv25d.plugin.Plugin; +import org.juv25d.router.SimpleRouter; // New import + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class RouteFilterTests { + + @Test + void routeFilter_shouldOnlyRun_whenRouteMatches() throws Exception { + Pipeline pipeline = new Pipeline(); + + RecordingFilter route = new RecordingFilter("route"); + pipeline.addRouteFilter(route, 1, "/api/*"); + + // Configure SimpleRouter and set it in the pipeline + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", new NoOpPlugin()); // Register NoOpPlugin for the root path + pipeline.setRouter(router); // Set the router in the pipeline + + execute(pipeline, "/api/test"); + assertTrue(route.wasExecuted()); + route.reset(); + execute(pipeline, "/home"); + assertFalse(route.wasExecuted()); + } + + @Test + void routeFilter_shouldMatchExactPath() throws Exception { + Pipeline pipeline = new Pipeline(); + + // Configure SimpleRouter and set it in the pipeline + SimpleRouter router = new SimpleRouter(); + router.registerPlugin("/", new NoOpPlugin()); // Register NoOpPlugin for the root path + pipeline.setRouter(router); // Set the router in the pipeline + + RecordingFilter exact = new RecordingFilter("exact"); + pipeline.addRouteFilter(exact, 1, "/admin"); + execute(pipeline, "/admin"); + assertTrue(exact.wasExecuted()); + exact.reset(); + execute(pipeline, "/admin/settings"); + assertFalse(exact.wasExecuted()); + } + + private void execute(Pipeline pipeline, String path) throws Exception { + HttpRequest request = mock(HttpRequest.class); + when(request.path()).thenReturn(path); + HttpResponse response = mock(HttpResponse.class); + var chain = pipeline.createChain(request); + chain.doFilter(request, response); + } + + static class RecordingFilter implements Filter { + private final String name; + private boolean executed = false; + + RecordingFilter(String name) { + this.name = name; + } + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + executed = true; + chain.doFilter(req, res); + } + + boolean wasExecuted() {return executed;} + void reset() {executed = false;} + } + + static class NoOpPlugin implements Plugin { + @Override + public void handle(HttpRequest req, HttpResponse res) throws IOException {} + } +} diff --git a/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java b/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java new file mode 100644 index 00000000..ecbf64f0 --- /dev/null +++ b/src/test/java/org/juv25d/handler/MimeTypeResolverTest.java @@ -0,0 +1,86 @@ +package org.juv25d.handler; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MimeTypeResolverTest { + + @Test + void shouldReturnTextHtmlForHtmlFiles() { + assertThat(MimeTypeResolver.getMimeType("index.html")).isEqualTo("text/html"); + assertThat(MimeTypeResolver.getMimeType("page.htm")).isEqualTo("text/html"); + } + + @Test + void shouldReturnTextCssForCssFiles() { + assertThat(MimeTypeResolver.getMimeType("styles.css")).isEqualTo("text/css"); + } + + @Test + void shouldReturnApplicationJavascriptForJsFiles() { + assertThat(MimeTypeResolver.getMimeType("app.js")).isEqualTo("application/javascript"); + } + + @Test + void shouldReturnImagePngForPngFiles() { + assertThat(MimeTypeResolver.getMimeType("logo.png")).isEqualTo("image/png"); + } + + @Test + void shouldReturnImageJpegForJpgFiles() { + assertThat(MimeTypeResolver.getMimeType("photo.jpg")).isEqualTo("image/jpeg"); + assertThat(MimeTypeResolver.getMimeType("image.jpeg")).isEqualTo("image/jpeg"); + } + + @Test + void shouldReturnApplicationJsonForJsonFiles() { + assertThat(MimeTypeResolver.getMimeType("data.json")).isEqualTo("application/json"); + } + + @Test + void shouldReturnDefaultMimeTypeForUnknownExtension() { + assertThat(MimeTypeResolver.getMimeType("file.unknown")).isEqualTo("application/octet-stream"); + } + + @Test + void shouldReturnDefaultMimeTypeForFileWithoutExtension() { + assertThat(MimeTypeResolver.getMimeType("README")).isEqualTo("application/octet-stream"); + } + + @Test + void shouldReturnDefaultMimeTypeForNullFilename() { + assertThat(MimeTypeResolver.getMimeType(null)).isEqualTo("application/octet-stream"); + } + + @Test + void shouldReturnDefaultMimeTypeForEmptyFilename() { + assertThat(MimeTypeResolver.getMimeType("")).isEqualTo("application/octet-stream"); + } + + @Test + void shouldHandleUppercaseExtensions() { + assertThat(MimeTypeResolver.getMimeType("file.HTML")).isEqualTo("text/html"); + assertThat(MimeTypeResolver.getMimeType("file.CSS")).isEqualTo("text/css"); + assertThat(MimeTypeResolver.getMimeType("file.JS")).isEqualTo("application/javascript"); + } + + @Test + void shouldHandleMixedCaseExtensions() { + assertThat(MimeTypeResolver.getMimeType("file.HtMl")).isEqualTo("text/html"); + assertThat(MimeTypeResolver.getMimeType("photo.JpG")).isEqualTo("image/jpeg"); + } + + @Test + void shouldHandleFilesWithMultipleDots() { + assertThat(MimeTypeResolver.getMimeType("my.file.name.html")).isEqualTo("text/html"); + assertThat(MimeTypeResolver.getMimeType("bundle.min.js")).isEqualTo("application/javascript"); + } + + @Test + void shouldHandlePathsWithDirectories() { + assertThat(MimeTypeResolver.getMimeType("/css/styles.css")).isEqualTo("text/css"); + assertThat(MimeTypeResolver.getMimeType("/js/app.js")).isEqualTo("application/javascript"); + assertThat(MimeTypeResolver.getMimeType("/images/logo.png")).isEqualTo("image/png"); + } +} diff --git a/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java new file mode 100644 index 00000000..19af8605 --- /dev/null +++ b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java @@ -0,0 +1,163 @@ +package org.juv25d.handler; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +import static org.assertj.core.api.Assertions.assertThat; + +class StaticFileHandlerTest { + + @Test + void shouldReturn200ForExistingFile() { + HttpRequest request = createRequest("GET", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.statusText()).isEqualTo("OK"); + } + + @Test + void shouldReturnCorrectContentTypeForHtml() { + HttpRequest request = createRequest("GET", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.headers()).containsEntry("Content-Type", "text/html; charset=utf-8"); + } + + @Test + void shouldReturnCorrectContentTypeForCss() { + HttpRequest request = createRequest("GET", "/css/styles.css"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.headers()).containsEntry("Content-Type", "text/css; charset=utf-8"); + } + + @Test + void shouldReturnCorrectContentTypeForJs() { + HttpRequest request = createRequest("GET", "/js/app.js"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.headers()).containsEntry("Content-Type", "application/javascript; charset=utf-8"); + } + + @Test + void shouldReturn404ForNonExistingFile() { + HttpRequest request = createRequest("GET", "/nonexistent.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(response.statusText()).isEqualTo("Not Found"); + } + + @Test + void shouldReturn404ResponseWithHtmlContent() { + HttpRequest request = createRequest("GET", "/missing.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.body()).isNotEmpty(); + assertThat(new String(response.body())).contains("404"); + assertThat(new String(response.body())).contains("Not Found"); + } + + @Test + void shouldServeIndexHtmlForRootPath() { + HttpRequest request = createRequest("GET", "/"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers()).containsEntry("Content-Type", "text/html; charset=utf-8"); + assertThat(new String(response.body())).contains(""); + } + + @Test + void shouldReturn403ForPathTraversalAttempt() { + HttpRequest request = createRequest("GET", "/../../../etc/passwd"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(403); + assertThat(response.statusText()).isEqualTo("Forbidden"); + } + + @Test + void shouldReturn403ForPathWithDoubleDots() { + HttpRequest request = createRequest("GET", "/css/../../secrets.txt"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(403); + } + + @Test + void shouldReturn403ForPathWithDoubleSlashes() { + HttpRequest request = createRequest("GET", "//etc/passwd"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(403); + } + + @Test + void shouldReturn403ForPathWithBackslashes() { + HttpRequest request = createRequest("GET", "/css\\..\\secrets.txt"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(403); + } + + @Test + void shouldReturn405ForNonGetRequest() { + HttpRequest request = createRequest("POST", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(405); + assertThat(response.statusText()).isEqualTo("Method Not Allowed"); + } + + @Test + void shouldReturn405ForPutRequest() { + HttpRequest request = createRequest("PUT", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(405); + } + + @Test + void shouldReturn405ForDeleteRequest() { + HttpRequest request = createRequest("DELETE", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(405); + } + + @Test + void shouldHandleValidNestedPaths() { + HttpRequest request = createRequest("GET", "/css/styles.css"); + HttpResponse response = StaticFileHandler.handle(request); + + // Should either return 200 (if file exists) or 404 (if not), but NOT 403 + assertThat(response.statusCode()).isIn(200, 404); + } + + @Test + void shouldReturnNonEmptyBodyForSuccessfulRequest() { + HttpRequest request = createRequest("GET", "/index.html"); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.body()).isNotEmpty(); + assertThat(response.body().length).isGreaterThan(0); + } + + // Helper method to create HttpRequest objects + private HttpRequest createRequest(String method, String path) { + return new HttpRequest( + method, + path, + null, + "HTTP/1.1", + new HashMap<>(), + new byte[0], + "UNKNOWN" + ); + } +} diff --git a/src/test/java/org/juv25d/http/HttpParserTest.java b/src/test/java/org/juv25d/http/HttpParserTest.java new file mode 100644 index 00000000..d9161071 --- /dev/null +++ b/src/test/java/org/juv25d/http/HttpParserTest.java @@ -0,0 +1,200 @@ +package org.juv25d.http; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link HttpParser}. + *

    + * Covers: + * - Valid GET and POST requests + * - Query string parsing + * - Header parsing and validation + * - Content-Length handling, including invalid and negative values + * - Error handling for empty and malformed requests + */ +@DisplayName("HttpParser - unit tests") +class HttpParserTest { + + private HttpParser parser; + + @BeforeEach + void setUp() { + parser = new HttpParser(); + } + + /** + * Parses a well-formed GET request without a body and extracts method, path, + * HTTP version, and headers. + */ + @DisplayName("Parses a valid GET request without body") + @Test + void parseValidGetRequest() throws IOException { + // Arrange + String request = "GET /index.html HTTP/1.1\r\n" + + "Host: localhost:8080\r\n" + + "Connection: close\r\n" + + "\r\n"; + + // Act + HttpRequest result = parser.parse(createInputStream(request)); + + // Assert + assertThat(result.method()).isEqualTo("GET"); + assertThat(result.path()).isEqualTo("/index.html"); + assertThat(result.httpVersion()).isEqualTo("HTTP/1.1"); + assertThat(result.headers().get("Host")).isEqualTo("localhost:8080"); + assertThat(result.headers().get("Connection")).isEqualTo("close"); + assertThat(result.body()).isEmpty(); + assertThat(result.queryString()).isNull(); + } + + /** + * Rejects empty requests (empty or only CRLF) with an informative IOException. + */ + @DisplayName("Empty request β†’ IOException with 'The request is empty'") + @Test + void parseEmptyRequest_throwsException() { + // Arrange + String emptyStringRequest = ""; + String emptyRequest = "\r\n"; + + // Act + Assert + assertThatThrownBy(() -> parser.parse(createInputStream(emptyStringRequest))) + .isInstanceOf(IOException.class) + .hasMessage("The request is empty"); + assertThatThrownBy(() -> parser.parse(createInputStream(emptyRequest))) + .isInstanceOf(IOException.class) + .hasMessage("The request is empty"); + } + + /** + * Fails when the request line is malformed (missing HTTP version or parts). + */ + @DisplayName("Malformed request line β†’ IOException") + @Test + void parseMalformedRequest_throwsException() { + // Arrange + String request = "GET /index.html\r\n"; + + // Act + Assert + assertThatThrownBy(() -> parser.parse(createInputStream(request))) + .isInstanceOf(IOException.class) + .hasMessageContaining("Malformed request line"); + } + + /** + * Fails when a header line is malformed (missing name/value separator). + */ + @DisplayName("Malformed header line β†’ IOException") + @Test + void parseMalformedHeader_throwsException() { + // Arrange + String request = "GET /index.html HTTP/1.1\r\n" + + ":Host localhost:8080\r\n" + + "Connection: close\r\n"; + + // Act + Assert + assertThatThrownBy(() -> parser.parse(createInputStream(request))) + .isInstanceOf(IOException.class) + .hasMessageContaining("Malformed header line"); + } + + /** + * Extracts the query string and normalized path from the request target. + */ + @DisplayName("Parses query string and normalizes path") + @Test + void parseValidQueryString() throws IOException { + // Arrange + String request = "GET /search?q=java HTTP/1.1\r\n"; + + // Act + HttpRequest result = parser.parse(createInputStream(request)); + + // Assert + assertThat(result.path()).isEqualTo("/search"); + assertThat(result.queryString()).isEqualTo("q=java"); + } + + /** + * Parses a well-formed POST request with Content-Length and body. + */ + @DisplayName("Parses a valid POST request with headers and body") + @Test + void parseValidPostRequest() throws IOException { + // Arrange + String body = "body"; + String request = "POST /users HTTP/1.1\r\n" + + "Host: localhost:8080\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: " + body.getBytes(StandardCharsets.UTF_8).length + "\r\n" + + "\r\n" + + body; + + // Act + HttpRequest result = parser.parse(createInputStream(request)); + + // Assert + assertThat(result.method()).isEqualTo("POST"); + assertThat(result.path()).isEqualTo("/users"); + assertThat(result.httpVersion()).isEqualTo("HTTP/1.1"); + assertThat(result.headers().get("Host")).isEqualTo("localhost:8080"); + assertThat(result.headers().get("Content-Type")).isEqualTo("text/html"); + assertThat(result.body()).isEqualTo(body.getBytes()); + } + + /** + * Rejects non-numeric Content-Length values. + */ + @DisplayName("Invalid Content-Length (non-numeric) β†’ IOException") + @Test + void parseInvalidContentLength_throwsException() { + // Arrange + String request = "POST /users HTTP/1.1\r\n" + + "Host: localhost:8080\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: abc\r\n" + + "\r\n"; + + // Act + Assert + assertThatThrownBy(() -> parser.parse(createInputStream(request))) + .isInstanceOf(IOException.class) + .hasMessage("Invalid Content-Length: abc"); + } + + /** + * Rejects negative Content-Length values. + */ + @DisplayName("Negative Content-Length β†’ IOException") + @Test + void parseNegativeContentLength_throwsException() { + // Arrange + String request = "POST /users HTTP/1.1\r\n" + + "Host: localhost:8080\r\n" + + "Content-Type: text/html\r\n" + + "Content-Length: -10\r\n" + + "\r\n"; + + // Act + Assert + assertThatThrownBy(() -> parser.parse(createInputStream(request))) + .isInstanceOf(IOException.class) + .hasMessage("Negative Content-Length: -10"); + } + + /** + * Utility to wrap a request string into an {@link InputStream}. + */ + private InputStream createInputStream(String request) { + return new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/org/juv25d/http/HttpResponseWriterTest.java b/src/test/java/org/juv25d/http/HttpResponseWriterTest.java new file mode 100644 index 00000000..aa5504a1 --- /dev/null +++ b/src/test/java/org/juv25d/http/HttpResponseWriterTest.java @@ -0,0 +1,59 @@ +package org.juv25d.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class HttpResponseWriterTest { + + + @Test + @DisplayName("Should write a valid HTTP 200 OK response ") + void writesValidHttp200Response() throws Exception { + // Arrange + HttpResponse response = new HttpResponse( + 200, + "OK", + Map.of("Content-Type", "text/plain"), + "Hello World".getBytes(StandardCharsets.UTF_8) + ); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + HttpResponseWriter.write(out, response); + + // Assert + String result = out.toString(StandardCharsets.UTF_8); + + assertThat(result).startsWith("HTTP/1.1 200 OK"); + assertThat(result).contains("Content-Type: text/plain"); + assertThat(result).contains("Content-Length: 11"); + assertThat(result).endsWith("Hello World"); + } + + @Test + @DisplayName("Should write a valid HTTP 404 Not Found Response") + void writes404NotFoundResponse() throws Exception { + HttpResponse response = new HttpResponse( + 404, + "Not Found", + Map.of("Content-Type", "text/plain"), + "Not found".getBytes(StandardCharsets.UTF_8) + ); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + HttpResponseWriter.write(out, response); + + String result = out.toString(StandardCharsets.UTF_8); + + assertThat(result).startsWith("HTTP/1.1 404 Not Found"); + } + +} diff --git a/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java b/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java new file mode 100644 index 00000000..ceb499f9 --- /dev/null +++ b/src/test/java/org/juv25d/logging/ConnectionIdLoggingTest.java @@ -0,0 +1,49 @@ +package org.juv25d.logging; + +import org.junit.jupiter.api.Test; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConnectionIdLoggingTest { + + @Test + void logMessageShouldIncludeConnectionId() { + // Arrange + Logger logger = Logger.getLogger("test.connectionid"); + logger.setUseParentHandlers(false); + + List formattedMessages = new ArrayList<>(); + ServerLogFormatter formatter = new ServerLogFormatter(); + + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + formattedMessages.add(formatter.format(record)); + } + @Override + public void flush() {} + @Override + public void close() throws SecurityException {} + }; + logger.addHandler(handler); + + try { + String testId = "test-123"; + LogContext.setConnectionId(testId); + + // Act + logger.info("This is a test message"); + + // Assert + assertTrue(formattedMessages.get(0).contains("[" + testId + "]"), + "Log message should contain the connection ID. Found: " + formattedMessages.get(0)); + } finally { + LogContext.clear(); + } + } +} diff --git a/src/test/java/org/juv25d/logging/ServerLoggingTest.java b/src/test/java/org/juv25d/logging/ServerLoggingTest.java new file mode 100644 index 00000000..915a543b --- /dev/null +++ b/src/test/java/org/juv25d/logging/ServerLoggingTest.java @@ -0,0 +1,142 @@ +package org.juv25d.logging; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.juv25d.util.ConfigLoader; + +import java.util.logging.ConsoleHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +class ServerLoggingTest { + + private Logger logger; + + //Arrange + @BeforeEach + void setUp() { + logger = ServerLogging.getLogger(); + + // Clean handlers + for (Handler handler : logger.getHandlers()) { + logger.removeHandler(handler); + } + } + + @Test + @DisplayName("Logger should return same instance") + void getLogger_shouldReturnSameInstance() { + Logger logger1 = ServerLogging.getLogger(); + Logger logger2 = ServerLogging.getLogger(); + + assertSame(logger1, logger2); + } + + @Test + @DisplayName("Logger should have console handler") + void logger_shouldHaveConsoleHandler() { + Logger logger = ServerLogging.getLogger(); + + // Reset logger + for (Handler handler : logger.getHandlers()) { + logger.removeHandler(handler); + } + + // Explicit configure + ServerLogging.configure(logger); + + boolean hasConsoleHandler = false; + for (Handler handler : logger.getHandlers()) { + if (handler instanceof ConsoleHandler) { + hasConsoleHandler = true; + break; + } + } + + assertTrue(hasConsoleHandler, "Logger should have a Console Handler"); + } + + @Test + @DisplayName("Logger should not add duplicate handlers") + void logger_shouldNotAddDuplicateHandlers() { + Logger logger = ServerLogging.getLogger(); + + // Clean slate + for (Handler handler : logger.getHandlers()) { + logger.removeHandler(handler); + } + + ServerLogging.configure(logger); + int handlerCountAfterFirst = logger.getHandlers().length; + + ServerLogging.configure(logger); + int handlerCountAfterSecond = logger.getHandlers().length; + + assertEquals(1, handlerCountAfterFirst); + assertEquals(handlerCountAfterFirst, handlerCountAfterSecond, + "configure() should not add duplicate handlers"); + } + + @Test + @DisplayName("Logger should have INFO level by default") + void logger_shouldHaveInfoLevelByDefault() { + Logger logger = ServerLogging.getLogger(); + + assertEquals(Level.INFO, logger.getLevel()); + } + + @Test + @DisplayName("Logger should not use parent handlers") + void logger_shouldNotUseParentHandlers() { + Logger logger = ServerLogging.getLogger(); + + assertFalse(logger.getUseParentHandlers()); + } + + @Test + @DisplayName("Logger should use log level from system property") + void logger_shouldUseLogLevelFromSystemProperty() { + String original = System.getProperty("log.level"); + + try { + System.setProperty("log.level", "WARNING"); + + Logger testLogger = Logger.getLogger("test.logger"); + ServerLogging.configure(testLogger); + + assertEquals(Level.WARNING, testLogger.getLevel()); + + } finally { + // Reset system state + if (original == null) { + System.clearProperty("log.level"); + } else { + System.setProperty("log.level", original); + } + } + } + + @Test + @DisplayName("Logger should fall back to logging.level from application-properties.yml") + void logger_shouldUseLogLevelFromApplicationProperties() { + String configLogLevel = ConfigLoader.getInstance().getLogLevel(); + + try { + System.clearProperty("log.level"); + + Level expectedLevel = Level.parse(configLogLevel.toUpperCase()); + + Logger testLogger = Logger.getLogger("test.logger"); + ServerLogging.configure(testLogger); + + assertEquals(expectedLevel, testLogger.getLevel()); + + } finally { + System.clearProperty("log.level"); + } + } +} diff --git a/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java b/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java new file mode 100644 index 00000000..f0d452fb --- /dev/null +++ b/src/test/java/org/juv25d/plugin/NotFoundPluginTest.java @@ -0,0 +1,26 @@ +package org.juv25d.plugin; + +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class NotFoundPluginTest { + + @Test + void sets404StatusAndBody() throws IOException { + NotFoundPlugin plugin = new NotFoundPlugin(); + HttpRequest req = new HttpRequest("GET", "/unknown", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + HttpResponse res = new HttpResponse(); + + plugin.handle(req, res); + + assertEquals(404, res.statusCode()); + assertEquals("Not Found", res.statusText()); + assertArrayEquals("404 - Resource Not Found".getBytes(), res.body()); + } +} diff --git a/src/test/java/org/juv25d/router/SimpleRouterTest.java b/src/test/java/org/juv25d/router/SimpleRouterTest.java new file mode 100644 index 00000000..9c9e29d5 --- /dev/null +++ b/src/test/java/org/juv25d/router/SimpleRouterTest.java @@ -0,0 +1,121 @@ +package org.juv25d.router; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.juv25d.http.HttpRequest; +import org.juv25d.plugin.NotFoundPlugin; +import org.juv25d.plugin.Plugin; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class SimpleRouterTest { + + private SimpleRouter router; + private Plugin mockPluginA; + private Plugin mockPluginB; + private Plugin notFoundPlugin; + + @BeforeEach + void setUp() { + router = new SimpleRouter(); + mockPluginA = mock(Plugin.class); + mockPluginB = mock(Plugin.class); + notFoundPlugin = new NotFoundPlugin(); // Assuming NotFoundPlugin is a concrete class + } + + @Test + void resolve_returnsRegisteredPluginForExactPath() { + router.registerPlugin("/pathA", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/pathA", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginA, resolvedPlugin); + } + + @Test + void resolve_returnsNotFoundPluginForUnregisteredPath() { + router.registerPlugin("/pathA", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/nonExistentPath", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + // Assuming SimpleRouter's constructor initializes notFoundPlugin + // or there's a way to get it for assertion + assertTrue(resolvedPlugin instanceof NotFoundPlugin); + } + + @Test + void resolve_returnsWildcardPluginForMatchingPath() { + router.registerPlugin("/api/*", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginA, resolvedPlugin); + } + + @Test + void resolve_returnsNotFoundPluginIfNoWildcardMatch() { + router.registerPlugin("/admin/*", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertTrue(resolvedPlugin instanceof NotFoundPlugin); + } + + @Test + void resolve_prefersExactMatchOverWildcard() { + router.registerPlugin("/api/users", mockPluginA); + router.registerPlugin("/api/*", mockPluginB); + HttpRequest request = new HttpRequest("GET", "/api/users", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginA, resolvedPlugin); + } + + @Test + void resolve_handlesRootPath() { + router.registerPlugin("/", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginA, resolvedPlugin); + } + + @Test + void resolve_handlesRootWildcardPath() { + router.registerPlugin("/*", mockPluginA); + HttpRequest request = new HttpRequest("GET", "/any/path/here", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginA, resolvedPlugin); + } + + @Test + void resolve_returnsNotFoundPluginForEmptyRouter() { + HttpRequest request = new HttpRequest("GET", "/anypath", null, "HTTP/1.1", Map.of(), new byte[0], "UNKNOWN"); + Plugin resolvedPlugin = router.resolve(request); + assertTrue(resolvedPlugin instanceof NotFoundPlugin); + } + + @Test + void resolve_prefersMoreSpecificWildcardOverLessSpecific() { + router.registerPlugin("/api/*", mockPluginA); + router.registerPlugin("/api/users/*", mockPluginB); + + HttpRequest request = new HttpRequest( + "GET", + "/api/users/123", + null, + "HTTP/1.1", + Map.of(), + new byte[0], + "UNKNOWN" + ); + + Plugin resolvedPlugin = router.resolve(request); + assertEquals(mockPluginB, resolvedPlugin); + } + +}