Full Code of aternosorg/mclogs for AI

main 5269341d2bad cached
98 files
217.8 KB
58.5k tokens
357 symbols
1 requests
Download .txt
Showing preview only (241K chars total). Download the full file or copy to clipboard to get everything.
Repository: aternosorg/mclogs
Branch: main
Commit: 5269341d2bad
Files: 98
Total size: 217.8 KB

Directory structure:
gitextract_kqffgv0b/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── publish.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── build.php
├── composer.json
├── dev/
│   ├── compose.yaml
│   └── dev.ini
├── docker/
│   ├── Caddyfile
│   ├── compose.production.yaml
│   └── mclogs.ini
├── example.config.json
├── src/
│   ├── Api/
│   │   ├── Action/
│   │   │   ├── AnalyseLogAction.php
│   │   │   ├── ApiAction.php
│   │   │   ├── BulkDeleteLogsAction.php
│   │   │   ├── CreateLogAction.php
│   │   │   ├── DeleteLogAction.php
│   │   │   ├── EmptyAction.php
│   │   │   ├── EndpointNotFoundAction.php
│   │   │   ├── GetFiltersAction.php
│   │   │   ├── GetLimitsAction.php
│   │   │   ├── LogInfoAction.php
│   │   │   ├── LogInsightsAction.php
│   │   │   ├── RateLimitErrorAction.php
│   │   │   └── RawLogAction.php
│   │   ├── ApiRouter.php
│   │   ├── ContentParser.php
│   │   ├── LogContentParser.php
│   │   └── Response/
│   │       ├── ApiError.php
│   │       ├── ApiResponse.php
│   │       ├── CodexLogResponse.php
│   │       ├── FiltersResponse.php
│   │       ├── LimitsResponse.php
│   │       ├── LogResponse.php
│   │       ├── MultiResponse.php
│   │       └── RawLogResponse.php
│   ├── Cache/
│   │   └── CacheEntry.php
│   ├── Config/
│   │   ├── Config.php
│   │   └── ConfigKey.php
│   ├── Data/
│   │   ├── Deobfuscator.php
│   │   ├── MetadataEntry.php
│   │   └── Token.php
│   ├── Detective.php
│   ├── Filter/
│   │   ├── AccessTokenFilter.php
│   │   ├── Filter.php
│   │   ├── FilterType.php
│   │   ├── IPv4Filter.php
│   │   ├── IPv6Filter.php
│   │   ├── LimitBytesFilter.php
│   │   ├── LimitLinesFilter.php
│   │   ├── Pattern/
│   │   │   ├── Modifier.php
│   │   │   ├── Pattern.php
│   │   │   └── PatternWithReplacement.php
│   │   ├── RegexFilter.php
│   │   ├── TrimFilter.php
│   │   └── UsernameFilter.php
│   ├── Frontend/
│   │   ├── Action/
│   │   │   ├── ApiDocsAction.php
│   │   │   ├── CreateLogAction.php
│   │   │   ├── DeleteLogAction.php
│   │   │   ├── FaviconAction.php
│   │   │   ├── NotFoundAction.php
│   │   │   ├── StartAction.php
│   │   │   └── ViewLogAction.php
│   │   ├── Assets/
│   │   │   ├── Asset.php
│   │   │   ├── AssetLoader.php
│   │   │   └── AssetType.php
│   │   ├── Cookie/
│   │   │   ├── Cookie.php
│   │   │   ├── SettingsCookie.php
│   │   │   └── TokenCookie.php
│   │   ├── FrontendRouter.php
│   │   └── Settings/
│   │       ├── Setting.php
│   │       └── Settings.php
│   ├── Id.php
│   ├── Log.php
│   ├── Printer/
│   │   ├── FormatModification.php
│   │   └── Printer.php
│   ├── Router/
│   │   ├── Action.php
│   │   ├── Method.php
│   │   ├── Route.php
│   │   └── Router.php
│   ├── Storage/
│   │   └── MongoDBClient.php
│   └── Util/
│       ├── Singleton.php
│       ├── TimeInterval.php
│       └── URL.php
├── web/
│   ├── frontend/
│   │   ├── 404.php
│   │   ├── api-docs.php
│   │   ├── log.php
│   │   ├── parts/
│   │   │   ├── favicon.php
│   │   │   ├── footer.php
│   │   │   ├── head.php
│   │   │   └── header.php
│   │   └── start.php
│   └── public/
│       ├── css/
│       │   └── mclogs.css
│       └── js/
│           ├── log.js
│           └── start.js
└── worker.php

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
vendor/
.git/
.github/
Dockerfile

================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish Docker Image

on:
  push:
    branches:
      - 'main'
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # Branch Name (e.g. 'two', 'main')
            type=ref,event=branch
            # Full Version (e.g. '1.2.3')
            type=semver,pattern={{version}}
            # Major Version (e.g. '1')
            type=semver,pattern={{major}}
            # Major.Minor (e.g. '1.2')
            type=semver,pattern={{major}}.{{minor}}
            # Latest (Only on release tags)
            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          platforms: linux/amd64,linux/arm64
          cache-from: |
            type=gha
            type=gha,scope=refs/heads/main
          cache-to: type=gha,mode=max

================================================
FILE: .gitignore
================================================
*.log
*.cache
.idea
/vendor/
config.json

================================================
FILE: Dockerfile
================================================
FROM dunglas/frankenphp:1-php8.5

# System Setup
RUN install-php-extensions mongodb zip

ARG USER=mclogs
RUN useradd ${USER} && \
    setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp

COPY --from=composer/composer:2-bin /composer /usr/bin/composer

WORKDIR /app

# Dependencies (Cached)
COPY composer.json composer.lock ./

RUN --mount=type=cache,target=/tmp/cache/composer \
    COMPOSER_CACHE_DIR=/tmp/cache/composer \
    composer install --no-dev --no-interaction --no-scripts --no-autoloader --prefer-dist --ignore-platform-req=ext-frankenphp

# Application Setup
COPY docker/Caddyfile /etc/frankenphp/Caddyfile
COPY docker/mclogs.ini /usr/local/etc/php/conf.d/mclogs.ini

COPY . .

RUN composer dump-autoload --optimize --no-dev --classmap-authoritative
RUN php build.php

# Permissions & Runtime
RUN chown -R ${USER}:${USER} /config/caddy /data/caddy /app

USER ${USER}

EXPOSE 80
EXPOSE 443
EXPOSE 443/udp

VOLUME ["/data"]

================================================
FILE: LICENSE
================================================
Copyright (c) 2018-2024 Aternos GmbH

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<p align="center">
    <img src="web/public/img/logo.svg" width="260">
</p>
<p align="center">
    <strong>Paste, share & analyse your logs</strong><br>
    Built for Minecraft & Hytale
</p>

## Features
* Share logs by pasting or uploading files
* Automatic removal of sensitive information (e.g. IP addresses)
* Short URLs for easy sharing
* Syntax highlighting
* Line numbers
* Direct links to specific lines
* Analysis and parsing using [codex](https://github.com/aternosorg/codex-minecraft)

### For developers
* Upload your logs using the API
* Add metadata to shared logs, e.g. version numbers, server ids, etc.
* Retrieve logs and their metadata from the API
* Open source and self-hostable

## Self-hosting
You can self-host mclogs using Docker. A [docker image](https://github.com/aternosorg/mclogs/pkgs/container/mclogs) is available in the GitHub Container Registry: `ghcr.io/aternosorg/mclogs`. 
A MongoDB instance is also required to run mclogs.

An example docker compose files for self-hosting can be found here: [docker/compose.production.yaml](docker/compose.production.yaml).

### Config
You can configure mclogs by creating a `config.json` file in the root directory, see [example.config.json](example.config.json) or by setting
environment variables. Environment variables override settings in the config file.

Here is a list of all available config options:

| Variable / JSON Path                                                | Default             | Description                                 |
|:--------------------------------------------------------------------|:--------------------|:--------------------------------------------|
| `MCLOGS_STORAGE_TTL` <br> `storage.ttl`                             | `7776000` (90d)     | Time until logs are deleted after last view |
| `MCLOGS_STORAGE_LIMIT_BYTES` <br> `storage.limit.bytes`             | `10485760` (10 MiB) | Maximum size of a log in bytes              |
| `MCLOGS_STORAGE_LIMIT_LINES` <br> `storage.limit.lines`             | `25000`             | Maximum number of lines in a log            |
| `MCLOGS_MONGODB_URL` <br> `mongodb.url`                             | `"mongodb://mongo"` | MongoDB connection URL                      |
| `MCLOGS_MONGODB_DATABASE` <br> `mongodb.database`                   | `"mclogs"`          | Name of the MongoDB database                |
| `MCLOGS_ID_LENGTH` <br> `id.length`                                 | `7`                 | The default length for new IDs              |
| `MCLOGS_LEGAL_ABUSE` <br> `legal.abuse`                             | `null`              | Public email address to report abuse        |
| `MCLOGS_LEGAL_IMPRINT` <br> `legal.imprint`                         | `null`              | The imprint URL                             |
| `MCLOGS_LEGAL_PRIVACY` <br> `legal.privacy`                         | `null`              | The privacy policy URL                      |
| `MCLOGS_FRONTEND_NAME` <br> `frontend.name`                         | `null`              | Instance name (defaults to domain)          |
| `MCLOGS_FRONTEND_COLOR_ACCENT` <br> `frontend.color.accent`         | `#5cb85c`           | The accent/primary color                    |
| `MCLOGS_FRONTEND_COLOR_BACKGROUND` <br> `frontend.color.background` | `#1a1a1a`           | The background color                        |
| `MCLOGS_FRONTEND_COLOR_TEXT` <br> `frontend.color.text`             | `#e8e8e8`           | The text color                              |
| `MCLOGS_FRONTEND_COLOR_ERROR` <br> `frontend.color.error`           | `#f62451`           | The error color                             |
| `MCLOGS_WORKER_REQUESTS` <br> `worker.requests`                     | `500`               | Max requests per single worker              |

There are a few more environment variables that can be set to modify the FrankenPHP/Caddy setup directly:

| Variable             | Default            | Description                                                                                                                                |
|----------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `SERVER_NAME`        | `":80"`            | Set the Caddy server name, set this to your domain for [automatic SSL](https://caddyserver.com/docs/automatic-https#hostname-requirements) |
| `TRUSTED_PROXIES`    | `"private_ranges"` | Set [trusted proxy](https://caddyserver.com/docs/caddyfile/options#trusted-proxies) address ranges                                         |
| `FRANKENPHP_WORKERS` | `16`               | The number of [FrankenPHP workers](https://frankenphp.dev/docs/worker/)                                                                    |                                                                                                                                            |


## Development setup
### Prerequisites
* [Docker](https://www.docker.com/get-started/) and [Docker Compose](https://docs.docker.com/compose/install/)
* [PHP 8.5+](https://www.php.net/downloads)
* [Composer](https://getcomposer.org/download/)

### Installation
```bash
# clone repository
git clone git@github.com:aternosorg/mclogs.git

# install composer dependencies
cd mclogs
composer install

# start development environment
cd dev
docker compose up
```
Open http://localhost in browser and enjoy.

================================================
FILE: build.php
================================================
<?php

require_once __DIR__ . '/vendor/autoload.php';

\Aternos\Mclogs\Frontend\Assets\AssetLoader::getInstance()->writeCache();

================================================
FILE: composer.json
================================================
{
    "name": "aternos/mclogs",
    "description": "Paste, share and analyse Minecraft logs",
    "authors": [
        {
            "name": "Matthias Neid",
            "email": "matthias@aternos.org"
        }
    ],
    "require": {
        "php": ">=8.5",
        "ext-frankenphp": "*",
        "ext-json": "*",
        "ext-mbstring": "*",
        "ext-mongodb": "*",
        "ext-uri": "*",
        "ext-zlib": "*",
        "aternos/codex-hytale": "^2.0",
        "aternos/codex-minecraft": "^5.0.1",
        "aternos/sherlock": "^1.0.2",
        "mongodb/mongodb": "2.1.2"
    },
    "autoload": {
        "psr-4": {
            "Aternos\\Mclogs\\": "src/"
        }
    }
}


================================================
FILE: dev/compose.yaml
================================================
name: "mclogs"
services:
  web:
    build:
      context: ..
      dockerfile: ./Dockerfile
    environment:
      - MCLOGS_WORKER_REQUESTS=1
      - FRANKENPHP_WORKERS=4
    ports:
      - "80:80"
    volumes:
      - ../:/app
      - ./dev.ini:/usr/local/etc/php/conf.d/dev.ini
    user: root
    depends_on:
      mongo:
        condition: service_healthy

  mongo:
    image: mongo
    volumes:
      - mongo:/data/db
    healthcheck:
      test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

volumes:
  mongo:


================================================
FILE: dev/dev.ini
================================================
opcache.enable=1
opcache.enable_cli=1

opcache.validate_timestamps=1
opcache.revalidate_freq=0

opcache.jit=off

================================================
FILE: docker/Caddyfile
================================================
{
    servers {
        trusted_proxies static {$TRUSTED_PROXIES:private_ranges}
        trusted_proxies_strict
    }

    frankenphp {
        worker /app/worker.php {
            num {$FRANKENPHP_WORKERS:16}
        }
    }
}

{$SERVER_NAME::80} {
    root * /app/web/public
    encode zstd br gzip

    @static file
    handle @static {
        file_server
    }

    handle {
       root * /app
       rewrite * /worker.php
       php_server
    }
}

================================================
FILE: docker/compose.production.yaml
================================================
services:
  web:
    image: ghcr.io/aternosorg/mclogs:2
    restart: always
    ports:
      # Expose HTTP (80) and HTTPS (443)
      # Port 443/udp is required for HTTP/3 (QUIC)
      - "80:80"
      - "443:443"
      - "443:443/udp"
    environment:
      # Set this to your domain (e.g., mclogs.example.com) to enable Auto-SSL.
      # If running behind a proxy (Cloudflare/Nginx), set to ":80" to disable Auto-SSL.
      SERVER_NAME: :80

      MCLOGS_MONGODB_URL: mongodb://mongo:27017
      MCLOGS_MONGODB_DATABASE: mclogs

      # Optional MCLOGS configuration
      # See README.md for full list of available options
      # MCLOGS_FRONTEND_NAME: "mclogs"

    volumes:
      # For caddy cache (SSL certificates)
      - web-data:/data

    depends_on:
      mongo:
        condition: service_healthy

  mongo:
    image: mongo
    restart: always
    volumes:
      - mongo-data:/data/db

    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 5s

volumes:
  web-data:
  mongo-data:

================================================
FILE: docker/mclogs.ini
================================================
post_max_size = 50M

error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /dev/stderr

================================================
FILE: example.config.json
================================================
{
    "storage": {
        "ttl": 7776000,
        "limit": {
            "bytes": 10485760,
            "lines": 25000
        }
    },
    "mongodb": {
        "url": "mongodb://127.0.0.1:27017",
        "database": "mclogs"
    },
    "id": {
        "length": 7
    },
    "legal": {
        "abuse": "abuse@aternos.org",
        "imprint": "https://aternos.gmbh/imprint/",
        "privacy": "https://aternos.gmbh/en/mclogs/privacy"
    },
    "frontend": {
        "name": "mclo.gs",
        "assets": {
            "integrity": true
        },
        "color": {
            "background": "#1a1a1a",
            "text": "#e8e8e8",
            "accent": "#5cb85c",
            "error": "#f62451"
        }
    },
    "worker": {
        "requests": 500
    }
}

================================================
FILE: src/Api/Action/AnalyseLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\LogContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\CodexLogResponse;
use Aternos\Mclogs\Log;

class AnalyseLogAction extends ApiAction
{
    public function runApi(): ApiResponse
    {
        $data = new LogContentParser()->getContent();

        if ($data instanceof ApiError) {
            return $data;
        }

        $content = $data['content'];
        $log = new Log()->setContent($content);

        return new CodexLogResponse($log->getCodexLog());
    }
}


================================================
FILE: src/Api/Action/ApiAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\ContentParser;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Router\Action;

abstract class ApiAction extends Action
{
    abstract protected function runApi(): ApiResponse;

    protected function getAllowedOrigin(): string
    {
        return '*';
    }

    protected function shouldAllowCredentials(): bool
    {
        return false;
    }

    public function run(): bool
    {
        header('Access-Control-Allow-Origin: ' . $this->getAllowedOrigin());
        header('Access-Control-Allow-Headers: *');
        if ($this->shouldAllowCredentials()) {
            header('Access-Control-Allow-Credentials: true');
        }
        header("Accept-Encoding: " . implode(",", ContentParser::getSupportedEncodings()));

        $response = $this->runApi();
        $response->output();

        return true;
    }
}

================================================
FILE: src/Api/Action/BulkDeleteLogsAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\ContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\MultiResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Storage\MongoDBClient;

class BulkDeleteLogsAction extends ApiAction
{
    public const int MAX_IDS = 256;

    /**
     * @return ApiResponse
     */
    protected function runApi(): ApiResponse
    {
        $data = new ContentParser()->getContent();
        if ($data instanceof ApiError) {
            return $data;
        }

        if (count($data) === 0) {
            return new ApiError(400, "No logs provided.");
        }
        if (count($data) > static::MAX_IDS) {
            return new ApiError(400, "Too many logs. Maximum is " . static::MAX_IDS . ".");
        }

        $ids = [];
        foreach ($data as $log) {
            if (!is_array($log)) {
                return new ApiError(400, "Each entry must be an object with 'id' and 'token' fields.");
            }
            if (!isset($log["id"]) || !is_string($log["id"]) ||
                !preg_match("/^" . Id::PATTERN . "$/", $log["id"])) {
                return new ApiError(400, "Each log must have a valid 'id' field.");
            }
            if (!isset($log["token"]) || !is_string($log["token"])) {
                return new ApiError(400, "Each log must have a valid 'token' field.");
            }
            $ids[] = $log["id"];
        }

        $logs = Log::findAll($ids, false);

        $deleteIds = [];
        $response = new MultiResponse();
        foreach ($data as $log) {
            $id = $log["id"];
            $token = $log["token"];

            $log = $logs[$id] ?? null;
            if (!$log) {
                $response->addResponse($id, new ApiError(404, "Log not found."));
                continue;
            }

            $logToken = $log->getToken();
            if (!$logToken || !$logToken->matches($token)) {
                $response->addResponse($id, new ApiError(403, "Invalid token."));
                continue;
            }

            $deleteIds[] = $id;
            $response->addResponse($id, new ApiResponse());
        }

        MongoDBClient::getInstance()->deleteLogs($deleteIds);

        return $response;
    }
}


================================================
FILE: src/Api/Action/CreateLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\LogContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LogResponse;
use Aternos\Mclogs\Data\MetadataEntry;
use Aternos\Mclogs\Log;

class CreateLogAction extends ApiAction
{
    protected bool $includeCookie = false;
    protected bool $includeToken = true;

    public function runApi(): ApiResponse
    {
        $data = new LogContentParser()->getContent();

        if ($data instanceof ApiError) {
            return $data;
        }

        $content = $data['content'];
        $metadata = [];
        if (isset($data['metadata']) && is_array($data['metadata'])) {
            $metadata = MetadataEntry::allFromArray($data['metadata']);
        }
        $source = null;
        if (isset($data['source']) && is_string($data['source'])) {
            $source = $data['source'];
        }

        $log = Log::create($content, $metadata, $source);

        if ($this->includeCookie) {
            $log->setTokenCookie();
        }

        return new LogResponse($log, $this->includeToken);
    }
}


================================================
FILE: src/Api/Action/DeleteLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class DeleteLogAction extends ApiAction
{
    protected function getRequestToken(): ?string
    {
        $authorizationHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
        if (!$authorizationHeader) {
            return null;
        }
        $parts = explode(" ", $authorizationHeader);
        return $parts[1] ?? null;
    }

    /**
     * @return ApiResponse
     */
    protected function runApi(): ApiResponse
    {
        $requestToken = $this->getRequestToken();

        if (!$requestToken) {
            return new ApiError(400, "Missing token.");
        }

        $id = new Id(URL::getLastPathPart());
        $log = Log::find($id);

        if (!$log) {
            return new ApiError(404, "Log not found.");
        }

        $token = $log->getToken();
        if (!$token || !$token->matches($requestToken)) {
            return new ApiError(403, "Invalid token.");
        }

        $deleted = $log->delete();
        if (!$deleted) {
            return new ApiError(500, "Failed to delete log.");
        }

        $this->handleDeletedLog($log);

        return new ApiResponse();
    }

    protected function handleDeletedLog(Log $log): void
    {

    }
}


================================================
FILE: src/Api/Action/EmptyAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Router\Action;

class EmptyAction extends Action
{
    public function run(): bool
    {
        return true;
    }
}

================================================
FILE: src/Api/Action/EndpointNotFoundAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;

class EndpointNotFoundAction extends ApiAction
{
    protected function runApi(): ApiResponse
    {
        return new ApiError(404, "Could not find endpoint.");
    }
}

================================================
FILE: src/Api/Action/GetFiltersAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\FiltersResponse;

class GetFiltersAction extends ApiAction
{
    protected function runApi(): ApiResponse
    {
        return new FiltersResponse();
    }
}

================================================
FILE: src/Api/Action/GetLimitsAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LimitsResponse;

class GetLimitsAction extends ApiAction
{
    protected function runApi(): ApiResponse
    {
        return new LimitsResponse();
    }
}

================================================
FILE: src/Api/Action/LogInfoAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class LogInfoAction extends ApiAction
{
    /**
     * @return ApiResponse
     */
    protected function runApi(): ApiResponse
    {
        $id = new Id(URL::getLastPathPart());
        $log = Log::find($id);

        if (!$log) {
            return new ApiError(404, "Log not found.");
        }

        return new LogResponse($log);
    }
}

================================================
FILE: src/Api/Action/LogInsightsAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\CodexLogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class LogInsightsAction extends ApiAction
{
    /**
     * @return ApiResponse
     */
    protected function runApi(): ApiResponse
    {
        $id = new Id(URL::getLastPathPart());
        $log = Log::find($id);

        if (!$log) {
            return new ApiError(404, "Log not found.");
        }

        $codexLog = $log->getCodexLog();
        $codexLog->setIncludeEntries(false);

        return new CodexLogResponse($codexLog);
    }
}

================================================
FILE: src/Api/Action/RateLimitErrorAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;

class RateLimitErrorAction extends ApiAction
{
    protected function runApi(): ApiResponse
    {
        return new ApiError(
            429,
            "Unfortunately you have exceeded the rate limit for the current time period. Please try again later."
        );
    }
}

================================================
FILE: src/Api/Action/RawLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Api\Action;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\RawLogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class RawLogAction extends ApiAction
{
    /**
     * @return ApiResponse
     */
    protected function runApi(): ApiResponse
    {
        $id = new Id(URL::getLastPathPart());
        $log = Log::find($id);

        if (!$log) {
            return new ApiError(404, "Log not found.");
        }

        return new RawLogResponse($log);
    }
}

================================================
FILE: src/Api/ApiRouter.php
================================================
<?php

namespace Aternos\Mclogs\Api;

use Aternos\Mclogs\Router\Router;
use Aternos\Mclogs\Frontend;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Router\Method;

class ApiRouter extends Router
{
    protected function __construct()
    {
        parent::__construct();
        $this->register(Method::GET, "#^/$#", new Frontend\Action\ApiDocsAction())
            ->register(Method::OPTIONS, "#^/.*$#", new Action\EmptyAction())
            ->register(Method::POST, "#^/1/log/?$#", new Action\CreateLogAction())
            ->register(Method::GET, "#^/1/log/" . Id::PATTERN . "$#", new Action\LogInfoAction())
            ->register(Method::DELETE, "#^/1/log/" . Id::PATTERN . "$#", new Action\DeleteLogAction())
            ->register(Method::POST, "#^/1/bulk/log/delete/?$#", new Action\BulkDeleteLogsAction())
            ->register(Method::GET, "#^/1/insights/" . Id::PATTERN . "$#", new Action\LogInsightsAction())
            ->register(Method::GET, "#^/1/raw/" . Id::PATTERN . "$#", new Action\RawLogAction())
            ->register(Method::POST, "#^/1/analyse/?$#", new Action\AnalyseLogAction())
            ->register(Method::GET, "#^/1/errors/rate$#", new Action\RateLimitErrorAction())
            ->register(Method::GET, "#^/1/limits$#", new Action\GetLimitsAction())
            ->register(Method::GET, "#^/1/filters#", new Action\GetFiltersAction())
            ->setDefaultAction(new Action\EndpointNotFoundAction());
    }
}


================================================
FILE: src/Api/ContentParser.php
================================================
<?php

namespace Aternos\Mclogs\Api;

use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;

/**
 * Utility class for reading log content from the http request
 */
class ContentParser
{
    protected const int MAX_ENCODING_STEPS = 5;

    /**
     * Get all supported content encodings
     * @return string[]
     */
    public static function getSupportedEncodings(): array
    {
        return ["deflate", "gzip", "x-gzip"];
    }

    /**
     * Get the content from the http request
     *
     * @return array|ApiError An array with the content or an ApiError on failure
     */
    public function getContent(): array|ApiError
    {
        $limit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES) * 2;
        $body = file_get_contents('php://input', length: $limit + 1);
        if ($body === false) {
            return new ApiError(500, "Failed to read request body.");
        }
        if (strlen($body) > $limit) {
            return new ApiError(413, "Request body exceeds maximum allowed size.");
        }

        $encodingHeader = $_SERVER['HTTP_CONTENT_ENCODING'] ?? '';
        if ($encodingHeader) {
            $encodingSteps = explode(',', $encodingHeader);
            if (count($encodingSteps) > static::MAX_ENCODING_STEPS) {
                return new ApiError(400, "Too many Content-Encoding steps.");
            }
            foreach (array_reverse($encodingSteps) as $step) {
                switch (trim(strtolower($step))) {
                    case "deflate":
                        $body = @gzinflate($body, $limit);
                        break;
                    case "x-gzip":
                    case "gzip":
                        $body = @gzdecode($body, $limit);
                        break;
                    default:
                        return new ApiError(415, "Unsupported Content-Encoding: " . htmlspecialchars($step));
                }
                if ($body === false) {
                    return new ApiError(400, "Failed to decode request body with encoding: " . htmlspecialchars($step));
                }
            }
        }

        $contentTypeHeader = $_SERVER['CONTENT_TYPE'] ?? '';
        if ($pos = strpos($contentTypeHeader, ';')) {
            $contentTypeHeader = substr($contentTypeHeader, 0, $pos);
        }
        switch ($contentTypeHeader) {
            case "application/x-www-form-urlencoded":
                parse_str($body, $data);
                break;
            case "application/json":
                $data = @json_decode($body, true);
                if (!is_array($data)) {
                    return new ApiError(400, "Failed to parse JSON body.");
                }
                break;
            default:
                return new ApiError(415, "Unsupported Content-Type: " . htmlspecialchars($contentTypeHeader));
        }

        return $data;
    }
}


================================================
FILE: src/Api/LogContentParser.php
================================================
<?php

namespace Aternos\Mclogs\Api;

use Aternos\Mclogs\Api\Response\ApiError;

class LogContentParser extends ContentParser
{
    /**
     * @inheritDoc
     */
    public function getContent(): array|ApiError
    {
        $data = parent::getContent();
        if ($data instanceof ApiError) {
            return $data;
        }

        if (!isset($data['content'])) {
            return new ApiError(400, "Required field 'content' not found.");
        }

        if (empty($data['content'])) {
            return new ApiError(400, "Required field 'content' is empty.");
        }

        if (!is_string($data['content'])) {
            return new ApiError(400, "Field 'content' must be a string.");
        }

        return $data;
    }
}


================================================
FILE: src/Api/Response/ApiError.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

class ApiError extends ApiResponse
{
    protected bool $success = false;

    public function __construct(
        int              $httpCode,
        protected string $message,
    )
    {
        $this->setHttpCode($httpCode);
    }

    public function jsonSerialize(): array
    {
        $data = parent::jsonSerialize();
        $data['error'] = $this->message;
        return $data;
    }
}


================================================
FILE: src/Api/Response/ApiResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

class ApiResponse implements \JsonSerializable
{
    protected int $httpCode = 200;
    protected bool $success = true;

    public function jsonSerialize(): array
    {
        return [
            'success' => $this->success,
        ];
    }

    /**
     * @param int $httpCode
     * @return $this
     */
    public function setHttpCode(int $httpCode): static
    {
        $this->httpCode = $httpCode;
        return $this;
    }

    /**
     * @return int
     */
    public function getHttpCode(): int
    {
        return $this->httpCode;
    }

    /**
     * @param bool $success
     * @return $this
     */
    public function setSuccess(bool $success): static
    {
        $this->success = $success;
        return $this;
    }

    /**
     * @return bool
     */
    public function isSuccess(): bool
    {
        return $this->success;
    }

    /**
     * @return $this
     */
    public function output(): static
    {
        header('Content-Type: application/json');
        http_response_code($this->httpCode);
        echo json_encode($this, JSON_UNESCAPED_SLASHES);
        return $this;
    }
}


================================================
FILE: src/Api/Response/CodexLogResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

use Aternos\Codex\Log\LogInterface;

class CodexLogResponse extends ApiResponse
{
    public function __construct(protected LogInterface $codexLog)
    {
    }

    public function jsonSerialize(): array
    {
        return array_merge(parent::jsonSerialize(), $this->codexLog->jsonSerialize());
    }
}

================================================
FILE: src/Api/Response/FiltersResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

use Aternos\Mclogs\Filter\Filter;

class FiltersResponse extends ApiResponse
{
    public function jsonSerialize(): array
    {
        return Filter::getAll();
    }
}

================================================
FILE: src/Api/Response/LimitsResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;

class LimitsResponse extends ApiResponse
{
    public function jsonSerialize(): array
    {
        $config = Config::getInstance();
        $data = parent::jsonSerialize();
        $data['storageTime'] = $config->get(ConfigKey::STORAGE_TTL);
        $data['maxLength'] = $config->get(ConfigKey::STORAGE_LIMIT_BYTES);
        $data['maxLines'] = $config->get(ConfigKey::STORAGE_LIMIT_LINES);
        return $data;
    }
}

================================================
FILE: src/Api/Response/LogResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class LogResponse extends ApiResponse
{
    public function __construct(
        protected Log  $log,
        protected bool $withToken = false,
        protected bool $withInsights = false,
        protected bool $withRaw = false,
        protected bool $withParsed = false)
    {
        $this->loadFromGet();
    }

    public function loadFromGet(): static
    {
        $url = URL::getCurrent();
        $query = $url->getQuery();
        if (empty($query)) {
            return $this;
        }
        parse_str($url->getQuery(), $get);
        $this->withInsights = isset($get['insights']);
        $this->withRaw = isset($get['raw']);
        $this->withParsed = isset($get['parsed']);
        return $this;
    }

    public function jsonSerialize(): array
    {
        $data = parent::jsonSerialize();
        $data['id'] = $this->log->getId();
        $data['source'] = $this->log->getSource();
        $data['created'] = $this->log->getCreated()?->toDateTime()->getTimestamp();
        $data['expires'] = $this->log->getExpires()?->toDateTime()->getTimestamp();
        $data['size'] = $this->log->getSize();
        $data['lines'] = $this->log->getLinesCount();
        $data['errors'] = $this->log->getErrorsCount();
        $data['url'] = $this->log->getUrl()->toString();
        $data['raw'] = $this->log->getRawURL()->toString();
        if ($this->withToken) {
            $data['token'] = $this->log->getToken();
        }
        $data['metadata'] = $this->log->getMetadata();
        if ($this->withInsights || $this->withRaw || $this->withParsed) {
            $data['content'] = [];
        }
        if ($this->withInsights) {
            $data['content']['insights'] = $this->log->getCodexLog()->setIncludeEntries(false);
        }
        if ($this->withRaw) {
            $data['content']['raw'] = $this->log->getContent();
        }
        if ($this->withParsed) {
            $data['content']['parsed'] = $this->log->getCodexLog()->getEntries();
        }
        return $data;
    }
}

================================================
FILE: src/Api/Response/MultiResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

class MultiResponse extends ApiResponse
{
    protected int $httpCode = 207;

    /**
     * @var ApiResponse[]
     */
    protected array $responses = [];

    /**
     * @param string $id
     * @param ApiResponse $response
     * @return $this
     */
    public function addResponse(string $id, ApiResponse $response): static
    {
        $this->responses[$id] = $response;
        return $this;
    }

    public function jsonSerialize(): array
    {
        $response = parent::jsonSerialize();
        $results = [];
        foreach ($this->responses as $id => $apiResponse) {
            $result = $apiResponse->jsonSerialize();
            $result["id"] = $id;
            $result["status"] = $apiResponse->getHttpCode();
            $results[] = $result;
        }
        $response["results"] = $results;
        return $response;
    }
}


================================================
FILE: src/Api/Response/RawLogResponse.php
================================================
<?php

namespace Aternos\Mclogs\Api\Response;

use Aternos\Mclogs\Log;

class RawLogResponse extends ApiResponse
{
    public function __construct(
        protected Log  $log)
    {
    }

    public function output(): static
    {
        header('Content-Type: text/plain');
        echo $this->log->getContent();

        return $this;
    }

}

================================================
FILE: src/Cache/CacheEntry.php
================================================
<?php

namespace Aternos\Mclogs\Cache;

use Aternos\Mclogs\Storage\MongoDBClient;
use MongoDB\BSON\UTCDateTime;

class CacheEntry
{
    public function __construct(protected string $key)
    {
    }

    /**
     * @return string|null
     */
    public function get(): ?string
    {
        $result = MongoDBClient::getInstance()->getCacheCollection()->findOne([
            "_id" => $this->key
        ]);
        return $result?->data;
    }

    /**
     * @param string $data
     * @param int $ttl
     * @return $this
     */
    public function set(string $data, int $ttl = 24 * 60 * 60): static
    {
        MongoDBClient::getInstance()->getCacheCollection()->updateOne(
            ["_id" => $this->key],
            ['$set' => [
                'data' => $data,
                'expires' => new UTCDateTime((time() + $ttl) * 1000)
            ]],
            ['upsert' => true]
        );
        return $this;
    }

}

================================================
FILE: src/Config/Config.php
================================================
<?php

namespace Aternos\Mclogs\Config;

use Aternos\Mclogs\Util\Singleton;
use Aternos\Mclogs\Util\URL;

class Config
{
    use Singleton;

    protected array $jsonData = [];

    protected function __construct()
    {
        $configPath = __DIR__ . "/../../config.json";
        if (file_exists($configPath)) {
            $jsonContent = file_get_contents($configPath);
            $data = @json_decode($jsonContent, true);
            if (is_array($data)) {
                $this->jsonData = $data;
            }
        }
    }

    /**
     * Get config value by checking environment variable, then config file, then default value
     *
     * @param ConfigKey $key
     * @return mixed
     */
    public function get(ConfigKey $key): mixed
    {
        $env = getenv($key->getEnvironmentVariable());
        if ($env !== false) {
            if ($env === "true") {
                return true;
            } else if ($env === "false") {
                return false;
            }
            return $env;
        }

        $json = $this->getJsonValue($key->getJSONPath());
        if ($json !== null) {
            return $json;
        }

        return $key->getDefaultValue();
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->get(ConfigKey::FRONTEND_NAME) ?? URL::getBase()->getHost();
    }

    /**
     * Recursively get a value from the json data by path
     *
     * @param array $path
     * @param array|null $data
     * @return mixed
     */
    protected function getJsonValue(array $path, ?array $data = null): mixed
    {
        if ($data === null) {
            $data = $this->jsonData;
        }

        $nextKey = array_shift($path);

        if (!isset($data[$nextKey])) {
            return null;
        }

        $nextData = $data[$nextKey];
        if (count($path) === 0) {
            return $nextData;
        }
        if (!is_array($nextData)) {
            return null;
        }
        return $this->getJsonValue($path, $nextData);
    }
}

================================================
FILE: src/Config/ConfigKey.php
================================================
<?php

namespace Aternos\Mclogs\Config;

enum ConfigKey
{
    case STORAGE_TTL;
    case STORAGE_LIMIT_BYTES;
    case STORAGE_LIMIT_LINES;

    case MONGODB_URL;
    case MONGODB_DATABASE;

    case ID_LENGTH;

    case LEGAL_ABUSE;
    case LEGAL_IMPRINT;
    case LEGAL_PRIVACY;

    case FRONTEND_NAME;
    case FRONTEND_ANALYTICS;
    case FRONTEND_ASSETS_INTEGRITY;
    case FRONTEND_COLOR_BACKGROUND;
    case FRONTEND_COLOR_TEXT;
    case FRONTEND_COLOR_ACCENT;
    case FRONTEND_COLOR_ERROR;

    case WORKER_REQUESTS;

    /**
     * Get the default value for the config key
     *
     * @return string|int|null
     */
    public function getDefaultValue(): string|int|null
    {
        return match ($this) {
            ConfigKey::STORAGE_TTL => 90 * 24 * 60 * 60,
            ConfigKey::STORAGE_LIMIT_BYTES => 10 * 1024 * 1024,
            ConfigKey::STORAGE_LIMIT_LINES => 25000,

            ConfigKey::MONGODB_URL => 'mongodb://mongo:27017',
            ConfigKey::MONGODB_DATABASE => 'mclogs',

            ConfigKey::ID_LENGTH => 7,

            ConfigKey::FRONTEND_ANALYTICS => false,

            ConfigKey::FRONTEND_ASSETS_INTEGRITY => true,

            ConfigKey::FRONTEND_COLOR_BACKGROUND => "#1a1a1a",
            ConfigKey::FRONTEND_COLOR_TEXT => "#e8e8e8",
            ConfigKey::FRONTEND_COLOR_ACCENT => "#5cb85c",
            ConfigKey::FRONTEND_COLOR_ERROR => "#f62451",

            ConfigKey::WORKER_REQUESTS => 500,

            default => null
        };
    }

    /**
     * Get environment variable name
     *
     * @return string
     */
    public function getEnvironmentVariable(): string
    {
        return "MCLOGS_" . $this->name;
    }

    /**
     * @return array
     */
    public function getJSONPath(): array
    {
        $parts = explode("_", $this->name);
        return array_map(fn($part) => strtolower($part), $parts);
    }
}


================================================
FILE: src/Data/Deobfuscator.php
================================================
<?php

namespace Aternos\Mclogs\Data;

use Aternos\Codex\Analysis\Information;
use Aternos\Codex\Log\AnalysableLog;
use Aternos\Codex\Log\LogInterface;
use Aternos\Codex\Minecraft\Analysis\Information\Vanilla\VanillaVersionInformation;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\Fabric\FabricLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaClientLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaCrashReportLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaNetworkProtocolErrorReportLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaServerLog;
use Aternos\Mclogs\Cache\CacheEntry;
use Aternos\Sherlock\MapLocator\FabricMavenMapLocator;
use Aternos\Sherlock\MapLocator\LauncherMetaMapLocator;
use Aternos\Sherlock\Maps\GZURLYarnMap;
use Aternos\Sherlock\Maps\ObfuscationMap;
use Aternos\Sherlock\Maps\URLVanillaObfuscationMap;
use Aternos\Sherlock\Maps\VanillaObfuscationMap;
use Aternos\Sherlock\Maps\YarnMap;
use Aternos\Sherlock\ObfuscatedString;
use Exception;

class Deobfuscator
{
    public function __construct(protected LogInterface $codexLog)
    {
    }

    public function deobfuscate(): ?string
    {
        if (!$this->codexLog instanceof AnalysableLog) {
            return null;
        }
        if (!$this->codexLog instanceof VanillaLog) {
            return null;
        }
        $analysis = $this->codexLog->analyse();

        /**
         * @var ?Information $version
         */
        $version = $analysis->getFilteredInsights(VanillaVersionInformation::class)[0] ?? null;
        if (!$version) {
            return null;
        }
        $version = $version->getValue();

        try {
            $map = $this->getObfuscationMap($version);
        } catch (Exception) {
            $map = null;
        }

        if ($map === null) {
            return null;
        }

        $obfuscatedContent = new ObfuscatedString($this->codexLog->getLogFile()->getContent(), $map);
        if ($content = $obfuscatedContent->getMappedContent()) {
            return $content;
        }
        return null;
    }

    /**
     * Get the obfuscation map matching this log
     *
     * @param $version
     * @return ObfuscationMap|null
     * @throws Exception
     */
    protected function getObfuscationMap($version): ?ObfuscationMap
    {
        if (in_array(get_class($this->codexLog), [
            VanillaServerLog::class,
            VanillaClientLog::class,
            VanillaCrashReportLog::class,
            VanillaNetworkProtocolErrorReportLog::class
        ])) {
            $urlCache = new CacheEntry("sherlock:vanilla:$version:client");

            $mapURL = $urlCache->get();
            if (!$mapURL) {
                $mapURL = new LauncherMetaMapLocator($version, "client")->findMappingURL();

                if (!$mapURL) {
                    return null;
                }

                $urlCache->set($mapURL, 30 * 24 * 60 * 60);
            }

            try {
                $mapCache = new CacheEntry("sherlock:$mapURL");
                if ($mapContent = $mapCache->get()) {
                    $map = new VanillaObfuscationMap($mapContent);
                } else {
                    $map = new URLVanillaObfuscationMap($mapURL);
                    $mapCache->set($map->getContent());
                }
            } catch (Exception) {
            }
            return $map ?? null;
        }

        if ($this->codexLog instanceof FabricLog) {
            $urlCache = new CacheEntry("sherlock:yarn:$version:server");

            $mapURL = $urlCache->get();
            if (!$mapURL) {
                $mapURL = new FabricMavenMapLocator($version)->findMappingURL();

                if (!$mapURL) {
                    return null;
                }

                $urlCache->set($mapURL, 24 * 60 * 60);
            }

            try {
                $mapCache = new CacheEntry("sherlock:$mapURL");
                if ($mapContent = $mapCache->get()) {
                    $map = new YarnMap($mapContent);
                } else {
                    $map = new GZURLYarnMap($mapURL);
                    $mapCache->set($map->getContent());
                }
            } catch (Exception) {
            }
            return $map ?? null;
        }

        return null;
    }
}

================================================
FILE: src/Data/MetadataEntry.php
================================================
<?php

namespace Aternos\Mclogs\Data;

use MongoDB\BSON\Serializable;
use MongoDB\Model\BSONDocument;

class MetadataEntry implements \JsonSerializable, Serializable
{
    public const int MAX_ENTRIES = 100;
    protected const int MAX_KEY_LENGTH = 64;
    protected const int MAX_LABEL_LENGTH = 128;
    protected const int MAX_VALUE_LENGTH = 1024;

    protected ?string $key = null;
    protected mixed $value = null;
    protected ?string $label = null;
    protected bool $visible = true;

    /**
     * @param iterable|null $dataArray
     * @return MetadataEntry[]
     */
    public static function allFromArray(?iterable $dataArray): array
    {
        if ($dataArray === null) {
            return [];
        }
        $entries = [];
        foreach ($dataArray as $data) {
            if (is_array($data)) {
                $entry = static::fromArray($data);
            } else if (is_object($data)) {
                $entry = static::fromObject($data);
            } else {
                continue;
            }
            if ($entry !== null) {
                $entries[] = $entry;
            }
            if (count($entries) >= static::MAX_ENTRIES) {
                break;
            }
        }
        return $entries;
    }

    /**
     * @param array $data
     * @return MetadataEntry|null
     */
    public static function fromArray(array $data): ?MetadataEntry
    {
        $entry = new MetadataEntry()->setFromArray($data);
        if (!$entry->isValid()) {
            return null;
        }
        return $entry;
    }

    /**
     * @param object $data
     * @return MetadataEntry|null
     */
    public static function fromObject(object $data): ?MetadataEntry
    {
        if ($data instanceof BSONDocument) {
            $arrayData = $data->getArrayCopy();
        } else {
            $arrayData = get_object_vars($data);
        }
        return static::fromArray($arrayData);
    }

    public function jsonSerialize(): array
    {
        return [
            "key" => $this->key,
            "value" => $this->value,
            "label" => $this->label,
            "visible" => $this->visible,
        ];
    }

    public function bsonSerialize(): array
    {
        return $this->jsonSerialize();
    }

    public function getKey(): ?string
    {
        return $this->key;
    }

    public function setKey(?string $key): static
    {
        if (is_string($key) && strlen($key) > static::MAX_KEY_LENGTH) {
            $key = substr($key, 0, static::MAX_KEY_LENGTH);
        }
        $this->key = $key;
        return $this;
    }

    public function getValue(): mixed
    {
        return $this->value;
    }

    /**
     * @param mixed $value
     * @return $this
     */
    public function setValue(mixed $value): static
    {
        if (is_string($value)) {
            if (strlen($value) > static::MAX_VALUE_LENGTH) {
                $value = substr($value, 0, static::MAX_VALUE_LENGTH);
            }
            $this->value = $value;
            return $this;
        }
        if (is_int($value) || is_float($value) || is_bool($value) || is_null($value)) {
            $this->value = $value;
            return $this;
        }
        $encodedValue = @json_encode($value);
        if ($encodedValue === false) {
            $this->value = null;
            return $this;
        }
        if (strlen($encodedValue) > static::MAX_VALUE_LENGTH) {
            $encodedValue = substr($encodedValue, 0, static::MAX_VALUE_LENGTH);
        }
        $this->value = $encodedValue;
        return $this;
    }

    public function getLabel(): ?string
    {
        return $this->label;
    }

    public function getDisplayLabel(): ?string
    {
        return $this->label ?? $this->key;
    }

    public function getDisplayValue(): string
    {
        return $this->value;
    }

    public function setLabel(?string $label): static
    {
        if (is_string($label) && strlen($label) > static::MAX_LABEL_LENGTH) {
            $label = substr($label, 0, static::MAX_LABEL_LENGTH);
        }
        $this->label = $label;
        return $this;
    }

    public function isVisible(): bool
    {
        return $this->visible;
    }

    public function setVisible(bool $visible): static
    {
        $this->visible = $visible;
        return $this;
    }

    public function isValid(): bool
    {
        return $this->key !== null && $this->value !== null;
    }

    /**
     * @param array $data
     * @return $this
     */
    public function setFromArray(array $data): static
    {
        if (isset($data['key']) && is_string($data['key'])) {
            $this->setKey($data['key']);
        }
        if (isset($data['value'])) {
            $this->setValue($data['value']);
        }
        if (isset($data['label']) && is_string($data['label'])) {
            $this->setLabel($data['label']);
        }
        if (isset($data['visible']) && is_bool($data['visible'])) {
            $this->setVisible($data['visible']);
        }
        return $this;
    }
}


================================================
FILE: src/Data/Token.php
================================================
<?php

namespace Aternos\Mclogs\Data;

use Random\RandomException;

class Token implements \JsonSerializable
{
    public function __construct(protected ?string $value = null)
    {
        if ($this->value === null) {
            $this->generate();
        }
    }

    /**
     * @param string $token
     * @return bool
     */
    public function matches(string $token): bool
    {
        return hash_equals($this->value, $token);
    }

    public function jsonSerialize(): string
    {
        return $this->value;
    }

    /**
     * @throws RandomException
     */
    protected function generate(): void
    {
        $this->value = bin2hex(random_bytes(32));
    }

    public function get(): ?string
    {
        return $this->value;
    }
}

================================================
FILE: src/Detective.php
================================================
<?php

namespace Aternos\Mclogs;

use Aternos\Codex\Minecraft\Log\Minecraft\MinecraftLog;

class Detective extends \Aternos\Codex\Detective\Detective
{
    protected string $defaultLogClass = MinecraftLog::class;

    public function __construct()
    {
        $this->addDetective(new \Aternos\Codex\Minecraft\Detective\Detective())
            ->addDetective(new \Aternos\Codex\Hytale\Detective\Detective());
    }
}

================================================
FILE: src/Filter/AccessTokenFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;

class AccessTokenFilter extends RegexFilter
{
    /**
     * @inheritDoc
     */
    protected function getPatterns(): array
    {
        return [
            new PatternWithReplacement('\(Session ID is token:[^:]+\:[^)]+\)', '(Session ID is token:****************:****************)'),
            new PatternWithReplacement('--accessToken [^ ]+', '--accessToken ****************:****************'),
            new PatternWithReplacement('"authToken":"[^"]+"', '"authToken":"****************"'),
            new PatternWithReplacement('"refreshToken":"[^"]+"', '"refreshToken":"****************"'),
        ];
    }
}


================================================
FILE: src/Filter/Filter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

abstract class Filter implements \JsonSerializable
{
    /**
     * @var Filter[]|null
     */
    protected static ?array $filter = null;

    /**
     * Get all filters
     *
     * @return Filter[]
     */
    public static function getAll(): array
    {
        if (static::$filter !== null) {
            return static::$filter;
        }
        return static::$filter = [
            new TrimFilter(),
            new LimitBytesFilter(),
            new LimitLinesFilter(),
            new IPv4Filter(),
            new IPv6Filter(),
            new UsernameFilter(),
            new AccessTokenFilter(),
        ];
    }

    /**
     * Filter the $data string with all filters and return it
     *
     * @param string $data
     * @return string
     */
    public static function filterAll(string $data): string
    {
        foreach (static::getAll() as $filter) {
            $data = $filter->filter($data);
        }
        return $data;
    }

    /**
     * @return FilterType
     */
    abstract public function getType(): FilterType;

    /**
     * @return array
     */
    abstract public function getData(): mixed;

    /**
     * @return array
     */
    public function jsonSerialize(): array
    {
        return [
            "type" => $this->getType()->value,
            "data" => $this->getData(),
        ];
    }

    /**
     * Filter the $data string and return it
     *
     * @param string $data
     * @return string
     */
    abstract public function filter(string $data): string;
}

================================================
FILE: src/Filter/FilterType.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

enum FilterType: string
{
    case TRIM = 'trim';
    case LIMIT_BYTES = 'limit-bytes';
    case LIMIT_LINES = 'limit-lines';
    case REGEX = 'regex';
}


================================================
FILE: src/Filter/IPv4Filter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Filter\Pattern\Pattern;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;

class IPv4Filter extends RegexFilter
{
    /**
     * @inheritDoc
     */
    protected function getPatterns(): array
    {
        return [
            new PatternWithReplacement('(?<!version:? )(?<!([0-9]|-|\w))([0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])', '**.**.**.**'),
        ];
    }

    /**
     * @inheritDoc
     */
    protected function getExemptions(): array
    {
        return [
            new Pattern('^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'),
            new Pattern('^0\.0\.0\.0$'),
            new Pattern('^1\.[01]\.[01]\.1$'),
            new Pattern('^8\.8\.[84]\.[84]$'),
        ];
    }
}

================================================
FILE: src/Filter/IPv6Filter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Filter\Pattern\Pattern;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;

class IPv6Filter extends RegexFilter
{
    /**
     * @inheritDoc
     */
    protected function getPatterns(): array
    {
        return [
            new PatternWithReplacement('(?<=^|\W)((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?(?=$|\W)',
            '****:****:****:****:****:****:****:****')
        ];
    }

    /**
     * @inheritDoc
     */
    protected function getExemptions(): array
    {
        return [
            new Pattern('^[0:]+1?$')
        ];
    }
}

================================================
FILE: src/Filter/LimitBytesFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;

class LimitBytesFilter extends Filter
{
    /**
     * Filter the $data string and return it
     *
     * Cuts the length down to maxLength
     *
     * @param string $data
     * @return string
     */
    public function filter(string $data): string
    {
        $lengthLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES);
        return mb_strcut($data, 0, $lengthLimit);
    }

    /**
     * @return FilterType
     */
    public function getType(): FilterType
    {
        return FilterType::LIMIT_BYTES;
    }

    /**
     * @return array
     */
    public function getData(): array
    {
        return [
            "limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES)
        ];
    }
}

================================================
FILE: src/Filter/LimitLinesFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;

class LimitLinesFilter extends Filter
{
    /**
     * Filter the $data string and return it
     *
     * Cuts the lines down to maxLines
     *
     * @param string $data
     * @return string
     */
    public function filter(string $data): string
    {
        $linesLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES);
        return implode("\n", array_slice(explode("\n", $data), 0, $linesLimit));
    }

    /**
     * @return FilterType
     */
    public function getType(): FilterType
    {
        return FilterType::LIMIT_LINES;
    }

    /**
     * @return array
     */
    public function getData(): array
    {
        return [
            "limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES)
        ];
    }
}

================================================
FILE: src/Filter/Pattern/Modifier.php
================================================
<?php

namespace Aternos\Mclogs\Filter\Pattern;

/**
 * https://www.php.net/manual/en/reference.pcre.pattern.modifiers.php
 */
enum Modifier: string implements \JsonSerializable
{
    case CASELESS = 'i';

    public function jsonSerialize(): string
    {
        return $this->value;
    }
}

================================================
FILE: src/Filter/Pattern/Pattern.php
================================================
<?php

namespace Aternos\Mclogs\Filter\Pattern;

class Pattern implements \JsonSerializable
{
    protected const string DELIMITER = '/';

    /**
     * @param string $pattern
     * @param Modifier[] $modifiers
     */
    public function __construct(
        protected string $pattern,
        protected array  $modifiers = [Modifier::CASELESS]
    )
    {
    }

    /**
     * Get the full regex pattern with delimiters and modifiers
     *
     * @return string
     */
    public function get(): string
    {
        $modifiersString = '';
        foreach ($this->modifiers as $modifier) {
            $modifiersString .= $modifier->value;
        }
        return static::DELIMITER . $this->pattern . static::DELIMITER . $modifiersString;
    }

    public function getPattern(): string
    {
        return $this->pattern;
    }

    public function getModifiers(): array
    {
        return $this->modifiers;
    }

    public function jsonSerialize(): array
    {
        return [
            'pattern' => $this->getPattern(),
            'modifiers' => $this->getModifiers()
        ];
    }
}

================================================
FILE: src/Filter/Pattern/PatternWithReplacement.php
================================================
<?php

namespace Aternos\Mclogs\Filter\Pattern;

class PatternWithReplacement extends Pattern
{
    public function __construct(string $pattern, protected string $replacement, array $modifiers = [Modifier::CASELESS])
    {
        parent::__construct($pattern, $modifiers);
    }

    public function getReplacement(): string
    {
        return $this->replacement;
    }

    public function jsonSerialize(): array
    {
        return array_merge(
            parent::jsonSerialize(),
            [
                'replacement' => $this->getReplacement()
            ]
        );
    }
}

================================================
FILE: src/Filter/RegexFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Filter\Pattern\Pattern;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;

abstract class RegexFilter extends Filter
{
    /**
     * @return PatternWithReplacement[]
     */
    abstract protected function getPatterns(): array;

    /**
     * @return Pattern[]
     */
    protected function getExemptions(): array
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public function getType(): FilterType
    {
        return FilterType::REGEX;
    }

    /**
     * @inheritDoc
     */
    public function getData(): array
    {
        return [
            "patterns" => $this->getPatterns(),
            "exemptions" => $this->getExemptions(),
        ];
    }

    /**
     * @inheritDoc
     */
    public function filter(string $data): string
    {
        foreach ($this->getPatterns() as $pattern) {
            $data = preg_replace_callback($pattern->get(), function ($matches) use ($pattern) {
                foreach ($this->getExemptions() as $exemptionPattern) {
                    if (preg_match($exemptionPattern->get(), $matches[0])) {
                        return $matches[0];
                    }
                }
                return $pattern->getReplacement();
            }, $data);
        }
        return $data;
    }
}

================================================
FILE: src/Filter/TrimFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

class TrimFilter extends Filter
{
    /**
     * Filter the $data string and return it
     *
     * Trim pre and after whitespace
     *
     * @param string $data
     * @return string
     */
    public function filter(string $data): string
    {
        return trim($data);
    }

    public function getType(): FilterType
    {
        return FilterType::TRIM;
    }

    public function getData(): object
    {
        return new \stdClass();
    }
}

================================================
FILE: src/Filter/UsernameFilter.php
================================================
<?php

namespace Aternos\Mclogs\Filter;

use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;

class UsernameFilter extends RegexFilter
{
    /**
     * @inheritDoc
     */
    protected function getPatterns(): array
    {
        return [
            new PatternWithReplacement("C:\\\\Users\\\\([^\\\\]+)\\\\", "C:\\Users\\********\\"), // windows
            new PatternWithReplacement("C:\\\\\\\\Users\\\\\\\\([^\\\\]+)\\\\\\\\", "C:\\\\Users\\\\********\\\\"), // windows with double backslashes
            new PatternWithReplacement("C:\\/Users\\/([^\\/]+)\\/", "C:/Users/********/"), // windows with forward slashes
            new PatternWithReplacement("(?<!\\w)\\/home\\/[^\\/]+\\/", "/home/********/"), // linux
            new PatternWithReplacement("(?<!\\w)\\/Users\\/[^\\/]+\\/", "/Users/********/"), // macos
            new PatternWithReplacement("USERNAME=\\w+", "USERNAME=********"), // environment variable
        ];
    }
}

================================================
FILE: src/Frontend/Action/ApiDocsAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Router\Action;

class ApiDocsAction extends Action
{
    public function run(): bool
    {
        require __DIR__ . "/../../../web/frontend/api-docs.php";
        return true;
    }
}

================================================
FILE: src/Frontend/Action/CreateLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Util\URL;

class CreateLogAction extends \Aternos\Mclogs\Api\Action\CreateLogAction
{
    protected bool $includeCookie = true;
    protected bool $includeToken = false;

    protected function getAllowedOrigin(): string
    {
        return URL::getBase()->toString();
    }

    protected function shouldAllowCredentials(): bool
    {
        return true;
    }
}

================================================
FILE: src/Frontend/Action/DeleteLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Frontend\Cookie\TokenCookie;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;

class DeleteLogAction extends \Aternos\Mclogs\Api\Action\DeleteLogAction
{
    protected function getAllowedOrigin(): string
    {
        return URL::getBase()->toString();
    }

    protected function shouldAllowCredentials(): bool
    {
        return true;
    }

    protected function getRequestToken(): ?string
    {
        return new TokenCookie()->getValue();
    }

    protected function handleDeletedLog(Log $log): void
    {
        new TokenCookie()->setLog($log)->delete();
    }
}

================================================
FILE: src/Frontend/Action/FaviconAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Router\Action;

class FaviconAction extends Action
{
    public function run(): bool
    {
        header('Content-Type: image/svg+xml');
        require __DIR__ . "/../../../web/frontend/parts/favicon.php";
        return true;
    }
}

================================================
FILE: src/Frontend/Action/NotFoundAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Router\Action;

class NotFoundAction extends Action
{
    public function run(): bool
    {
        http_response_code(404);
        require __DIR__ . "/../../../web/frontend/404.php";
        return true;
    }
}

================================================
FILE: src/Frontend/Action/StartAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Router\Action;

class StartAction extends Action
{
    public function run(): bool
    {
        require __DIR__ . "/../../../web/frontend/start.php";
        return true;
    }
}

================================================
FILE: src/Frontend/Action/ViewLogAction.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Action;

use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Router\Action;
use Aternos\Mclogs\Util\URL;

class ViewLogAction extends Action
{
    public function run(): bool
    {
        $id = new Id(URL::getLastPathPart());
        $log = Log::find($id);
        if (!$log) {
            return false;
        }

        $log->renew();

        require __DIR__ . "/../../../web/frontend/log.php";
        return true;
    }
}

================================================
FILE: src/Frontend/Assets/Asset.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Assets;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;

class Asset implements \JsonSerializable
{
    protected const string HASH_ALGORITHM = 'sha384';

    /**
     * @param object $data
     * @return static|null
     */
    public static function fromObject(object $data): ?static
    {
        if (!isset($data->type) || !isset($data->path) || !isset($data->hash)) {
            return null;
        }

        $type = AssetType::tryFrom($data->type);
        if ($type === null) {
            return null;
        }

        return new static($type, $data->path, $data->hash);
    }

    public function __construct(
        protected AssetType $type,
        protected string    $path,
        protected ?string   $hash = null)
    {
        $this->path = ltrim($this->path, '/');
    }

    public function getPath(): string
    {
        return $this->path;
    }

    public function getPathWithVersion(): string
    {
        return $this->path . '?v=' . rawurlencode(substr($this->getHash(), 0, 16));
    }

    protected function getAbsoluteBasePath(): string
    {
        return __DIR__ . "/../../../web/public/";
    }

    protected function getAbsolutePath(): string
    {
        return $this->getAbsoluteBasePath() . $this->path;
    }

    protected function buildHash(): string
    {
        return hash_file(static::HASH_ALGORITHM, $this->getAbsolutePath());
    }

    protected function getHash(): string
    {
        if ($this->hash === null) {
            return $this->buildHash();
        }
        return $this->hash;
    }

    protected function getBase64Hash(): string
    {
        return base64_encode(hex2bin($this->getHash()));
    }

    public function jsonSerialize(): array
    {
        return [
            'type' => $this->getType()->value,
            'path' => $this->getPath(),
            'hash' => $this->getHash(),
        ];
    }

    public function getType(): AssetType
    {
        return $this->type;
    }

    public function getHTML(): string
    {
        return match ($this->type) {
            AssetType::CSS => '<link rel="stylesheet" href="/' . $this->getPathWithVersion() . '"' . $this->getIntegrityAttribute() . ' />',
            AssetType::JS => '<script src="/' . $this->getPathWithVersion() . '"' . $this->getIntegrityAttribute() . '></script>'
        };
    }

    protected function getIntegrityAttribute(): string
    {
        if (!Config::getInstance()->get(ConfigKey::FRONTEND_ASSETS_INTEGRITY)) {
            return '';
        }
        return ' integrity="' . static::HASH_ALGORITHM . '-' . $this->getBase64Hash() . '"';
    }
}

================================================
FILE: src/Frontend/Assets/AssetLoader.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Assets;

use Aternos\Mclogs\Util\Singleton;

class AssetLoader
{
    use Singleton;

    protected const string CACHE_PATH = __DIR__ . "/../../../assets.cache";

    /**
     * @var Asset[]
     */
    protected array $cachedAssets = [];

    protected function __construct()
    {
        $this->loadCache();
    }

    /**
     * @param AssetType $assetType
     * @param string $path
     * @return string
     */
    public function getHTML(AssetType $assetType, string $path): string
    {
        return $this->getAsset($assetType, $path)->getHTML();
    }

    /**
     * @param AssetType $assetType
     * @param string $path
     * @return Asset
     */
    protected function getAsset(AssetType $assetType, string $path): Asset
    {
        $cachedAsset = $this->findCachedAsset($assetType, $path);
        if ($cachedAsset !== null) {
            return $cachedAsset;
        }
        return new Asset($assetType, $path);
    }

    /**
     * @param AssetType $assetType
     * @param string $path
     * @return Asset|null
     */
    protected function findCachedAsset(AssetType $assetType, string $path): ?Asset
    {
        foreach ($this->cachedAssets as $asset) {
            if ($asset->getPath() === $path && $asset->getType() === $assetType) {
                return $asset;
            }
        }
        return null;
    }

    protected function loadCache(): void
    {
        if (!file_exists(self::CACHE_PATH)) {
            return;
        }

        $content = file_get_contents(self::CACHE_PATH);
        if ($content === false) {
            return;
        }

        $data = json_decode($content);
        if (!is_array($data)) {
            return;
        }

        foreach ($data as $assetData) {
            if (!is_object($assetData)) {
                continue;
            }
            $asset = Asset::fromObject($assetData);
            if ($asset === null) {
                continue;
            }
            $this->cachedAssets[] = $asset;
        }
    }

    public function writeCache(): void
    {
        $assets = [
            new Asset(AssetType::CSS, "css/mclogs.css"),
            new Asset(AssetType::JS, "js/start.js"),
            new Asset(AssetType::JS, "js/log.js"),
            new Asset(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css")
        ];

        file_put_contents(static::CACHE_PATH, json_encode($assets));
    }
}

================================================
FILE: src/Frontend/Assets/AssetType.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Assets;

enum AssetType: string
{
    case CSS = "css";
    case JS = "js";
}

================================================
FILE: src/Frontend/Cookie/Cookie.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Cookie;

use Aternos\Mclogs\Util\URL;

abstract class Cookie
{
    protected ?string $value = null;

    /**
     * @return string
     */
    abstract protected function getKey(): string;

    /**
     * @return string
     */
    protected function getDomain(): string
    {
        return "";
    }

    /**
     * @return int|null
     */
    protected function getMaxAge(): ?int
    {
        return null;
    }

    /**
     * @return string
     */
    protected function getPath(): string
    {
        return "/";
    }

    /**
     * @return bool
     */
    protected function isSecure(): bool
    {
        return URL::getCurrent()->getScheme() === "https";
    }

    /**
     * @return bool
     */
    protected function isHttpOnly(): bool
    {
        return true;
    }

    /**
     * @return string
     */
    protected function getSameSite(): string
    {
        return "Lax";
    }

    public function __construct()
    {
        $this->value = $_COOKIE[$this->getKey()] ?? null;
    }

    /**
     * @param string $value
     * @return bool
     */
    public function set(string $value): bool
    {
        $options = [
            'expires' => $this->getMaxAge() !== null ? time() + $this->getMaxAge() : 0,
            'path' => $this->getPath(),
            'domain' => $this->getDomain(),
            'secure' => $this->isSecure(),
            'httponly' => $this->isHttpOnly(),
            'samesite' => $this->getSameSite()
        ];

        $result = setcookie(
            $this->getKey(),
            $value,
            $options
        );

        if ($result) {
            $this->value = $value;
        }

        return $result;
    }

    /**
     * @return bool
     */
    public function delete(): bool
    {
        $options = [
            'expires' => time() - 3600,
            'path' => $this->getPath(),
            'domain' => $this->getDomain(),
            'secure' => $this->isSecure(),
            'httponly' => $this->isHttpOnly(),
            'samesite' => $this->getSameSite()
        ];

        $result = setcookie(
            $this->getKey(),
            '',
            $options
        );

        if ($result) {
            $this->value = null;
        }

        return $result;
    }

    /**
     * @return string|null
     */
    public function getValue(): ?string
    {
        return $this->value;
    }

    /**
     * @return bool
     */
    public function exists(): bool
    {
        return $this->getValue() !== null;
    }
}

================================================
FILE: src/Frontend/Cookie/SettingsCookie.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Cookie;

class SettingsCookie extends Cookie
{
    /**
     * @inheritDoc
     */
    protected function getKey(): string
    {
        return "MCLOGS_SETTINGS";
    }
}

================================================
FILE: src/Frontend/Cookie/TokenCookie.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Cookie;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Log;

class TokenCookie extends Cookie
{
    /**
     * @param Log $log
     * @return $this
     */
    public function setLog(Log $log): static
    {
        $this->log = $log;
        return $this;
    }

    /**
     * @inheritDoc
     */
    protected function getKey(): string
    {
        return "MCLOGS_LOG_TOKEN";
    }

    /**
     * @param Log|null $log
     */
    public function __construct(protected ?Log $log = null)
    {
        parent::__construct();
    }

    /**
     * @return string
     */
    protected function getPath(): string
    {
        if (!$this->log) {
            return "/";
        }
        return "/" . $this->log->getId()->get();
    }

    protected function getMaxAge(): ?int
    {
        return Config::getInstance()->get(ConfigKey::STORAGE_TTL);
    }
}

================================================
FILE: src/Frontend/FrontendRouter.php
================================================
<?php

namespace Aternos\Mclogs\Frontend;

use Aternos\Mclogs\Router\Router;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Router\Method;

class FrontendRouter extends Router
{
    protected function __construct()
    {
        parent::__construct();
        $this->register(Method::GET, "#^/$#", new Action\StartAction())
            ->register(Method::GET, "#^/" . Id::PATTERN . "$#", new Action\ViewLogAction())
            ->register(Method::POST, "#^/new$#", new Action\CreateLogAction())
            ->register(Method::DELETE, "#^/" . Id::PATTERN . "$#", new Action\DeleteLogAction())
            ->register(Method::GET, "#^/favicon\.svg$#", new Action\FaviconAction())
            ->setDefaultAction(new Action\NotFoundAction());
    }
}

================================================
FILE: src/Frontend/Settings/Setting.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Settings;

enum Setting: string
{
    case FULL_WIDTH = "fullWidth";
    case NO_WRAP = "noWrap";
    case FLOATING_SCROLLBAR = "floatingScrollbar";
    case OVERFLOW = "overflow";


    /**
     * @return string
     */
    function getLabel(): string
    {
        return match ($this) {
            Setting::FULL_WIDTH => "Full Width",
            Setting::NO_WRAP => "No Wrap",
            Setting::FLOATING_SCROLLBAR => "Floating Scrollbar",
            Setting::OVERFLOW => "Overflow"
        };
    }

    /**
     * @return string|null
     */
    function getBodyClass(): ?string
    {
        return match ($this) {
            Setting::FULL_WIDTH => "setting-full-width",
            Setting::NO_WRAP => "setting-no-wrap",
            Setting::FLOATING_SCROLLBAR => "setting-floating-scrollbar",
            Setting::OVERFLOW => "setting-overflow",
            default => null
        };
    }
}


================================================
FILE: src/Frontend/Settings/Settings.php
================================================
<?php

namespace Aternos\Mclogs\Frontend\Settings;

use Aternos\Mclogs\Frontend\Cookie\SettingsCookie;

class Settings
{
    /**
     * @var array<string, mixed>
     */
    protected array $data = [];

    public function __construct()
    {
        $rawData = new SettingsCookie()->getValue();
        if ($rawData) {
            $parsedData = json_decode($rawData, true);
            if (is_array($parsedData)) {
                $this->data = $parsedData;
            }
        }
    }

    /**
     * @param Setting $key
     * @return bool
     */
    public function get(Setting $key): bool
    {
        $value = $this->data[$key->value] ?? false;
        if (is_bool($value)) {
            return $value;
        }
        return false;
    }

    /**
     * @return string[]
     */
    public function getBodyClasses(): array
    {
        $classes = [];
        foreach (Setting::cases() as $setting) {
            if ($this->get($setting)) {
                $bodyClass = $setting->getBodyClass();
                if ($bodyClass) {
                    $classes[] = $bodyClass;
                }
            }
        }
        return $classes;
    }

    /**
     * @return string
     */
    public function getBodyClassesString(): string
    {
        $classes = $this->getBodyClasses();
        if (empty($classes)) {
            return "";
        }
        return " " . implode(" ", $this->getBodyClasses());
    }
}

================================================
FILE: src/Id.php
================================================
<?php

namespace Aternos\Mclogs;

use Aternos\Mclogs\Config\ConfigKey;

class Id implements \JsonSerializable
{
    public const string PATTERN = '[a-zA-Z0-9]+';
    protected const string CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";

    /**
     * @param string|null $id
     */
    public function __construct(protected ?string $id = null)
    {
        if ($this->id === null) {
            $this->generate();
        }
    }

    /**
     * Generates a new id
     *
     * @return string
     */
    protected function generate(): string
    {
        $config = \Aternos\Mclogs\Config\Config::getInstance();
        $idLength = $config->get(ConfigKey::ID_LENGTH);

        $newId = "";
        for ($i = 0; $i < $idLength; $i++) {
            $newId .= static::CHARACTERS[rand(0, strlen(static::CHARACTERS) - 1)];
        }

        return $this->id = $newId;
    }

    /**
     * @return string
     */
    public function get(): string
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function __toString(): string
    {
        return $this->id;
    }

    public function jsonSerialize(): string
    {
        return $this->id;
    }
}

================================================
FILE: src/Log.php
================================================
<?php

namespace Aternos\Mclogs;

use Aternos\Codex\Analysis\Analysis;
use Aternos\Codex\Log\AnalysableLogInterface;
use Aternos\Codex\Log\File\StringLogFile;
use Aternos\Codex\Log\Level;
use Aternos\Codex\Log\LogInterface;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Data\Deobfuscator;
use Aternos\Mclogs\Data\MetadataEntry;
use Aternos\Mclogs\Data\Token;
use Aternos\Mclogs\Filter\Filter;
use Aternos\Mclogs\Frontend\Cookie\TokenCookie;
use Aternos\Mclogs\Printer\Printer;
use Aternos\Mclogs\Storage\MongoDBClient;
use Aternos\Mclogs\Util\URL;
use MongoDB\BSON\UTCDateTime;
use Uri\Rfc3986\Uri;

class Log
{
    protected const int SOURCE_MAX_LENGTH = 64;

    protected ?string $source = null;
    protected ?UTCDateTime $expires = null;
    protected ?UTCDateTime $created = null;
    protected ?Token $token = null;

    /**
     * @var MetadataEntry[]
     */
    protected array $metadata = [];

    protected ?LogInterface $log = null;
    protected ?Printer $printer = null;

    /**
     * Find a log by its id
     *
     * @param Id $id
     * @param bool $includeContent
     * @return static|null
     */
    public static function find(Id $id, bool $includeContent = true): ?static
    {
        $data = MongoDBClient::getInstance()->findLog($id, $includeContent);
        if ($data === null) {
            return null;
        }

        return static::fromObject($id, $data);
    }

    /**
     * @param (string|Id)[] $ids
     * @param bool $includeContent
     * @return array<string, Log>
     */
    public static function findAll(array $ids, bool $includeContent = true): array
    {
        $ids = array_map(fn($id) => (string)$id, $ids);
        $objects = MongoDBClient::getInstance()->findLogs($ids, $includeContent);
        $logs = [];
        foreach ($objects as $data) {
            $id = new Id($data->_id);
            $logs[$id->get()] = static::fromObject($id, $data);
        }
        return $logs;
    }

    /**
     * @param Id $id
     * @param object $data
     * @return static
     */
    protected static function fromObject(Id $id, object $data): static
    {
        return new static($id)
            ->setContent($data->data ?? "")
            ->setToken(isset($data->token) ? new Token($data->token) : null)
            ->setMetadata(MetadataEntry::allFromArray($data->metadata ?? []))
            ->setSource($data->source ?? null)
            ->setCreated($data->created ?? null)
            ->setExpires($data->expires ?? null);
    }

    /**
     * Create and save a new log
     *
     * @param string $content
     * @param MetadataEntry[] $metadata
     * @param string|null $source
     * @return static
     */
    public static function create(string $content, array $metadata = [], ?string $source = null): static
    {
        return new static()
            ->setMetadata($metadata)
            ->setSource($source)
            ->setToken(new Token())
            ->save($content);
    }

    /**
     * @param Id|null $id
     */
    public function __construct(protected ?Id $id = null)
    {
    }

    /**
     * @param Token|null $token
     * @return $this
     */
    public function setToken(?Token $token): static
    {
        $this->token = $token;
        return $this;
    }

    /**
     * @param MetadataEntry[] $metadata
     * @return $this
     */
    public function setMetadata(array $metadata): static
    {
        $this->metadata = $metadata;
        return $this;
    }

    /**
     * @param MetadataEntry $metadataEntry
     * @return $this
     */
    public function addMetadata(MetadataEntry $metadataEntry): static
    {
        $this->metadata[] = $metadataEntry;
        return $this;
    }

    /**
     * @param string|null $source
     * @return $this
     */
    public function setSource(?string $source): static
    {
        if (is_string($source) && strlen($source) > static::SOURCE_MAX_LENGTH) {
            $source = substr($source, 0, static::SOURCE_MAX_LENGTH);
        }
        $this->source = $source;
        return $this;
    }

    /**
     * @return string|null
     */
    public function getSource(): ?string
    {
        return $this->source;
    }

    /**
     * @param UTCDateTime|null $created
     * @return $this
     */
    public function setCreated(?UTCDateTime $created): static
    {
        $this->created = $created;
        return $this;
    }

    /**
     * @param UTCDateTime|null $expires
     * @return $this
     */
    public function setExpires(?UTCDateTime $expires): static
    {
        $this->expires = $expires;
        return $this;
    }

    /**
     * @return UTCDateTime|null
     */
    public function getCreated(): ?UTCDateTime
    {
        return $this->created;
    }

    /**
     * @return UTCDateTime|null
     */
    public function getExpires(): ?UTCDateTime
    {
        return $this->expires;
    }

    /**
     * @param string $content
     * @return $this
     */
    public function setContent(string $content): static
    {
        $this->processAndDeobfuscate($content);
        return $this;
    }

    public function getContent(): string
    {
        return $this->log->getLogFile()->getContent();
    }

    protected function processAndDeobfuscate(string $data): void
    {
        $this->process($data);
        $deobfuscator = new Deobfuscator($this->getCodexLog());
        if ($deobfuscatedData = $deobfuscator->deobfuscate()) {
            $this->process($deobfuscatedData);
        }
    }

    protected function process($data): void
    {
        $this->log = new Detective()->setLogFile(new StringLogFile($data))->detect();
        $this->log->parse();
        if ($this->log instanceof AnalysableLogInterface) {
            $this->log->analyse();
        }
    }

    /**
     * Get the codex log object
     *
     * @return LogInterface
     */
    public function getCodexLog(): LogInterface
    {
        return $this->log;
    }

    /**
     * Get the log analysis
     *
     * @return Analysis|null
     */
    public function getAnalysis(): ?Analysis
    {
        $log = $this->getCodexLog();
        if ($log instanceof AnalysableLogInterface) {
            return $log->analyse();
        }
        return null;
    }

    /**
     * @return Printer
     */
    public function getPrinter(): Printer
    {
        if ($this->printer === null) {
            $this->printer = new Printer()->setLog($this->log)->setId($this->id);
        }
        return $this->printer;
    }

    /**
     * Get the amount of lines in this log
     *
     * @return int
     */
    public function getLinesCount(): int
    {
        $codexLog = $this->getCodexLog();
        $lines = 0;
        foreach ($codexLog as $entry) {
            $lines += count($entry);
        }
        return $lines;
    }

    /**
     * @return string
     */
    public function getLinesString(): string
    {
        $lineCount = $this->getLinesCount();
        return $lineCount . ($lineCount === 1 ? " line" : " lines");
    }

    /**
     * @return int
     */
    public function getSize(): int
    {
        return strlen($this->getContent());
    }

    /**
     * Get the amount of error entries in the log
     *
     * @return int
     */
    public function getErrorsCount(): int
    {
        $errorCount = 0;

        foreach ($this->log as $entry) {
            if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {
                $errorCount++;
            }
        }

        return $errorCount;
    }

    /**
     * @return bool
     */
    public function hasErrors(): bool
    {
        return $this->getErrorsCount() > 0;
    }

    /**
     * @return string
     */
    public function getErrorsString(): string
    {
        $errorCount = $this->getErrorsCount();
        return $errorCount . ($errorCount === 1 ? " error" : " errors");
    }

    protected function generateId(): Id
    {
        do {
            $this->id = new Id();
        } while (MongoDBClient::getInstance()->hasLog($this->id));
        return $this->id;
    }

    /**
     * Save the log to the database
     *
     * @return $this
     */
    public function save(string $content): static
    {
        if ($this->id === null) {
            $this->generateId();
        }

        $content = Filter::filterAll($content);

        MongoDBClient::getInstance()->getLogsCollection()->insertOne([
            "_id" => $this->id->get(),
            "data" => $content,
            "token" => $this->token?->get(),
            "source" => $this->source,
            "metadata" => $this->metadata,
            "expires" => $this->expires = $this->getExpiryTimestamp(),
            "created" => $this->created = new UTCDateTime()
        ]);

        return $this->setContent($content);
    }

    /**
     * @return UTCDateTime
     */
    protected function getExpiryTimestamp(): UTCDateTime
    {
        $ttl = \Aternos\Mclogs\Config\Config::getInstance()->get(ConfigKey::STORAGE_TTL);
        $expires = time() + $ttl;
        return new UTCDateTime($expires * 1000);
    }

    /**
     * Renew the expiry timestamp to expand the ttl
     *
     * @return bool
     */
    public function renew(): bool
    {
        $expires = $this->getExpiryTimestamp();
        $result = MongoDBClient::getInstance()->setLogExpires($this->id, $expires);
        if ($result) {
            $this->expires = $expires;
        }
        return $result;
    }

    /**
     * @return Uri
     */
    public function getURL(): Uri
    {
        return URL::getBase()->withPath("/" . $this->id->get());
    }

    /**
     *
     * @return string
     */
    public function getDisplayURL(): string
    {
        $url = $this->getURL();
        return $url->getHost() . $url->getPath();
    }

    /**
     * @return Uri
     */
    public function getRawURL(): Uri
    {
        return URL::getApi()->withPath("/1/raw/" . $this->id->get());
    }

    /**
     * @return Id|null
     */
    public function getId(): ?Id
    {
        return $this->id;
    }

    /**
     * @return Token|null
     */
    public function getToken(): ?Token
    {
        return $this->token;
    }

    /**
     * @return bool
     */
    public function delete(): bool
    {
        return MongoDBClient::getInstance()->deleteLog($this->id->get());
    }

    /**
     * @return MetadataEntry[]
     */
    public function getMetadata(): array
    {
        return $this->metadata;
    }

    /**
     * @return MetadataEntry[]
     */
    public function getVisibleMetadata(): array
    {
        return array_filter($this->metadata, function (MetadataEntry $entry) {
            return $entry->isVisible();
        });
    }

    /**
     * @return bool
     */
    public function setTokenCookie(): bool
    {
        if (!$this->getToken()) {
            return false;
        }
        return new TokenCookie($this)->set($this->getToken()->get());
    }

    /**
     * @return bool
     */
    public function hasValidTokenCookie(): bool
    {
        $tokenCookie = new TokenCookie();
        $cookieValue = $tokenCookie->getValue();
        if ($cookieValue === null || !$this->getToken()) {
            return false;
        }
        return $this->getToken()->matches($cookieValue);
    }

    /**
     * @return string
     */
    public function getPageTitle(): string
    {
        return $this->getCodexLog()?->getTitle() . " [#" . $this->getId()?->get() . "]";
    }

    /**
     * @return string
     */
    public function getPageDescription(): string
    {
        $description = $this->getLinesString();
        if ($this->hasErrors()) {
            $description .= " | " . $this->getErrorsString();
        }

        $problems = $this->getAnalysis()->getProblems();

        if (count($problems) > 0) {
            $problemString = "problems";
            if (count($problems) === 1) {
                $problemString = "problem";
            }
            $description .= " | " . count($problems) . " " . $problemString . " automatically detected";
        }

        return $description;
    }
}


================================================
FILE: src/Printer/FormatModification.php
================================================
<?php

namespace Aternos\Mclogs\Printer;

/**
 * Class FormatModification
 *
 * @package Printer
 */
class FormatModification extends \Aternos\Codex\Minecraft\Printer\FormatModification
{
    /**
     * @param string $format
     * @return string
     */
    protected function getClasses(string $format): string
    {
        return "format format-" . $format;
    }
}

================================================
FILE: src/Printer/Printer.php
================================================
<?php

namespace Aternos\Mclogs\Printer;

use Aternos\Codex\Log\Entry;
use Aternos\Codex\Log\EntryInterface;
use Aternos\Codex\Log\Level;
use Aternos\Codex\Log\LineInterface;
use Aternos\Codex\Printer\ModifiableDefaultPrinter;
use Aternos\Mclogs\Id;

/**
 * Class Printer
 *
 * @package Printer
 */
class Printer extends ModifiableDefaultPrinter
{
    public function __construct()
    {
        $this->addModification(new FormatModification());
    }

    /**
     * @var Id
     */
    protected Id $id;

    /**
     * @param Id $id
     * @return Printer
     */
    public function setId(Id $id): static
    {
        $this->id = $id;
        return $this;
    }

    /**
     * @return string
     */
    protected function printLog(): string
    {
        return '<div class="log-inner">' . parent::printLog() . '</div>';
    }

    /**
     * @param EntryInterface|null $entry
     * @return string
     * @throws \Exception
     */
    protected function printEntry(?EntryInterface $entry = null): string
    {
        $entry = $entry ?? $this->entry;
        /** @var Entry $entry */
        $return = '';
        $first = true;
        foreach ($entry as $line) {
            $entryClass = "entry-no-error";
            if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {
                $entryClass = "entry-error";
            }
            $return .= '<div class="entry ' . $entryClass . '">';
            $return .= '<div class="line-number-container"><a href="/' . $this->id->get() . '#L' . $line->getNumber() . '" id="L' . $line->getNumber() . '" class="line-number">' . $line->getNumber() . '</a></div>';
            $return .= '<div class="line-content"><span class="level level-' . $entry->getLevel()->asString() . ((!$first) ? " multiline" : "") . '">';
            $lineString = $this->printLine($line);
            if ($entry->getPrefix() !== null) {
                $prefix = htmlentities($entry->getPrefix());
                $lineString = str_replace($prefix, '<span class="level-prefix">' . $prefix . '</span>', $lineString);
            }
            $return .= $lineString;
            $return .= '</span></div>';
            $return .= '</div>';
            $first = false;
        }

        return $return;
    }

    /**
     * @param LineInterface $line
     * @return string
     */
    protected function printLine(LineInterface $line): string
    {
        return $this->runModifications(htmlentities($line->getText())) . PHP_EOL;
    }
}


================================================
FILE: src/Router/Action.php
================================================
<?php

namespace Aternos\Mclogs\Router;

abstract class Action
{
    abstract public function run(): bool;
}

================================================
FILE: src/Router/Method.php
================================================
<?php

namespace Aternos\Mclogs\Router;

enum Method: string
{
    case GET = 'GET';
    case POST = 'POST';
    case PUT = 'PUT';
    case DELETE = 'DELETE';
    case OPTIONS = 'OPTIONS';

    public static function getCurrent(): self
    {
        return self::tryFrom($_SERVER['REQUEST_METHOD']) ?? self::GET;
    }
}

================================================
FILE: src/Router/Route.php
================================================
<?php

namespace Aternos\Mclogs\Router;

class Route
{
    public function __construct(
        protected Method $method,
        protected string $pattern,
        protected Action $action
    )
    {
    }

    /**
     * @param Method $method
     * @param string $path
     * @return bool
     */
    public function matches(Method $method, string $path): bool
    {
        if ($this->getMethod() !== $method) {
            return false;
        }
        return preg_match($this->getPattern(), $path) === 1;
    }

    /**
     * @return Method
     */
    public function getMethod(): Method
    {
        return $this->method;
    }

    /**
     * @return string
     */
    public function getPattern(): string
    {
        return $this->pattern;
    }

    /**
     * @return Action
     */
    public function getAction(): Action
    {
        return $this->action;
    }
}

================================================
FILE: src/Router/Router.php
================================================
<?php

namespace Aternos\Mclogs\Router;

use Aternos\Mclogs\Util\Singleton;
use Aternos\Mclogs\Util\URL;

class Router
{
    use Singleton;

    /**
     * @var Route[]
     */
    protected array $routes = [];

    protected ?Action $defaultAction = null;

    /**
     * @param Method $method
     * @param string $pattern
     * @param Action $action
     * @return $this
     */
    public function register(Method $method, string $pattern, Action $action): static
    {
        $this->routes[] = new Route($method, $pattern, $action);
        return $this;
    }

    /**
     * @param Action $defaultAction
     * @return $this
     */
    public function setDefaultAction(Action $defaultAction): static
    {
        $this->defaultAction = $defaultAction;
        return $this;
    }

    /**
     * @return $this
     */
    public function run(): static
    {
        $route = $this->findRoute();
        if (!$route) {
            $this->defaultAction?->run();
            return $this;
        }
        $result = $route->getAction()->run();
        if (!$result) {
            $this->defaultAction?->run();
        }
        return $this;
    }

    /**
     * @return Route|null
     */
    protected function findRoute(): ?Route
    {
        $path = URL::getCurrent()->getPath();
        $method = Method::getCurrent();

        foreach ($this->routes as $route) {
            if ($route->matches($method, $path)) {
                return $route;
            }
        }
        return null;
    }
}


================================================
FILE: src/Storage/MongoDBClient.php
================================================
<?php

namespace Aternos\Mclogs\Storage;

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Util\Singleton;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;
use MongoDB\Collection;
use MongoDB\Database;
use Uri\Rfc3986\Uri;

class MongoDBClient
{
    use Singleton;

    protected ?Client $connection = null;
    protected Database $database;

    protected ?Collection $logs = null;
    protected ?Collection $cache = null;

    /**
     * @return string
     */
    protected function getConnectionURL(): string
    {
        $configUrl = Config::getInstance()->get(ConfigKey::MONGODB_URL);
        $url = new Uri($configUrl);
        $query = $url->getQuery();
        $queryParams = [];
        if ($query !== null) {
            parse_str($query, $queryParams);
        }
        if (!isset($queryParams['serverSelectionTimeoutMS'])) {
            $queryParams['serverSelectionTimeoutMS'] = 5_000;
        }
        if (!isset($queryParams['socketTimeoutMS'])) {
            $queryParams['socketTimeoutMS'] = 60_000;
        }
        $newQuery = http_build_query($queryParams);
        $newUrl = $url->withQuery($newQuery);
        return $newUrl->toString();
    }

    /**
     * Connect to MongoDB
     */
    protected function connect(): void
    {
        if ($this->connection === null) {
            $config = Config::getInstance();
            $this->connection = new Client($this->getConnectionURL());
            $this->database = $this->connection->getDatabase($config->get(ConfigKey::MONGODB_DATABASE));
        }
    }

    /**
     * Ensure indexes exist
     *
     * @return void
     */
    public function ensureIndexes(): void
    {
        $logs = $this->getLogsCollection();
        $logs->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);

        $cache = $this->getCacheCollection();
        $cache->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);
    }

    /**
     * @return void
     */
    public function reset(): void
    {
        $this->connection = null;
        $this->logs = null;
        $this->cache = null;
    }

    /**
     * Get the collection for logs
     *
     * @return Collection
     */
    public function getLogsCollection(): Collection
    {
        if ($this->logs === null) {
            $this->connect();
            $this->logs = $this->database->getCollection('logs');
        }
        return $this->logs;
    }

    /**
     * @param string $id
     * @param bool $includeContent
     * @return object|null
     */
    public function findLog(string $id, bool $includeContent = true): ?object
    {
        $options = [];
        if (!$includeContent) {
            $options['projection'] = ['data' => 0];
        }

        $collection = $this->getLogsCollection();
        $result = $collection->findOne(['_id' => $id], $options);
        if ($result === null) {
            // Check for legacy ID without the first character
            return $collection->findOne(['_id' => substr($id, 1)], $options);
        }
        return $result;
    }

    /**
     * @param string[] $ids
     * @param bool $includeContent
     * @return object[]
     */
    public function findLogs(array $ids, bool $includeContent = true): array
    {
        $options = [];
        if (!$includeContent) {
            $options['projection'] = ['data' => 0];
        }

        $collection = $this->getLogsCollection();
        $results = $collection->find(['_id' => ['$in' => $ids]], $options)->toArray();
        $foundIds = [];
        foreach ($results as $result) {
            $foundIds[] = (string)$result->_id;
        }

        $missingIds = array_diff($ids, $foundIds);
        if (!empty($missingIds)) {
            $legacyIds = [];
            foreach ($missingIds as $id) {
                $legacyIds[substr($id, 1)] = $id;
            }

            // Check for legacy IDs without the first character
            $legacyResults = $collection->find(['_id' => ['$in' => array_keys($legacyIds)]], $options)->toArray();
            foreach ($legacyResults as $result) {
                // Map the legacy ID back to the original ID
                $originalId = $legacyIds[(string)$result->_id];
                $result->_id = $originalId;

                // Add the found legacy results to the main results array
                $results[] = $result;
            }
        }
        return $results;
    }

    /**
     * @param string $id
     * @return bool
     */
    public function deleteLog(string $id): bool
    {
        $collection = $this->getLogsCollection();
        $result = $collection->deleteOne(['_id' => $id]);
        if ($result->getDeletedCount() === 0) {
            // Check for legacy ID without the first character
            $result = $collection->deleteOne(['_id' => substr($id, 1)]);
            return $result->getDeletedCount() === 1;
        }
        return true;
    }

    /**
     * @param array $ids
     * @return int Number of logs deleted
     */
    public function deleteLogs(array $ids): int
    {
        $collection = $this->getLogsCollection();
        $result = $collection->deleteMany(['_id' => ['$in' => $ids]]);
        $deletedCount = $result->getDeletedCount();

        if ($deletedCount === count($ids)) {
            return $deletedCount;
        }

        // Check for legacy IDs without the first character
        $legacyIds = [];
        foreach ($ids as $id) {
            $legacyIds[] = substr($id, 1);
        }
        $legacyResult = $collection->deleteMany(['_id' => ['$in' => $legacyIds]]);
        return $deletedCount + $legacyResult->getDeletedCount();
    }

    /**
     * @param string $id
     * @return bool
     */
    public function hasLog(string $id): bool
    {
        return $this->findLog($id) !== null;
    }

    /**
     * @param string $id
     * @param UTCDateTime $expires
     * @return bool
     */
    public function setLogExpires(string $id, UTCDateTime $expires): bool
    {
        $collection = $this->getLogsCollection();
        $result = $collection->updateOne(
            ['_id' => $id],
            ['$set' => ['expires' => $expires]]
        );
        return $result->getModifiedCount() === 1;
    }

    /**
     * Get the collection for caching
     *
     * @return Collection
     */
    public function getCacheCollection(): Collection
    {
        if ($this->cache === null) {
            $this->connect();
            $this->cache = $this->database->getCollection('cache');
        }
        return $this->cache;
    }
}


================================================
FILE: src/Util/Singleton.php
================================================
<?php

namespace Aternos\Mclogs\Util;

trait Singleton
{
    /**
     * @var static[]
     */
    protected static array $instances = [];

    public static function getInstance(): static
    {
        $class = get_called_class();

        if (!isset(static::$instances[$class])) {
            static::$instances[$class] = new static;
        }

        return static::$instances[$class];
    }


    /**
     * Prohibited for singleton
     */
    protected function __clone()
    {
    }

    /**
     * Prohibited for singleton
     */
    protected function __construct()
    {
    }
}

================================================
FILE: src/Util/TimeInterval.php
================================================
<?php

namespace Aternos\Mclogs\Util;

class TimeInterval
{
    use Singleton;

    protected const array UNITS = [
        "year"   => 365 * 24 * 60 * 60,
        "month"  => 30 * 24 * 60 * 60,
        "week"   => 7 * 24 * 60 * 60,
        "day"    => 24 * 60 * 60,
        "hour"   => 60 * 60,
        "minute" => 60,
        "second" => 1,
    ];

    /**
     * @param int $value
     * @param string $unit
     * @return string
     */
    protected function formatUnit(int $value, string $unit): string
    {
        if ($value === 1) {
            return $value . " " . $unit;
        } else {
            return $value . " " . $unit . "s";
        }
    }

    /**
     * @param int $duration
     * @param string $separator
     * @return string
     */
    public function format(int $duration, string $separator = ", "): string
    {
        $parts = [];
        while ($duration > 0) {
            foreach (self::UNITS as $unit => $seconds) {
                if ($duration >= $seconds) {
                    $value = intdiv($duration, $seconds);
                    $duration -= $value * $seconds;
                    $parts[] = $this->formatUnit($value, $unit);
                    break;
                }
            }
        }
        return implode($separator, $parts);
    }
}


================================================
FILE: src/Util/URL.php
================================================
<?php

namespace Aternos\Mclogs\Util;

use Uri\Rfc3986\Uri;

class URL
{
    protected const string API_SUBDOMAIN = "api.";

    protected static ?Uri $base = null;
    protected static ?Uri $api = null;
    protected static ?Uri $current = null;

    public static function clear(): void
    {
        static::$base = null;
        static::$api = null;
        static::$current = null;
    }

    /**
     * @return string
     */
    protected static function readProtocol(): string
    {
        if (isset($_SERVER['HTTP_FORWARDED'])) {
            $forwarded = explode(';', $_SERVER['HTTP_FORWARDED']);
            foreach ($forwarded as $part) {
                $part = trim($part);
                $partParts = explode('=', $part, 2);
                if (count($partParts) === 2 && strtolower($partParts[0]) === 'proto') {
                    $protocol = $partParts[1];
                    $protocol = trim($protocol, '"\'');
                    return strtolower($protocol);
                }
            }
        }
        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
            $protoParts = explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO']);
            return strtolower(trim($protoParts[0]));
        }
        if (isset($_SERVER['REQUEST_SCHEME'])) {
            return strtolower($_SERVER['REQUEST_SCHEME']);
        }
        return 'http';
    }

    /**
     * @return string
     */
    protected static function getProtocol(): string
    {
        $protocol = static::readProtocol();
        if ($protocol === 'https') {
            return 'https';
        }
        return 'http';
    }

    /**
     * Get base URL
     *
     * @return Uri
     */
    public static function getBase(): Uri
    {
        if (static::$base) {
            return static::$base;
        }
        $host = $_SERVER['HTTP_HOST'];
        if (str_starts_with($host, static::API_SUBDOMAIN)) {
            $host = substr($host, strlen(static::API_SUBDOMAIN));
        }
        return static::$base = new Uri(static::getProtocol() . "://" . $host);
    }

    /**
     * Get API URL
     *
     * @return Uri
     */
    public static function getApi(): Uri
    {
        if (static::$api) {
            return static::$api;
        }
        $base = static::getBase();
        return static::$api = $base->withHost(static::API_SUBDOMAIN . $base->getHost());
    }

    /**
     * @return Uri
     */
    public static function getCurrent(): Uri
    {
        if (static::$current) {
            return static::$current;
        }
        $scheme = $_SERVER['REQUEST_SCHEME'];
        $host = $_SERVER['HTTP_HOST'];
        $requestUri = $_SERVER['REQUEST_URI'];
        return static::$current = new Uri("$scheme://$host$requestUri");
    }

    /**
     * @return bool
     */
    public static function isApi(): bool
    {
        $currentHost = static::getCurrent()->getHost();
        $apiHost = static::getApi()->getHost();
        return $currentHost === $apiHost;
    }

    /**
     * @return string
     */
    public static function getLastPathPart(): string
    {
        $path = static::getCurrent()->getPath();
        $parts = explode("/", $path);
        do {
            $part = trim(array_pop($parts));
        } while ($part === "" && count($parts) > 0);
        return $part;
    }
}

================================================
FILE: web/frontend/404.php
================================================
<!DOCTYPE html>
<html lang="en">
    <head>
        <?php include __DIR__ . '/parts/head.php'; ?>
        <title>404 - Page not found</title>
    </head>
    <body>
    <?php include __DIR__ . '/parts/header.php'; ?>
            <main>
                <div class="error-page">
                    <div class="error-code">404</div>
                    <div class="error-message">Page not found</div>
                    <p class="error-description">The log you're looking for doesn't exist or has expired.</p>
                    <a href="/" class="btn btn-blue">
                        <i class="fa-solid fa-home"></i>
                        Back to Home
                    </a>
                </div>
            </main>
        <?php include __DIR__ . '/parts/footer.php'; ?>
    </body>
</html>


================================================
FILE: web/frontend/api-docs.php
================================================
<?php

use Aternos\Mclogs\Api\Action\BulkDeleteLogsAction;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\MultiResponse;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Util\URL;

$config = Config::getInstance();
?>
<!DOCTYPE html>
<html lang="en">
    <head>
        <?php include __DIR__ . '/parts/head.php'; ?>
        <title>API Documentation - <?= htmlspecialchars($config->getName()); ?></title>
        <meta name="description" content="API documentation for <?= htmlspecialchars($config->getName()); ?> - Integrate log sharing directly into your server panel or hosting software." />
    </head>
    <body>
    <?php include __DIR__ . '/parts/header.php'; ?>
            <main>
                <div class="api-docs-header">
                    <div class="api-docs-header-content">
                        <h1>API Documentation</h1>
                        <p>Integrate <strong><?= htmlspecialchars($config->getName()); ?></strong> directly into your server panel, your hosting software or anything else. This platform was built for high performance automation and can easily be integrated into any existing software via our HTTP API.</p>
                    </div>
                </div>
                <div class="api-docs-toc">
                    <h3>Quick Links</h3>
                    <nav class="api-docs-toc-nav">
                        <a href="#create-log">Create a log</a>
                        <a href="#get-log-info">Get log info and content</a>
                        <a href="#delete-log">Delete a log</a>
                    </nav>
                </div>
                <div class="api-docs-section" id="create-log">
                    <h2>Create a log</h2>

                    <div class="api-endpoint">
                        <span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/log")->toString()); ?></span> <span class="content-type">application/json</span>
                    </div>
                    <div class="api-note">
                        Posting content with the content type <span class="content-type">application/x-www-form-urlencoded</span> is still supported for backwards compatibility, but does not support setting metadata.
                    </div>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Required</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">content</td>
                            <td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
                            <td class="api-type">string</td>
                            <td class="api-description">
                                The raw log file content as string.
                                Limited to <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_BYTES) / 1024 / 1024, 2); ?> MiB and <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_LINES)); ?> lines.
                                Will be truncated if possible and necessary, but truncating on the client side is recommended.
                            </td>
                        </tr>
                        <tr>
                            <td class="api-field">source</td>
                            <td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
                            <td class="api-type">string</td>
                            <td class="api-description">The name of the source, e.g. a domain or software name.</td>
                        </tr>
                        <tr>
                            <td class="api-field">metadata</td>
                            <td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
                            <td class="api-type">array</td>
                            <td class="api-description">An array of metadata entries.</td>
                        </tr>
                    </table>

                    <h3>Example body <span class="content-type">application/json</span></h3>
                    <pre class="api-code">{
    "content": "[log file content...]",
    "source": "example.org"
}</pre>

                    <h3>Metadata</h3>
                    <p>
                        You can send metadata alongside the log content to be displayed on the log page and/or be read by other applications through this API.
                        This is entirely optional, but can help to provide additional context, e.g. internal server IDs, software versions etc.
                    </p>
                    <p>
                        A metadata entry is an object with the following fields:
                    </p>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Required</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">key</td>
                            <td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
                            <td class="api-type">string</td>
                            <td class="api-description">The metadata key. Can be used to identify the entry in your code later.</td>
                        </tr>
                        <tr>
                            <td class="api-field">value</td>
                            <td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
                            <td class="api-type">string|int|float|bool|null</td>
                            <td class="api-description">The metadata value.</td>
                        </tr>
                        <tr>
                            <td class="api-field">label</td>
                            <td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
                            <td class="api-type">string</td>
                            <td class="api-description">The display label. If not provided, the key will be used as label.</td>
                        </tr>
                        <tr>
                            <td class="api-field">visible</td>
                            <td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
                            <td class="api-type">bool</td>
                            <td class="api-description">Whether this metadata should be visible on the log page or is only available through the API. Default is true.</td>
                        </tr>
                    </table>

                    <h3>Example body with metadata <span class="content-type">application/json</span></h3>
                    <pre class="api-code">{
    "content": "[log file content...]",
    "source": "example.org",
    "metadata": [
        {
            "key": "server_id",
            "value": 12345,
            "visible": false
        },
        {
            "key": "software_version",
            "value": "1.2.3",
            "label": "Software Version",
            "visible": true
        }
    ]
}</pre>

                    <h3>Responses</h3>
                    <h4>Success <span class="content-type">application/json</span></h4>
                    <div class="api-note">
                        The token provided in this response can be used to delete this log later. Store or discard it securely, it will not be shown again.
                    </div>
                    <pre class="api-code">{
    "success":true,
    "id":"WnMMikq",
    "source":null,
    "created":1769597979,
    "expires":1777373979,
    "size":157369,
    "lines":1201,
    "errors":8,
    "url": "<?= htmlspecialchars(URL::getBase()->withPath("/WnMMikq")->toString()); ?>",
    "raw": "<?= htmlspecialchars(URL::getApi()->withPath("/1/raw/WnMMikq")->toString()); ?>",
    "token":"78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771",
    "metadata": [
        {
            "key": "server_id",
            "value": 12345,
            "visible": false
        },
        {
            "key": "software_version",
            "value": "1.2.3",
            "label": "Software Version",
            "visible": true
        }
    ]
}</pre>
                    <h4>Error <span class="content-type">application/json</span></h4>
                    <pre class="api-code">
{
    "success": false,
    "error": "Required field 'content' not found."
}</pre>
                </div>

                <div class="api-docs-section" id="get-log-info">
                    <h2>Get log info and content</h2>
                    <div class="api-endpoint">
                        <span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>
                    </div>
                    <p>
                        This endpoint only returns the log info and metadata by default (same response as creating a log), you can also get the content in the same request by enabling it in different
                        formats using GET parameters. You can combine multiple parameters to get multiple content formats in one request, but keep in mind that this will
                        increase the response size.
                    </p>
                    <table class="api-table">
                        <tr>
                            <th>GET Parameter</th>
                            <th>Response field</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">raw</td>
                            <td class="api-type">content.raw</td>
                            <td class="api-description">Includes the raw log content as string in the response.</td>
                        </tr>
                        <tr>
                            <td class="api-field">parsed</td>
                            <td class="api-type">content.parsed</td>
                            <td class="api-description">Includes the parsed log content as array/objects in the response.</td>
                        </tr>
                        <tr>
                            <td class="api-field">insights</td>
                            <td class="api-type">content.insights</td>
                            <td class="api-description">Includes the automatically detected insights in the response.</td>
                        </tr>
                    </table>
                    <h3>Responses</h3>
                    <h4>Success <span class="content-type">application/json</span></h4>
                    <div class="api-note">
                        All content fields are only included if the corresponding GET parameter is provided.
                        If no content parameter is provided, the entire content object is omitted from the response.
                    </div>
                    <pre class="api-code">{
    "success":true,
    "id":"WnMMikq",
    "source":null,
    "created":1769597979,
    "expires":1777373979,
    "size":157369,
    "lines":1201,
    "errors":8,
    "url": "<?= htmlspecialchars(URL::getBase()->withPath("/WnMMikq")->toString()); ?>",
    "raw": "<?= htmlspecialchars(URL::getApi()->withPath("/1/raw/WnMMikq")->toString()); ?>",
    "metadata": [
        {
            "key": "server_id",
            "value": 12345,
            "visible": false
        },
        {
            "key": "software_version",
            "value": "1.2.3",
            "label": "Software Version",
            "visible": true
        }
    ],
    "content": {
        "raw": "[log file content...]",
        "parsed": [ /* parsed log entries */ ],
        "insights": { "problems": [ /* detected problems */ ], "information": [ /* detected information */ ] }
    }
}</pre>
                    <h4>Error <span class="content-type">application/json</span></h4>
                    <pre class="api-code">
{
    "success": false,
    "error": "Log not found."
}</pre>
                </div>
                <div class="api-docs-section" id="delete-log">
                    <h2>Delete a log</h2>
                    <div class="api-note">
                        Deleting a log requires the token that was provided when creating the log.
                    </div>

                    <div class="api-endpoint">
                        <span class="api-method">DELETE</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>
                    </div>

                    <h3>Headers</h3>
                    <table class="api-table">
                        <tr>
                            <th>Header</th>
                            <th>Example</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">Authorization</td>
                            <td class="api-type">Authorization: Bearer 78351fafe495398163f...</td>
                            <td class="api-description">The type (always "Bearer") and the log token received when creating the log.</td>
                        </tr>
                    </table>

                    <h3>Responses</h3>
                    <h4>Success <span class="content-type">application/json</span></h4>
                    <pre class="api-code">{
    "success": true
}</pre>
                    <h4>Error <span class="content-type">application/json</span></h4>
                    <pre class="api-code">
{
    "success": false,
    "error": "Invalid token."
}</pre>
                </div>
                <div class="api-docs-section" id="bulk-delete-log">
                    <h2>Bulk delete multiple logs</h2>
                    <div class="api-note">
                        This method allows deleting up to <?= BulkDeleteLogsAction::MAX_IDS; ?> at once.
                        Deleting logs requires the tokens that were provided when the logs were created.
                    </div>

                    <div class="api-endpoint">
                        <span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/bulk/log/delete</span>
                    </div>

                    <h3>Example body <span class="content-type">application/json</span></h3>
                    <pre class="api-code"><?= json_encode([
                                [
                                        "id" => "6wexMDE",
                                        "token" => "78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771"
                                ],
                                [
                                        "id" => "OahzhMG",
                                        "token" => "6520dd42ec3d5fd0e83f28220974fb83d3bdc0746853f5022373f8e5b062651b"
                                ],
                        ], JSON_PRETTY_PRINT); ?></pre>

                    <h3>Responses</h3>
                    <h4>Success <span class="content-type">application/json</span></h4>
                    <div class="api-note">
                        The bulk delete request will return a successful result and status code <code>207</code>,
                        indicating that the request was processed.
                        Results for the individual operations are included in the response body.
                    </div>
                    <pre class="api-code"><?=json_encode(new MultiResponse()
                                ->addResponse("6wexMDE", new ApiResponse())
                                ->addResponse("OahzhMG", new ApiResponse()), JSON_PRETTY_PRINT); ?></pre>
                    <h4>Partial success <span class="content-type">application/json</span></h4>
                    <div class="api-note">
                        If a bulk delete request is valid, but not all logs can be deleted (e.g. due to invalid tokens or non-existing logs),
                        it will still overall be considered successful, but the response body will include error results for the logs that could not be deleted.
                    </div>
                    <pre class="api-code"><?=json_encode(new MultiResponse()
                                ->addResponse("6wexMDE", new ApiResponse())
                                ->addResponse("OahzhMG", new ApiError(404, "Log not found.")), JSON_PRETTY_PRINT); ?></pre>
                    <h4>Error <span class="content-type">application/json</span></h4>
                    <div class="api-note">
                        If a bulk delete request is malformed or invalid, the entire request will be
                        rejected with an error response and no logs will be deleted.
                    </div>
                    <pre class="api-code">
{
    "success": false,
    "error": "No logs provided."
}</pre>
                </div>
                <div class="api-docs-section" id="get-raw">
                    <h2>Get the raw log file content</h2>
                    <div class="api-note">
                        Only use this endpoint if you really only need the raw log content. For most use cases, getting the log info and content together from the log endpoint is recommended.
                    </div>
                    <div class="api-endpoint">
                        <span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/raw/[id]</span>
                    </div>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">[id]</td>
                            <td class="api-type">string</td>
                            <td class="api-description">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>
                        </tr>
                    </table>

                    <h3>Success <span class="content-type">text/plain</span></h3>
                    <pre class="api-code">
[18:25:33] [Server thread/INFO]: Starting minecraft server version 1.16.2
[18:25:33] [Server thread/INFO]: Loading properties
[18:25:34] [Server thread/INFO]: Default game type: SURVIVAL
...
</pre>
                    <h3>Error <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
    "success": false,
    "error": "Log not found."
}</pre>
                </div>
                <div class="api-docs-section" id="get-insights">
                    <h2>Get insights</h2>
                    <div class="api-note">
                        This endpoint is mainly kept for backwards compatibility. For new applications, getting the insights together with the log info from the log endpoint is recommended.
                    </div>
                    <div class="api-endpoint">
                        <span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/insights/[id]</span>
                    </div>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">[id]</td>
                            <td class="api-type">string</td>
                            <td class="api-description">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>
                        </tr>
                    </table>

                    <h3>Success <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
  "id": "name/type",
  "name": "Software name, e.g. Vanilla",
  "type": "Type name, e.g. Server Log",
  "version": "Version, e.g. 1.12.2",
  "title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
  "analysis": {
    "problems": [
      {
        "message": "A message explaining the problem.",
        "counter": 1,
        "entry": {
          "level": 6,
          "time": null,
          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
          "lines": [
            {
              "number": 1,
              "content": "The full content of the line."
            }
          ]
        },
        "solutions": [
          {
            "message": "A message explaining a possible solution."
          }
        ]
      }
    ],
    "information": [
      {
        "message": "Label: value",
        "counter": 1,
        "label": "The label of this information, e.g. Minecraft version",
        "value": "The value of this information, e.g. 1.12.2",
        "entry": {
          "level": 6,
          "time": null,
          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
          "lines": [
            {
              "number": 6,
              "content": "The full content of the line."
            }
          ]
        }
      }
    ]
  }
}</pre>
                    <h3>Error <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
    "success": false,
    "error": "Log not found."
}</pre>
                </div>
                <div class="api-docs-section" id="analyse">
                    <h2>Analyse a log without saving it</h2>
                    <p>
                        If you only want to use the analysis features of this service without saving the log, you can use this endpoint.
                        Please do not save logs that you only want to analyse, as this wastes storage space and resources.
                    </p>

                    <div class="api-endpoint">
                        <span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/analyse")->toString()); ?></span> <span class="content-type">application/x-www-form-urlencoded</span> <span class="content-type">application/json</span>
                    </div>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">content</td>
                            <td class="api-type">string</td>
                            <td class="api-description">The raw log file content as string. Maximum length is 10MiB and 25k lines, will be shortened if necessary.</td>
                        </tr>
                    </table>

                    <h3>Success <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
  "id": "name/type",
  "name": "Software name, e.g. Vanilla",
  "type": "Type name, e.g. Server Log",
  "version": "Version, e.g. 1.12.2",
  "title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
  "analysis": {
    "problems": [
      {
        "message": "A message explaining the problem.",
        "counter": 1,
        "entry": {
          "level": 6,
          "time": null,
          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
          "lines": [
            {
              "number": 1,
              "content": "The full content of the line."
            }
          ]
        },
        "solutions": [
          {
            "message": "A message explaining a possible solution."
          }
        ]
      }
    ],
    "information": [
      {
        "message": "Label: value",
        "counter": 1,
        "label": "The label of this information, e.g. Minecraft version",
        "value": "The value of this information, e.g. 1.12.2",
        "entry": {
          "level": 6,
          "time": null,
          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
          "lines": [
            {
              "number": 6,
              "content": "The full content of the line."
            }
          ]
        }
      }
    ]
  }
}</pre>
                    <h3>Error <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
    "success": false,
    "error": "Required field 'content' is empty."
}</pre>
                </div>
                <div class="api-docs-section" id="check-limits">
                    <h2>Check storage limits</h2>

                    <div class="api-endpoint">
                        <span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/limits")->toString()); ?></span>
                    </div>
                    <h3>Success <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
{
  "storageTime": 7776000,
  "maxLength": 10485760,
  "maxLines": 25000
}</pre>
                    <table class="api-table">
                        <tr>
                            <th>Field</th>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">storageTime</td>
                            <td class="api-type">integer</td>
                            <td class="api-description">The duration in seconds that a log is stored for after the last view.</td>
                        </tr>
                        <tr>
                            <td class="api-field">maxLength</td>
                            <td class="api-type">integer</td>
                            <td class="api-description">Maximum file length in bytes. Logs over this limit will be truncated to this length.</td>
                        </tr>
                        <tr>
                            <td class="api-field">maxLines</td>
                            <td class="api-type">integer</td>
                            <td class="api-description">Maximum number of lines. Additional lines will be removed.</td>
                        </tr>
                    </table>
                </div>
                <div class="api-docs-section" id="check-limits">
                    <h2>Get filters</h2>
                    <p>
                        Filters modify the log content before storing it. They are applied automatically when creating a new log on the server side.
                        You can get a list of active filters from this endpoint if you want to apply the same filters on the client side before uploading a log.
                    </p>
                    <div class="api-endpoint">
                        <span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/filters")->toString()); ?></span>
                    </div>
                    <h3>Success <span class="content-type">application/json</span></h3>
                    <pre class="api-code">
<?=htmlspecialchars(json_encode(\Aternos\Mclogs\Filter\Filter::getAll(), JSON_PRETTY_PRINT)); ?></pre>
                    <h3>Filter types</h3>
                    <table class="api-table">
                        <tr>
                            <th>Type</th>
                            <th>Description</th>
                        </tr>
                        <tr>
                            <td class="api-field">trim</td>
                            <td class="api-description">
                                Trim any whitespace characters from the beginning and end of the log content.
                            </td>
                        </tr>
                        <tr>
                            <td class="api-field">limit-bytes</td>
                            <td class="api-description">
                                Limit the log content to a maximum number of bytes (data.limit). Content exceeding this limit will be truncated.
                            </td>
                        </tr>
                        <tr>
                            <td class="api-field">limit-lines</td>
                            <td class="api-description">
                                Limit the log content to a maximum number of lines (data.limit). Additional lines will be removed.
                            </td>
                        </tr>
                        <tr>
                            <td class="api-field">regex</td>
                            <td class="api-description">
                                Apply regular expression replacements to the log content. Each pattern in data.patterns will be applied in order and replaced with the provided replacement, unless the matched string matches one of the exemption patterns in data.exemptions.
                            </td>
                        </tr>
                    </table>
                    <div class="api-note">
                        Make sure to handle any filter error, e.g. unknown filter types gracefully, as new filter types may be added in the future.
                    </div>
                </div>
                <div class="api-docs-notes">
                    <div class="api-docs-notes-content">
                        <h2>Notes</h2>
                        <p>The API has currently a rate limit of 60 requests per minute per IP address. This is set to ensure the operability of this service. If you have any use case that requires a higher limit, feel free to contact us.</p>
                        <div class="api-docs-notes-actions">
                            <a class="btn btn-small" href="mailto:matthias@aternos.org">
                                <i class="fa-solid fa-envelope"></i> Contact via mail
                            </a>
                        </div>
                    </div>
                </div>
            </main>
        <?php include __DIR__ . '/parts/footer.php'; ?>
    </body>
</html>


================================================
FILE: web/frontend/log.php
================================================
<?php

use Aternos\Mclogs\Frontend\Assets\AssetLoader;
use Aternos\Mclogs\Frontend\Assets\AssetType;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Frontend\Settings\Setting;
use Aternos\Mclogs\Frontend\Settings\Settings;
use Aternos\Mclogs\Util\TimeInterval;

/** @var Log $log */

$settings = new Settings();
?><!DOCTYPE html>
<html lang="en">
    <head>
        <?php include __DIR__ . '/parts/head.php'; ?>
        <title><?=htmlspecialchars($log->getPageTitle()); ?></title>
        <meta name="description" content="<?=htmlspecialchars($log->getPageDescription()); ?>" />
    </head>
    <body class="log-body<?=$settings->getBodyClassesString(); ?>">
    <?php include __DIR__ . '/parts/header.php'; ?>
            <main>
                <div class="log-header">
                   <div class="log-header-inner">
                       <div class="left">
                           <div class="log-title">
                               <h1>
                                   <i class="fas fa-file-lines"></i>
                                   <?=htmlspecialchars($log->getCodexLog()->getTitle()); ?>
                               </h1>
                               <button class="log-url-btn" data-clipboard="<?=htmlspecialchars($log->getURL()->toString()); ?>" title="Copy log URL to clipboard">
                                   <span class="log-url"><?=htmlspecialchars($log->getDisplayURL()); ?></span>
                                   <i class="fa-solid fa-copy"></i>
                               </button>
                           </div>
                       </div>
                       <div class="right">
                           <div class="details">
                               <div class="log-info-actions">
                                   <?php if($log->hasErrors()): ?>
                                       <div class="btn btn-danger btn-small" id="error-toggle">
                                           <i class="fa fa-exclamation-circle"></i>
                                           <?=htmlspecialchars($log->getErrorsString()); ?>
                                       </div>
                                   <?php endif; ?>
                                   <div class="btn btn-dark btn-small" id="down-button">
                                       <i class="fa fa-arrow-circle-down"></i>
                                       <?=htmlspecialchars($log->getLinesString()); ?>
                                   </div>
                                   <a class="btn btn-dark btn-small" id="raw" target="_blank" title="Raw log" href="<?=$log->getRawURL()->toString(); ?>">
                                       <i class="fa fa-arrow-up-right-from-square"></i>
                                       Raw
                                   </a>
                               </div>
                           </div>
                       </div>
                   </div>
                   <?php $information = $log->getAnalysis()->getInformation(); ?>
                   <?php if(count($log->getVisibleMetadata()) > 0 || count($information) > 0): ?>
                       <div class="log-info-rows">
                           <?php if(count($log->getVisibleMetadata()) > 0): ?>
                               <div class="log-info-row">
                                   <div class="info-row-items">
                                       <div class="info-row-header">
                                           <i class="fa-solid fa-tags"></i>
                                           <span>Metadata</span>
                                       </div>
                                       <?php foreach($log->getVisibleMetadata() as $metadata): ?>
                                           <span class="info-item">
                                               <span class="info-label"><?=htmlspecialchars($metadata->getDisplayLabel()); ?>:</span>
                                               <span class="info-value"><?=htmlspecialchars($metadata->getDisplayValue()); ?></span>
                                           </span>
                                       <?php endforeach; ?>
                                   </div>
                               </div>
                           <?php endif; ?>
                           <?php if(count($information) > 0): ?>
                               <div class="log-info-row">
                                   <div class="info-row-items">
                                       <div class="info-row-header">
                                           <i class="fa-solid fa-cube"></i>
                                           <span>Detected</span>
                                       </div>
                                       <?php foreach($information as $info): ?>
                                           <span class="info-item">
                                               <span class="info-label"><?=htmlspecialchars($info->getLabel()); ?>:</span>
                                               <span class="info-value"><?=htmlspecialchars($info->getValue()); ?></span>
                                           </span>
                                       <?php endforeach; ?>
                                   </div>
                               </div>
                           <?php endif; ?>
                       </div>
                   <?php endif; ?>
                    <?php $problems = $log->getAnalysis()?->getProblems(); ?>
                    <?php if(count($problems) > 0): ?>
                        <div class="problems-panel-container">
                            <div class="problems-panel">
                                <div class="problems-header">
                                    <span class="problems-count"><?=count($problems); ?></span>
                                    <span class="problems-title"><?=count($problems) === 1 ? 'Problem' : 'Problems'; ?> detected</span>
                                </div>
                                <div class="problems-list">
                                    <?php foreach($problems as $problem): ?>
                                        <?php $number = $problem->getEntry()[0]->getNumber(); ?>
                                        <div class="problem-item">
                                            <a href="/<?=htmlspecialchars($log->getId()->get()) . "#L" . $number; ?>" class="problem-entry" onclick="updateLineNumber('#L<?=$number; ?>');">
                                        <span class="problem-label">
                                            <i class="fa-solid fa-triangle-exclamation"></i>
                                            Problem
                                        </span>
                                                <span class="problem-text"><?=htmlspecialchars($problem->getMessage()); ?></span>
                                                <span class="problem-line">Line <?=$number; ?></span>
                                            </a>
                                            <?php if(count($problem->getSolutions()) > 0): ?>
                                                <div class="problem-solutions">
                                                    <span class="problem-solutions-label"><?=count($problem->getSolutions()) === 1 ? 'Solution:' : 'Solutions:'; ?></span>
                                                    <?php foreach($problem->getSolutions() as $solution): ?>
                                                        <div class="problem-solution">
                                                            <i class="fa-solid fa-lightbulb"></i>
                                                            <span><?=preg_replace("/'([^']+)'/", "'<strong>$1</strong>'", htmlspecialchars($solution->getMessage())); ?></span>
                                                        </div>
                                                    <?php endforeach; ?>
                                                </div>
                                            <?php endif; ?>
                                        </div>
                                    <?php endforeach; ?>
                                </div>
                            </div>
                        </div>
                    <?php endif; ?>
                </div>
            </main>
            <div class="log-container">
                <div class="log">
                    <?php
                    echo $log->getPrinter()->print();
                    ?>
                </div>
            </div>
            <div class="log-footer">
                <div class="log-bottom">
                    <div class="btn btn-small btn-dark" id="up-button" title="Scroll to top">
                        <i class="fa fa-arrow-circle-up"></i>
                    </div>
                    <div class="actions">
                        <?php if ($log->hasValidTokenCookie()): ?>
                        <div class="delete-wrapper popover-wrapper">
                            <button class="delete-trigger popover-trigger btn btn-small btn-danger" title="Delete log" popovertarget="delete-overlay">
                                <i class="fa-solid fa-trash"></i>
                                Delete
                            </button>
                            <div class="delete-overlay popover-content popover-danger" id="delete-overlay" popover>
                                <span class="delete-message">Delete this log permanently?</span>
                                <div class="popover-error">

                                </div>
                                <div class="delete-actions">
                                    <button class="btn btn-small btn-white" popovertarget="delete-overlay">Cancel</button>
                                    <button class="btn btn-small btn-danger delete-log-button">Delete</button>
                                </div>
                            </div>
                        </div>
                        <?php endif; ?>
                        <div class="settings-dropdown popover-wrapper">
                            <button class="settings-trigger popover-trigger btn btn-small btn-dark" title="Settings" popovertarget="settings-overlay">
                                <i class="fas fa-cog"></i>
                                Settings
                            </button>
                            <div class="settings-overlay popover-content" id="settings-overlay" popover>
                                <?php foreach(Setting::cases() as $setting): ?>
                                    <label class="setting" for="setting-<?=$setting->value; ?>">
                                        <span class="setting-label"><?=$setting->getLabel(); ?></span>
                                        <input type="checkbox"
                                               id="setting-<?=$setting->value; ?>"
                                               class="setting-checkbox"
                                               data-body-class="<?=$setting->getBodyClass() ?? ""; ?>"
                                               data-key="<?=$setting->value; ?>"
                                                <?=($settings->get($setting)) ? " checked" : ""; ?>/>
                                    </label>
                                <?php endforeach; ?>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="log-details">
                <?php
                    $source = $log->getSource();
                    $created = $log->getCreated()?->toDateTime()->getTimestamp();
                ?>
                <?php if ($source || $created): ?>
                    <div class="meta-data">
                        <?php if ($source): ?>
                            <div class="source" title="Source">
                                <i class="fa-solid fa-arrow-up-from-bracket"></i>
                                <?=htmlspecialchars($source); ?>
                            </div>
                        <?php endif; ?>
                        <?php if ($created): ?>
                            <div class="created-time" title="Created">
                                <i class="fa-solid fa-clock"></i>
                                <span class="created" data-time="<?=htmlspecialchars($created); ?>">
                                </span>
                            </div>
                        <?php endif; ?>
                    </div>
                <?php endif; ?>
                    <div class="delete-notice">
                        This log will be saved for <?= htmlspecialchars(TimeInterval::getInstance()->format(Config::getInstance()->get(ConfigKey::STORAGE_TTL))); ?> from its last view.
                    </div>
                    <?php if ($abuseEmail = Config::getInstance()->get(ConfigKey::LEGAL_ABUSE)): ?>
                        <a href="mailto:<?=htmlspecialchars($abuseEmail); ?>?subject=Report%20<?=htmlspecialchars(rawurlencode(Config::getInstance()->getName())); ?>/<?=htmlspecialchars($log->getId()->get()); ?>" class="report-link">
                            <i class="fa-solid fa-flag"></i>
                            Report abuse
                        </a>
                    <?php endif; ?>
                </div>
            </div>
        <?php include __DIR__ . '/parts/footer.php'; ?>
        <div class="floating-scrollbar-container">
            <div class="floating-scrollbar">
                <div class="floating-scrollbar-content">
                </div>
            </div>
        </div>
        <?= AssetLoader::getInstance()->getHTML(AssetType::JS, "js/log.js"); ?>
    </body>
</html>


================================================
FILE: web/frontend/parts/favicon.php
================================================
<svg width="41" height="42" viewBox="0 0 41 42" fill="<?=htmlspecialchars(\Aternos\Mclogs\Config\Config::getInstance()->get(\Aternos\Mclogs\Config\ConfigKey::FRONTEND_COLOR_ACCENT)); ?>" xmlns="http://www.w3.org/2000/svg">
    <rect width="41" height="5" rx="2"/>
    <rect y="9.25" width="33" height="5" rx="2"/>
    <rect y="18.5" width="19" height="5" rx="2"/>
    <rect y="27.75" width="33" height="5" rx="2"/>
    <rect y="37" width="41" height="5" rx="2"/>
</svg>


================================================
FILE: web/frontend/parts/footer.php
================================================
<?php
use Aternos\Mclogs\Config\Config;use Aternos\Mclogs\Config\ConfigKey;use Aternos\Mclogs\Util\URL;

$imprintUrl = Config::getInstance()->get(ConfigKey::LEGAL_IMPRINT);
$privacyUrl = Config::getInstance()->get(ConfigKey::LEGAL_PRIVACY);
?>
<footer>
    <?php if($imprintUrl || $privacyUrl): ?>
    <nav class="legal">
        <?php if ($imprintUrl): ?>
            <a href="<?=htmlspecialchars($imprintUrl); ?>" class="footer-link" title="Imprint" target="_blank">Imprint</a>
        <?php endif; ?>
        <?php if ($imprintUrl && $privacyUrl): ?>
            <span class="footer-separator"> - </span>
        <?php endif; ?>
        <?php if ($privacyUrl): ?>
            <a href="<?=htmlspecialchars($privacyUrl); ?>" class="footer-link" title="Privacy Policy" target="_blank">Privacy Policy</a>
        <?php endif; ?>
    </nav>
    <?php endif; ?>
    <nav class="footer-nav">
        <a href="https://github.com/aternosorg/mclogs" title="mclo.gs on Github" target="_blank"><i class="fa-brands fa-github"></i>GitHub</a>
        <a href="https://modrinth.com/plugin/mclogs" title="Download mclo.gs Mod/Plugin" target="_blank"><i class="fa-solid fa-cube"></i>Mod/Plugin</a>
        <a href="<?=htmlspecialchars(URL::getApi()->toString()); ?>" title="mclo.gs API"><i class="fa-solid fa-code"></i>API</a>
    </nav>
    <span class="footer-text">developed by <a href="https://aternos.org" target="_blank" title="Aternos website">Aternos</a>
    </span>
</footer>


================================================
FILE: web/frontend/parts/head.php
================================================
<?php

use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Frontend\Assets\AssetLoader;
use Aternos\Mclogs\Frontend\Assets\AssetType;
use Aternos\Mclogs\Util\URL;

?>
    <meta charset="utf-8"/>

    <base href="/"/>
    <?= AssetLoader::getInstance()->getHTML(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css"); ?>
    <?= AssetLoader::getInstance()->getHTML(AssetType::CSS, "css/mclogs.css"); ?>

    <style>
        :root {
            --bg: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_BACKGROUND)); ?>;
            --text: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_TEXT)); ?>;
            --accent: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ACCENT)); ?>;
            --error: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ERROR)); ?>;
        }
    </style>

    <link rel="shortcut icon" href="img/favicon.ico" type="image/x-icon" sizes="any"/>
    <link rel="shortcut icon" href="<?= htmlspecialchars(URL::getBase()->withPath("/favicon.svg")->toString()); ?>" type="image/svg+xml">

    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<?php if (Config::getInstance()->get(ConfigKey::FRONTEND_ANALYTICS)): ?>
    <script>
        let _paq = window._paq = window._paq || [];
        _paq.push(['disableCookies']);
        _paq.push(['trackPageView']);
        _paq.push(['enableLinkTracking']);
        (function () {
            _paq.push(['setTrackerUrl', '/data']);
            _paq.push(['setSiteId', '5']);
            let d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
            g.async = true;
            g.src = '/data.js';
            s.parentNode.insertBefore(g, s);
        })();
    </script>
<?php endif; ?>


================================================
FILE: web/frontend/parts/header.php
================================================
<header>
    <a href="<?=htmlspecialchars(\Aternos\Mclogs\Util\URL::getBase()->toString()); ?>" class="logo">
        <svg class="logo-icon" width="41" height="42" viewBox="0 0 41 42" fill="none"
             xmlns="http://www.w3.org/2000/svg">
            <rect width="41" height="5" rx="2" fill="currentColor"/>
            <rect y="9.25" width="33" height="5" rx="2" fill="currentColor"/>
            <rect y="18.5" width="19" height="5" rx="2" fill="currentColor"/>
            <rect y="27.75" width="33" height="5" rx="2" fill="currentColor"/>
            <rect y="37" width="41" height="5" rx="2" fill="currentColor"/>
        </svg>
        <span class="logo-text"><?= htmlspecialchars(\Aternos\Mclogs\Config\Config::getInstance()->getName()); ?></span>
    </a>
    <div class="tagline">
        <h1 class="tagline-main"><span class="title-verb">Paste</span> your logs.</h1>
        <div class="tagline-sub">Built for Minecraft & Hytale</div>
    </div>
    <script>
        const titles = ["Paste", "Share", "Analyse"];
        let currentTitle = 0;
        let speed = 30;
        let pause = 3000;
        const titleElement = document.querySelector('.title-verb');

        setTimeout(nextTitle, pause);

        function nextTitle() {
            currentTitle++;
            if (typeof (titles[currentTitle]) === "undefined") {
                currentTitle = 0;
            }

            const title = titleElement.innerHTML;
            for (let i = 0; i < title.length - 1; i++) {
                setTimeout(function () {
                    titleElement.innerHTML = titleElement.innerHTML.substring(0, titleElement.innerHTML.length - 1);
                }, i * speed);
            }

            const newTitle = titles[currentTitle];
            for (let i = 1; i <= newTitle.length; i++) {
                setTimeout(function () {
                    titleElement.innerHTML = newTitle.substring(0, titleElement.innerHTML.length + 1);
                }, title.length * speed + i * speed);
            }

            setTimeout(nextTitle, title.length * speed + newTitle.length * speed + pause);
        }
    </script>
</header>


================================================
FILE: web/frontend/start.php
================================================
<?php
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Filter\Filter;
use Aternos\Mclogs\Frontend\Assets\AssetLoader;
use Aternos\Mclogs\Frontend\Assets\AssetType;
?><!DOCTYPE html>
<html lang="en">
    <head>
        <?php include __DIR__ . '/parts/head.php'; ?>
        <title><?= htmlspecialchars(Config::getInstance()->getName()); ?> - Paste, share & analyse your logs</title>
        <meta name="description" content="Easily paste your Minecraft & Hytale logs to share and analyse them." />
    </head>
    <body data-name="<?=htmlspecialchars(Config::getInstance()->getName()); ?>">
    <?php include __DIR__ . '/parts/header.php'; ?>
            <main>
                <div class="paste-area" id="dropzone">
                    <div class="paste-placeholder">
                        <i class="fa-solid fa-cloud-arrow-up"></i>
                        <p>Paste or drop your log here</p>
                        <div class="paste-hints">
                            <button type="button" class="btn btn-transparent" title="Paste log" id="paste-clipboard"><i class="fa-solid fa-paste"></i> Paste</button>
                            <button type="button" class="btn btn-transparent" title="Browse on files" id="paste-select-file"><i class="fa-solid fa-folder-open"></i> Browse</button>
                            <span><i class="fa-solid fa-file-arrow-up" title="Drop file"></i> Drop</span>
                        </div>
                    </div>
                    <textarea aria-label="Paste or drop your log here" spellcheck="false" data-enable-grammarly="false" id="paste-text"></textarea>
                    <button type="button" class="btn-save btn paste-save" title="Save log" disabled><i class="fa-solid fa-save"></i> Save</button>
                    <div class="paste-error" id="paste-error"></div>
                </div>
            </main>
        <?php include __DIR__ . '/parts/footer.php'; ?>
        <script>
            const FILTERS = <?= json_encode(Filter::getAll()); ?>;
        </script>
        <?= AssetLoader::getInstance()->getHTML(AssetType::JS, "js/start.js"); ?>
    </body>
</html>


================================================
FILE: web/public/css/mclogs.css
================================================
/* plus-jakarta-sans-regular - latin */
@font-face {
    font-display: swap;
    font-family: 'Plus Jakarta Sans';
    font-style: normal;
    font-weight: 400;
    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2');
}
/* plus-jakarta-sans-500 - latin */
@font-face {
    font-display: swap;
    font-family: 'Plus Jakarta Sans';
    font-style: normal;
    font-weight: 500;
    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2');
}
/* plus-jakarta-sans-600 - latin */
@font-face {
    font-display: swap;
    font-family: 'Plus Jakarta Sans';
    font-style: normal;
    font-weight: 600;
    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2') format('woff2');
}
/* jetbrains-mono-regular - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */
@font-face {
    font-display: swap;
    font-family: 'JetBrains Mono';
    font-style: normal;
    font-weight: 400;
    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2') format('woff2');
}
/* jetbrains-mono-italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */
@font-face {
    font-display: swap;
    font-family: 'JetBrains Mono';
    font-style: italic;
    font-weight: 400;
    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2') format('woff2');
}
/* jetbrains-mono-700 - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */
@font-face {
    font-display: swap;
    font-family: 'JetBrains Mono';
    font-style: normal;
    font-weight: 700;
    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2') format('woff2');
}
/* jetbrains-mono-700italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */
@font-face {
    font-display: swap;
    font-family: 'JetBrains Mono';
    font-style: italic;
    font-weight: 700;
    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2') format('woff2');
}

:root {
    --bg-surface: color-mix(in srgb, var(--bg) 92%, var(--text) 8%);
    --bg-elevated: color-mix(in srgb, var(--bg) 95%, var(--text) 5%);
    --bg-inset: var(--bg-surface);
    --text-muted: color-mix(in srgb, var(--text) 55%, var(--bg) 45%);
    --accent-hover: color-mix(in srgb, var(--accent) 78%, var(--bg) 22%);
    --accent-bg: color-mix(in srgb, var(--accent) 12%, transparent);
    --accent-border: var(--accent);
    --error-bg: color-mix(in srgb, var(--error) 10%, transparent);
    --error-border: color-mix(in srgb, var(--error) 40%, transparent);
    --border: rgba(255, 255, 255, 0.08);
    --surface: rgba(255, 255, 255, 0.04);
    --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
    --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
    --max-width: 1400px;
    --page-padding: clamp(1rem, 2.5vw, 1.25rem);
    --max-width-content: min(100%, calc(var(--max-width)) - var(--page-padding) * 2);
    --radius: 12px;
    --scrollbar-height: 8px;
    --browser: unset;
    scroll-behavior: smooth;
}

@view-transition {
    navigation: auto;
}

/* Global scrollbar styling */
*::-webkit-scrollbar {
    width: 8px;
    height: var(--scrollbar-height);
}

*::-webkit-scrollbar-track {
    background: transparent;
}

*::-webkit-scrollbar-thumb {
    background-color: var(--accent);
    border-radius: 4px;
}

*::-webkit-scrollbar-thumb:hover {
    background-color: var(--accent-hover);
}

::selection {
    background-color: var(--accent);
    color: var(--text);
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    scrollbar-color: var(--accent) transparent;
}

html {
    height: 100%;
    text-size-adjust: 100%;
    -webkit-text-size-adjust: 100%;
}

body {
    font-family: var(--font-sans), system-ui, sans-serif;
    background-color: var(--bg);
    color: var(--text);
    line-height: 1.5;
    min-height: 100%;
    display: flex;
    flex-direction: column;
    position: relative;
    font-weight: 400;
}

/* Log Settings */
body.setting-full-width {
    --max-width: 100%;
    --max-width-content: calc(100% - var(--page-padding) * 2);
}

body.setting-overflow .log-container {
    max-width: unset;
    min-width: 100%;
}

body.setting-no-wrap .log-inner {
    white-space: pre;
}

body.setting-no-wrap .log-inner .line-content {
    word-break: normal;
    overflow-wrap: normal;
}

body.setting-no-wrap .log-inner .level {
    white-space: pre;
}

body.setting-no-wrap .log-inner .collapsed-lines-count {
    justify-content: flex-start;
}

a {
    color: inherit;
    text-decoration: none;
    transition: color 0.15s ease;
}

a:hover:not(.btn) {
    color: var(--accent);
}

body::before {
    content: '';
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-image:
        linear-gradient(color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px),
        linear-gradient(90deg, color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px);
    background-size: 40px 40px;
    pointer-events: none;
    z-index: 0;
}

/** Buttons **/

.btn {
    background-color: var(--accent);
    color: var(--bg);
    font-family: inherit;
    font-size: clamp(0.85rem, 2vw, 0.9rem);
    font-weight: 600;
    cursor: pointer;
    display: flex;
    align-items: center;
    border: 2px solid transparent;
    padding: clamp(0.6rem, 2vw, 0.7rem) clamp(1.2rem, 3vw, 1.5rem);
    border-radius: 8px;
    gap: .4rem;
    line-height: 1;
    transition: color .15s ease, background-color .15s ease, border-color .15s ease;
}

.btn:hover:not(:disabled) {
    background-image: linear-gradient(#00000014,#00000014);
}

.btn:disabled,
.btn.disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

.btn-small {
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
    padding: clamp(0.35rem, 1.5vw, 0.4rem) clamp(0.85rem, 2.5vw, 1rem);
}

.btn-transparent {
    background-color: transparent;
    color: var(--accent);
    border: 0 none;
}

.btn-transparent:hover {
    color: var(--accent);
}

.btn-danger {
    background-color: var(--error);
    color: var(--text);
}

#error-toggle {
    cursor: pointer;
    transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

#error-toggle.toggled {
    background-color: var(--error-bg);
    color: var(--text);
    border-color: var(--error);
}

#error-toggle.toggled:hover {
    background-color: var(--error-bg);
}

.btn-white {
    background-color: #fff;
    color: var(--bg);
}

.btn-dark {
    background-color: var(--surface);
    color: var(--text);
    border-color: var(--border);
}

/** Header **/

header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    width: 100%;
    max-width: var(--max-width);
    margin: 0 auto;
    padding: clamp(1rem, 3vw, 2rem) var(--page-padding);
    position: relative;
    z-index: 1;
    transition: max-width .25s ease;
}

.logo {
    view-transition-name: logo;
    display: flex;
    align-items: center;
    gap: .9rem;
    text-decoration: none;
    transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);
    transform-origin: center;
}

.logo:active {
    transform: scale(.9);
}

.logo-icon {
    height: clamp(1.5rem, 3vw, 2rem);
    width: auto;
    margin-top: 3px;
    color: var(--accent);
}

.logo-text {
    font-size: clamp(1.75rem, 3vw, 2rem);
    font-weight: 600;
    color: var(--text);
    margin-top: -3px;
}

.tagline {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    text-align: right;
}

.tagline-main {
    font-size: clamp(1rem, 3vw, 1.5rem);
    color: var(--text);
    font-weight: 400;
}

.tagline-sub {
    font-size: clamp(0.75rem, 2vw, 1rem);
    color: var(--text-muted);
}

.title-verb {
    font-weight: 600;
    color: var(--accent);
}

/** Footer **/

footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 1rem;
    color: var(--text-muted);
    font-size: clamp(0.75rem, 2vw, 0.9rem);
    max-width: var(--max-width);
    width: 100%;
    margin: 0 auto;
    padding: clamp(1rem, 3vw, 2rem) clamp(1rem, 2.5vw, 1.25rem);
    position: relative;
    z-index: 1;
    transition: max-width .25s ease;
}

.legal {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.footer-nav {
    display: flex;
    gap: 1.5rem;
}

.footer-nav a {
    color: var(--text-muted);
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.footer-nav a:hover {
    color: var(--accent);
}

.footer-nav a i {
    font-size: clamp(0.9rem, 2vw, 1rem);
}

.footer-text a {
    color: var(--text-muted);
}

.footer-text a:hover {
    color: var(--accent);
}

/** Main  **/

main {
    max-width: var(--max-width-content);
    width: 100%;
    margin: 0 auto;
    flex: 1;
    display: flex;
    flex-direction: column;
    background-color: var(--bg-surface);
    border-radius: var(--radius);
    position: relative;
    overflow: hidden;
    z-index: 1;
    transition: max-width .25s ease;
}

.paste-area {
    flex: 1;
    width: 100%;
    display: flex;
    flex-direction: column;
    border-radius: var(--radius);
    position: relative;
    transition: background-color 0.25s ease, border-color 0.25s ease;
    border: 2px dashed transparent;
}

.paste-area::after {
    content: '';
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    height: 120px;
    background: linear-gradient(to bottom,
        transparent 0%,
        color-mix(in srgb, var(--bg-surface) 40%, transparent) 40%,
        color-mix(in srgb, var(--bg-surface) 80%, transparent) 70%,
        var(--bg-surface) 100%);
    pointer-events: none;
    z-index: 5;
    border-radius: 0 0 var(--radius) var(--radius);
}

.paste-area.dragover,
.paste-area.window-dragover {
    background-color: color-mix(in srgb, var(--bg-surface) 90%, var(--accent) 10%);
    border-color: var(--accent);
}

.paste-area.dragover .paste-placeholder i.fa-cloud-arrow-up,
.paste-area.window-dragover .paste-placeholder i.fa-cloud-arrow-up {
    color: var(--accent);
    transform: scale(1.1) translateY(-4px);
}

.paste-area.dragover .paste-placeholder p,
.paste-area.window-dragover .paste-placeholder p {
    color: var(--accent);
}

.paste-placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
    z-index: 2;
    font-size: clamp(1rem, 3vw, 1.5rem);
}

.paste-placeholder i.fa-cloud-arrow-up {
    font-size: clamp(2rem, 8vw, 3.5rem);
    color: var(--text-muted);
    margin-bottom: clamp(0.5rem, 2vw, 1.5rem);
    transition: color 0.25s ease, transform 0.25s ease;
}

.paste-placeholder p {
    color: var(--text);
    margin-bottom: clamp(1.2rem, 2vw, 1.5rem);
    transition: color 0.25s ease;
    font-weight: 600;
}

.paste-hints {
    display: flex;
    gap: clamp(1rem, 3vw, 1.5rem);
    justify-content: center;
    color: var(--text-muted);
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
}

.paste-hints span,
.paste-hints button {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
}

.paste-hints button {
    background: none;
    border: none;
    padding: 0;
    color: var(--text-muted);
    font-weight: 400;
}

.paste-hints button.btn:hover {
    background-image: none;
}

.paste-hints i {
    font-size: clamp(0.85rem, 2vw, 0.9rem);
}

.paste-area .btn-save {
    position: absolute;
    bottom: 1.5rem;
    left: 50%;
    transform: translateX(-50%);
    width: fit-content;
    z-index: 10;
    font-size: clamp(1rem, 2.5vw, 1.1rem);
    padding: 0.85rem 2rem;
}

.paste-area .btn-save:not(:disabled) {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    animation: btn-save-pulse 1.5s ease-in-out infinite;
}

@keyframes btn-save-pulse {
    0% {
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 80%, transparent);
    }
    70% {
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 12px color-mix(in srgb, var(--accent) 0%, transparent);
    }
    100% {
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
    }
}

.paste-area textarea {
    view-transition-name: log;
    flex: 1;
    width: 100%;
    background: transparent;
    border: none;
    outline: none;
    resize: none;
    padding: clamp(.5rem, 3vw, 1.2rem);
    font-family: var(--font-mono), monospace;
    font-size: clamp(0.75rem, 2vw, 0.9rem);
    color: var(--text);
    position: relative;
}

.paste-error {
    display: none;
    position: absolute;
    top: clamp(1rem, 2.5vw, 1.5rem);
    right: clamp(1rem, 2.5vw, 1.5rem);
    color: var(--error);
    font-weight: 600;
    font-size: clamp(0.85rem, 2vw, 0.9rem);
    padding: clamp(0.7rem, 2vw, 0.8rem) clamp(1rem, 2.5vw, 1.25rem);
    background-color: var(--error-bg);
    border: 1px solid var(--error-border);
    border-radius: 8px;
    z-index: 1000;
    animation: error-slide-in 0.3s ease-out;
}

.paste-error.show {
    display: block;
}

@keyframes error-slide-in {
    from {
        transform: translateX(100%);
        opacity: 0;
    }
    to {
        transform: translateX(0);
        opacity: 1;
    }
}

/** Log Page Layout **/

.log-body main {
    flex: 0 0 auto;
    border-radius: var(--radius) var(--radius) 0 0;
}

.log-container {
    max-width: var(--max-width-content);
    min-width: var(--max-width-content);
    margin: 0 auto;
    background-color: var(--bg-surface);
    position: relative;
    z-index: 1;
    transition: max-width .25s ease, min-width .25s ease;
}

.log-footer {
    max-width: var(--max-width-content);
    width: 100%;
    margin: 0 auto;
    padding: 0 var(--page-padding);
    background-color: var(--bg-surface);
    border-radius: 0 0 var(--radius) var(--radius);
    position: relative;
    z-index: 1;
    transition: max-width .25s ease;
}

/** Log Header **/

.log-header {
    padding: clamp(1rem, 3vw, 1.5rem) var(--page-padding);
    border-bottom: 1px solid var(--border);
}

.log-header-inner {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    flex-wrap: wrap;
    gap: 1rem;
}

.log-header .left {
    flex: 1 1 300px;
    min-width: 0;
}

.log-header .right {
    flex-shrink: 0;
}

.log-header .log-title h1 {
    font-size: clamp(1.1rem, 3vw, 1.25rem);
    font-weight: 600;
    color: var(--text);
    display: flex;
    align-items: center;
    gap: 0.5rem;
    line-height: 1.3;
    flex-wrap: wrap;
}

.log-header .log-title h1 i {
    color: var(--accent);
}

.log-header .log-title {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    flex-wrap: wrap;
}

.log-header .log-title-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.log-header .log-url-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.4rem, 1.5vw, 0.5rem);
    background-color: var(--surface);
    border: 1px solid var(--border);
    border-radius: 6px;
    font-size: clamp(0.7rem, 1.8vw, 0.75rem);
    color: var(--text-muted);
    font-family: var(--font-mono), monospace;
    line-height: 1;
    transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
    vertical-align: middle;
    cursor: pointer;
}

.log-header .log-url-btn:hover {
    border-color: var(--accent-border);
    background-color: var(--accent-bg);
    color: var(--text);
}

.log-header .log-url-btn i {
    font-size: 0.85em;
    opacity: 0.5;
    color: var(--accent);
}

.log-info-rows {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-top: 1rem;
}

.log-info-row {
    padding: clamp(0.4rem, 1.5vw, 0.5rem) clamp(0.6rem, 2vw, 0.75rem);
    background-color: var(--surface);
    border-radius: 6px;
}

.info-row-header {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: clamp(0.7rem, 1.8vw, 0.75rem);
    font-weight: 600;
    color: var(--text-muted);
    letter-spacing: 0.03em;
    padding-right: clamp(0.6rem, 2vw, 0.75rem);
    border-right: 1px solid var(--border);
}

.info-row-header i {
    font-size: 0.7rem;
    opacity: 0.8;
}

.info-row-items {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
}

.info-item {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
    color: var(--text-muted);
}

.info-label {
    font-weight: 500;
}

.info-value {
    color: var(--text);
    font-weight: 500;
    font-family: var(--font-mono), monospace;
}

.log-header .details {
    display: flex;
    align-items: center;
}

.log-header .log-info-actions {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
    align-items: center;
}

/** Problems Panel **/

.problems-panel-container {
    border-top: 1px solid var(--border);
    padding-top: clamp(0.75rem, 2vw, 1rem);
    margin-top: clamp(0.75rem, 2vw, 1rem);
}

.problems-panel {
    overflow: hidden;
    border: 1px solid var(--border);
    background-color: var(--surface);
    border-radius: 8px;
}

.problems-header {
    display: flex;
    align-items: center;
    gap: clamp(0.5rem, 1.5vw, 0.6rem);
    padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem);
    background-color: var(--surface);
    border-bottom: 1px solid var(--border);
}

.problems-count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: clamp(1.25rem, 2.5vw, 1.4rem);
    height: clamp(1.25rem, 2.5vw, 1.4rem);
    background-color: var(--accent);
    color: var(--bg);
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
    font-weight: 600;
    border-radius: 4px;
}

.problems-title {
    font-size: clamp(0.9rem, 2vw, 1rem);
    font-weight: 600;
    color: var(--text);
}

.problems-list {
    display: flex;
    flex-direction: column;
}

.problem-item {
    display: flex;
    flex-direction: column;
    gap: clamp(0.4rem, 1vw, 0.5rem);
    padding: clamp(0.75rem, 2vw, 1rem) clamp(0.85rem, 2.5vw, 1rem);
    border-bottom: 1px solid var(--border);
}

.problem-item:last-child {
    border-bottom: none;
}

.problem-entry {
    display: flex;
    border-radius: 5px;
    overflow: hidden;
    font-size: clamp(0.85rem, 2vw, 0.9rem);
    background: var(--error-bg);
    border: 1px solid var(--error-border);
    text-decoration: none;
    transition: border-color 0.15s ease;
}

.problem-entry:hover {
    border-color: var(--error);
}

.problem-label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem);
    font-weight: 600;
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
    white-space: nowrap;
    background-color: var(--error);
    color: #fff;
}

.problem-text {
    display: flex;
    align-items: center;
    padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem);
    color: var(--text);
    font-weight: 500;
    flex: 1;
    word-break: break-word;
}

.problem-line {
    display: inline-flex;
    align-items: center;
    margin: clamp(0.25rem, 0.8vw, 0.35rem) clamp(0.55rem, 1.5vw, 0.65rem);
    padding: 0.2em 0.5em;
    font-family: var(--font-mono), monospace;
    font-size: clamp(0.7rem, 1.6vw, 0.75rem);
    font-weight: 500;
    color: var(--text-muted);
    background-color: var(--surface);
    border: 1px solid var(--border);
    border-radius: 4px;
    white-space: nowrap;
}

.problem-solutions {
    display: flex;
    flex-direction: column;
    gap: clamp(0.25rem, 0.5vw, 0.3rem);
    padding: clamp(0.4rem, 1vw, 0.5rem) clamp(0.55rem, 1.5vw, 0.65rem);
    background-color: var(--surface);
    border-radius: 5px;
}

.problem-solutions-label {
    font-size: clamp(0.75rem, 1.8vw, 0.8rem);
    font-weight: 600;
    color: var(--text-muted);
}

.problem-solution {
    display: flex;
    align-items: baseline;
    gap: clamp(0.4rem, 1vw, 0.5rem);
    font-size: clamp(0.8rem, 1.8vw, 0.85rem);
}

.problem-solution i {
    color: var(--accent);
    font-size: 0.85em;
}

.problem-solution span {
    color: var(--text);
}

/** Log Viewer **/

.log {
    view-transition-name: log;
    padding: 0;
    border-bottom: 1px solid var(--border);
    background-color: var(--bg-elevated);
    position: relative;
    flex: 1;
}

.setting-floating-scrollbar .floating-scrollbar-container {
    display: flex;
}

.floating-scrollbar-container {
    --floating-scrollbar-width: 0;
    --floating-scrollbar-content-width: 0;

    position: fixed;
    display: none;
    justify-content: center;
    bottom: 0;
    width: 100%;
    z-index: 10;
}

.floating-scrollbar {
    overflow-x: scroll;
    width: var(--floating-scrollbar-width);
}

.floating-scrollbar-content {
    width: var(--floating-scrollbar-content-width);
    height: var(--scrollbar-height);
}

.log-inner {
    overflow-y: hidden;
    font-family: var(--font-mono), monospace;
    font-size: clamp(0.75rem, 2vw, 0.9rem);
    line-height: 1.6;
    overflow-x: auto;
    position: relative;
    padding: 0.5rem 0 0;
    display: grid;
    grid-template-columns: auto 1fr;
    contain: layout style paint;
    will-change: scroll-position;
}

.log-inner .entry {
    display: contents;
    width: 100%;
}

.log-inner .entry.entry-error .line-content,
.log-inner .entry.entry-error .line-number-container{
    background-color: var(--error-bg);
}

.log-inner .line-number-container {
    min-width: 2.75rem;
    padding: 0 0.4rem;
    border-right: 1px solid var(--border);
    text-align: right;
    user-select: none;
}


.log-inner .line-number {
    padding: clamp(0.08rem, 1vw, 0.1rem) clamp(0.2rem, 1.5vw, 0.25rem);
    color: var(--text-muted);
    font-weight: 500;
    font-size: clamp(0.65rem, 1.8vw, 0.8rem);
    border-radius: 4px;
}

.log-inner .entry.line-active .line-number {
    background-color: var(--accent);
    color: var(--bg);
    font-weight: 600;
}


.log-inner .entry.line-active .line-number-container,
.log-inner .entry.line-active .line-content {
    background-color: color-mix(in srgb, var(--accent) 15%, var(--bg) 85%);
}

.log-inner .entry.entry-error.line-active .line-number {
    background-color: var(--error);
    color: #fff;
}

.log-inner .entry.entry-error.line-active .line-number-container,
.log-inner .entry.entry-error.line-active .line-content {
    background-color: color-mix(in srgb, var(--error) 25%, var(--bg) 75%);
}

.log-inner .line-content {
    padding-left: clamp(0.4rem, 1vw, 0.9rem);
    padding-right: clamp(0.4rem, 2vw, 0.6rem);
    word-break: break-word;
    overflow-wrap: anywhere;
    color: var(--text);
}

/* Firefox fallback: use table layout instead of grid */
@supports (-moz-appearance: none) {
    :root {
        --browser: 'firefox';
    }
    .log-inner {
        display: table;
        table-layout: fixed;
        width: 100%;
    }

    .log-inner .entry,
    .log-inner .collapsed-lines {
        display: table-row;
    }

    .log-inner .line-number-container,
    .log-inner .collapsed-lines > div:first-child {
        display: table-cell;
        width: 3.6rem;
    }

    @media (max-width: 600px) {
        .log-inner .line-number-container {
            width: 2.7rem;
        }
    }

    .log-inner .line-content,
    .log-inner .collapsed-lines-count {
        display: table-cell;
    }

    .log-inner .collapsed-lines-count {
        text-align: center;
        vertical-align: middle;
    }

    body.setting-no-wrap .log {
        overflow-x: auto;
    }

    body.setting-no-wrap .log-inner {
        table-layout: auto;
    }
}

.collapsed-lines {
    display: contents;
    cursor: pointer;
}

.collapsed-lines > div:first-child {
    background-color: var(--surface);
    border-right: 1px solid var(--border);
}

.collapsed-lines-count {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    padding: 0.6rem 1.25rem;
    background-color: var(--surface);
    color: var(--text);
    font-size: clamp(0.85rem, 2vw, 0.9rem);
    font-family: var(--font-mono), monospace;
    font-weight: 500;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.collapsed-lines:hover .collapsed-lines-count {
    background-color: var(--accent-bg);
    color: var(--accent);
}

.collapsed-lines-count i {
    font-size: 0.75rem;
    color: var(--text-muted);
    transition: color 0.15s ease;
}

.collapsed-lines:hover .collapsed-lines-count i {
    color: var(--accent);
}

.log-inner .level {
    display: block;
    white-space: pre-wrap;
    tab-size: 4;
    width: 100%;
}

.log-inner .level-prefix {
    font-weight: 500;
    opacity: 0.9;
}

/** Log Level Styles **/

.level {
    white-space: pre-wrap;
    tab-size: 4;
    word-break: normal;
}

.level-prefix {
    font-weight: bold;
}

.level-info {
    color: var(--text);
}

.level-title {
    font-weight: bold;
    color: var(--bg);
    background-color: var(--accent);
    padding: 0 8px;
    border-radius: 2px;
}

.level-info .level-prefix,
.level-notice .level-prefix,
.level-debug .level-prefix {
    color: var(--accent);
}

.level-warning {
    color: #FF6625;
}

.level-error,
.level-critical,
.level-emergency,
.level-stacktrace {
    color: var(--error);
}

.level-comment {
    color: #A4A4A4;
}

/** Minecraft Format Colors **/

.format-black {
    color: #000;
}

.format-darkblue {
    color: #0000AA;
}

.format-darkgreen {
    color: #00AA00;
}

.format-darkaqua {
    color: #00AAAA;
}

.format-darkred {
    color: #AA0000;
}

.format-darkpurple {
    color: #AA00AA;
}

.format-gold {
    color: #FFAA00;
}

.format-gray {
    color: #AAAAAA;
}

.format-darkgray {
    color: #555555;
}

.format-blue {
    color: #5555FF;
}

.format-green {
    color: #55FF55;
}

.format-aqua {
    color: #55FFFF;
}

.format-red {
    color: #FF5555;
}

.format-lightpurple {
    color: #FF55FF;
}

.format-yellow {
    color: #FFFF55;
}

.format-white {
    color: #FFFFFF;
}

.format-reset {
    color: #FFFFFF;
    font-weight: normal;
    text-decoration: none;
    font-style: normal;
    display: inline-block;
}

.format-bold {
    font-weight: bold;
}

.format-underline {
    text-decoration: underline;
}

.format-italic {
    font-style: italic;
}

.format-strike {
    text-decoration: line-through;
}

/** Log Content Styles **/

.multiline {
    padding-left: 64px;
}

.highlight-error {
    background: var(--error);
    color: #fff;
    padding: 0 3px;
    border-radius: 2px;
    font-weight: bold;
    display: inline-block;
}

.highlight-warning {
    background: #FF6625;
    color: var(--text);
    padding: 0 3px;
    border-radius: 2px;
    font-weight: bold;
    display: inline-block;
}

.entry {
    overflow-wrap: anywhere;
}

@media (max-width: 800px) {
    .multiline {
        padding-left: 0;
    }

    .problem-line {
        display: none;
    }
}

/** Log bottom **/

.log-bottom {
    display: flex;
    justify-content: space-between;
    
Download .txt
gitextract_kqffgv0b/

├── .dockerignore
├── .github/
│   └── workflows/
│       └── publish.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── build.php
├── composer.json
├── dev/
│   ├── compose.yaml
│   └── dev.ini
├── docker/
│   ├── Caddyfile
│   ├── compose.production.yaml
│   └── mclogs.ini
├── example.config.json
├── src/
│   ├── Api/
│   │   ├── Action/
│   │   │   ├── AnalyseLogAction.php
│   │   │   ├── ApiAction.php
│   │   │   ├── BulkDeleteLogsAction.php
│   │   │   ├── CreateLogAction.php
│   │   │   ├── DeleteLogAction.php
│   │   │   ├── EmptyAction.php
│   │   │   ├── EndpointNotFoundAction.php
│   │   │   ├── GetFiltersAction.php
│   │   │   ├── GetLimitsAction.php
│   │   │   ├── LogInfoAction.php
│   │   │   ├── LogInsightsAction.php
│   │   │   ├── RateLimitErrorAction.php
│   │   │   └── RawLogAction.php
│   │   ├── ApiRouter.php
│   │   ├── ContentParser.php
│   │   ├── LogContentParser.php
│   │   └── Response/
│   │       ├── ApiError.php
│   │       ├── ApiResponse.php
│   │       ├── CodexLogResponse.php
│   │       ├── FiltersResponse.php
│   │       ├── LimitsResponse.php
│   │       ├── LogResponse.php
│   │       ├── MultiResponse.php
│   │       └── RawLogResponse.php
│   ├── Cache/
│   │   └── CacheEntry.php
│   ├── Config/
│   │   ├── Config.php
│   │   └── ConfigKey.php
│   ├── Data/
│   │   ├── Deobfuscator.php
│   │   ├── MetadataEntry.php
│   │   └── Token.php
│   ├── Detective.php
│   ├── Filter/
│   │   ├── AccessTokenFilter.php
│   │   ├── Filter.php
│   │   ├── FilterType.php
│   │   ├── IPv4Filter.php
│   │   ├── IPv6Filter.php
│   │   ├── LimitBytesFilter.php
│   │   ├── LimitLinesFilter.php
│   │   ├── Pattern/
│   │   │   ├── Modifier.php
│   │   │   ├── Pattern.php
│   │   │   └── PatternWithReplacement.php
│   │   ├── RegexFilter.php
│   │   ├── TrimFilter.php
│   │   └── UsernameFilter.php
│   ├── Frontend/
│   │   ├── Action/
│   │   │   ├── ApiDocsAction.php
│   │   │   ├── CreateLogAction.php
│   │   │   ├── DeleteLogAction.php
│   │   │   ├── FaviconAction.php
│   │   │   ├── NotFoundAction.php
│   │   │   ├── StartAction.php
│   │   │   └── ViewLogAction.php
│   │   ├── Assets/
│   │   │   ├── Asset.php
│   │   │   ├── AssetLoader.php
│   │   │   └── AssetType.php
│   │   ├── Cookie/
│   │   │   ├── Cookie.php
│   │   │   ├── SettingsCookie.php
│   │   │   └── TokenCookie.php
│   │   ├── FrontendRouter.php
│   │   └── Settings/
│   │       ├── Setting.php
│   │       └── Settings.php
│   ├── Id.php
│   ├── Log.php
│   ├── Printer/
│   │   ├── FormatModification.php
│   │   └── Printer.php
│   ├── Router/
│   │   ├── Action.php
│   │   ├── Method.php
│   │   ├── Route.php
│   │   └── Router.php
│   ├── Storage/
│   │   └── MongoDBClient.php
│   └── Util/
│       ├── Singleton.php
│       ├── TimeInterval.php
│       └── URL.php
├── web/
│   ├── frontend/
│   │   ├── 404.php
│   │   ├── api-docs.php
│   │   ├── log.php
│   │   ├── parts/
│   │   │   ├── favicon.php
│   │   │   ├── footer.php
│   │   │   ├── head.php
│   │   │   └── header.php
│   │   └── start.php
│   └── public/
│       ├── css/
│       │   └── mclogs.css
│       └── js/
│           ├── log.js
│           └── start.js
└── worker.php
Download .txt
SYMBOL INDEX (357 symbols across 72 files)

FILE: src/Api/Action/AnalyseLogAction.php
  class AnalyseLogAction (line 11) | class AnalyseLogAction extends ApiAction
    method runApi (line 13) | public function runApi(): ApiResponse

FILE: src/Api/Action/ApiAction.php
  class ApiAction (line 9) | abstract class ApiAction extends Action
    method runApi (line 11) | abstract protected function runApi(): ApiResponse;
    method getAllowedOrigin (line 13) | protected function getAllowedOrigin(): string
    method shouldAllowCredentials (line 18) | protected function shouldAllowCredentials(): bool
    method run (line 23) | public function run(): bool

FILE: src/Api/Action/BulkDeleteLogsAction.php
  class BulkDeleteLogsAction (line 13) | class BulkDeleteLogsAction extends ApiAction
    method runApi (line 20) | protected function runApi(): ApiResponse

FILE: src/Api/Action/CreateLogAction.php
  class CreateLogAction (line 12) | class CreateLogAction extends ApiAction
    method runApi (line 17) | public function runApi(): ApiResponse

FILE: src/Api/Action/DeleteLogAction.php
  class DeleteLogAction (line 11) | class DeleteLogAction extends ApiAction
    method getRequestToken (line 13) | protected function getRequestToken(): ?string
    method runApi (line 26) | protected function runApi(): ApiResponse
    method handleDeletedLog (line 56) | protected function handleDeletedLog(Log $log): void

FILE: src/Api/Action/EmptyAction.php
  class EmptyAction (line 7) | class EmptyAction extends Action
    method run (line 9) | public function run(): bool

FILE: src/Api/Action/EndpointNotFoundAction.php
  class EndpointNotFoundAction (line 8) | class EndpointNotFoundAction extends ApiAction
    method runApi (line 10) | protected function runApi(): ApiResponse

FILE: src/Api/Action/GetFiltersAction.php
  class GetFiltersAction (line 8) | class GetFiltersAction extends ApiAction
    method runApi (line 10) | protected function runApi(): ApiResponse

FILE: src/Api/Action/GetLimitsAction.php
  class GetLimitsAction (line 8) | class GetLimitsAction extends ApiAction
    method runApi (line 10) | protected function runApi(): ApiResponse

FILE: src/Api/Action/LogInfoAction.php
  class LogInfoAction (line 12) | class LogInfoAction extends ApiAction
    method runApi (line 17) | protected function runApi(): ApiResponse

FILE: src/Api/Action/LogInsightsAction.php
  class LogInsightsAction (line 12) | class LogInsightsAction extends ApiAction
    method runApi (line 17) | protected function runApi(): ApiResponse

FILE: src/Api/Action/RateLimitErrorAction.php
  class RateLimitErrorAction (line 8) | class RateLimitErrorAction extends ApiAction
    method runApi (line 10) | protected function runApi(): ApiResponse

FILE: src/Api/Action/RawLogAction.php
  class RawLogAction (line 12) | class RawLogAction extends ApiAction
    method runApi (line 17) | protected function runApi(): ApiResponse

FILE: src/Api/ApiRouter.php
  class ApiRouter (line 10) | class ApiRouter extends Router
    method __construct (line 12) | protected function __construct()

FILE: src/Api/ContentParser.php
  class ContentParser (line 12) | class ContentParser
    method getSupportedEncodings (line 20) | public static function getSupportedEncodings(): array
    method getContent (line 30) | public function getContent(): array|ApiError

FILE: src/Api/LogContentParser.php
  class LogContentParser (line 7) | class LogContentParser extends ContentParser
    method getContent (line 12) | public function getContent(): array|ApiError

FILE: src/Api/Response/ApiError.php
  class ApiError (line 5) | class ApiError extends ApiResponse
    method __construct (line 9) | public function __construct(
    method jsonSerialize (line 17) | public function jsonSerialize(): array

FILE: src/Api/Response/ApiResponse.php
  class ApiResponse (line 5) | class ApiResponse implements \JsonSerializable
    method jsonSerialize (line 10) | public function jsonSerialize(): array
    method setHttpCode (line 21) | public function setHttpCode(int $httpCode): static
    method getHttpCode (line 30) | public function getHttpCode(): int
    method setSuccess (line 39) | public function setSuccess(bool $success): static
    method isSuccess (line 48) | public function isSuccess(): bool
    method output (line 56) | public function output(): static

FILE: src/Api/Response/CodexLogResponse.php
  class CodexLogResponse (line 7) | class CodexLogResponse extends ApiResponse
    method __construct (line 9) | public function __construct(protected LogInterface $codexLog)
    method jsonSerialize (line 13) | public function jsonSerialize(): array

FILE: src/Api/Response/FiltersResponse.php
  class FiltersResponse (line 7) | class FiltersResponse extends ApiResponse
    method jsonSerialize (line 9) | public function jsonSerialize(): array

FILE: src/Api/Response/LimitsResponse.php
  class LimitsResponse (line 8) | class LimitsResponse extends ApiResponse
    method jsonSerialize (line 10) | public function jsonSerialize(): array

FILE: src/Api/Response/LogResponse.php
  class LogResponse (line 8) | class LogResponse extends ApiResponse
    method __construct (line 10) | public function __construct(
    method loadFromGet (line 20) | public function loadFromGet(): static
    method jsonSerialize (line 34) | public function jsonSerialize(): array

FILE: src/Api/Response/MultiResponse.php
  class MultiResponse (line 5) | class MultiResponse extends ApiResponse
    method addResponse (line 19) | public function addResponse(string $id, ApiResponse $response): static
    method jsonSerialize (line 25) | public function jsonSerialize(): array

FILE: src/Api/Response/RawLogResponse.php
  class RawLogResponse (line 7) | class RawLogResponse extends ApiResponse
    method __construct (line 9) | public function __construct(
    method output (line 14) | public function output(): static

FILE: src/Cache/CacheEntry.php
  class CacheEntry (line 8) | class CacheEntry
    method __construct (line 10) | public function __construct(protected string $key)
    method get (line 17) | public function get(): ?string
    method set (line 30) | public function set(string $data, int $ttl = 24 * 60 * 60): static

FILE: src/Config/Config.php
  class Config (line 8) | class Config
    method __construct (line 14) | protected function __construct()
    method get (line 32) | public function get(ConfigKey $key): mixed
    method getName (line 55) | public function getName(): string
    method getJsonValue (line 67) | protected function getJsonValue(array $path, ?array $data = null): mixed

FILE: src/Config/ConfigKey.php
  method getDefaultValue (line 35) | public function getDefaultValue(): string|int|null
  method getEnvironmentVariable (line 67) | public function getEnvironmentVariable(): string
  method getJSONPath (line 75) | public function getJSONPath(): array

FILE: src/Data/Deobfuscator.php
  class Deobfuscator (line 26) | class Deobfuscator
    method __construct (line 28) | public function __construct(protected LogInterface $codexLog)
    method deobfuscate (line 32) | public function deobfuscate(): ?string
    method getObfuscationMap (line 75) | protected function getObfuscationMap($version): ?ObfuscationMap

FILE: src/Data/MetadataEntry.php
  class MetadataEntry (line 8) | class MetadataEntry implements \JsonSerializable, Serializable
    method allFromArray (line 24) | public static function allFromArray(?iterable $dataArray): array
    method fromArray (line 52) | public static function fromArray(array $data): ?MetadataEntry
    method fromObject (line 65) | public static function fromObject(object $data): ?MetadataEntry
    method jsonSerialize (line 75) | public function jsonSerialize(): array
    method bsonSerialize (line 85) | public function bsonSerialize(): array
    method getKey (line 90) | public function getKey(): ?string
    method setKey (line 95) | public function setKey(?string $key): static
    method getValue (line 104) | public function getValue(): mixed
    method setValue (line 113) | public function setValue(mixed $value): static
    method getLabel (line 138) | public function getLabel(): ?string
    method getDisplayLabel (line 143) | public function getDisplayLabel(): ?string
    method getDisplayValue (line 148) | public function getDisplayValue(): string
    method setLabel (line 153) | public function setLabel(?string $label): static
    method isVisible (line 162) | public function isVisible(): bool
    method setVisible (line 167) | public function setVisible(bool $visible): static
    method isValid (line 173) | public function isValid(): bool
    method setFromArray (line 182) | public function setFromArray(array $data): static

FILE: src/Data/Token.php
  class Token (line 7) | class Token implements \JsonSerializable
    method __construct (line 9) | public function __construct(protected ?string $value = null)
    method matches (line 20) | public function matches(string $token): bool
    method jsonSerialize (line 25) | public function jsonSerialize(): string
    method generate (line 33) | protected function generate(): void
    method get (line 38) | public function get(): ?string

FILE: src/Detective.php
  class Detective (line 7) | class Detective extends \Aternos\Codex\Detective\Detective
    method __construct (line 11) | public function __construct()

FILE: src/Filter/AccessTokenFilter.php
  class AccessTokenFilter (line 7) | class AccessTokenFilter extends RegexFilter
    method getPatterns (line 12) | protected function getPatterns(): array

FILE: src/Filter/Filter.php
  class Filter (line 5) | abstract class Filter implements \JsonSerializable
    method getAll (line 17) | public static function getAll(): array
    method filterAll (line 39) | public static function filterAll(string $data): string
    method getType (line 50) | abstract public function getType(): FilterType;
    method getData (line 55) | abstract public function getData(): mixed;
    method jsonSerialize (line 60) | public function jsonSerialize(): array
    method filter (line 74) | abstract public function filter(string $data): string;

FILE: src/Filter/IPv4Filter.php
  class IPv4Filter (line 8) | class IPv4Filter extends RegexFilter
    method getPatterns (line 13) | protected function getPatterns(): array
    method getExemptions (line 23) | protected function getExemptions(): array

FILE: src/Filter/IPv6Filter.php
  class IPv6Filter (line 8) | class IPv6Filter extends RegexFilter
    method getPatterns (line 13) | protected function getPatterns(): array
    method getExemptions (line 24) | protected function getExemptions(): array

FILE: src/Filter/LimitBytesFilter.php
  class LimitBytesFilter (line 8) | class LimitBytesFilter extends Filter
    method filter (line 18) | public function filter(string $data): string
    method getType (line 27) | public function getType(): FilterType
    method getData (line 35) | public function getData(): array

FILE: src/Filter/LimitLinesFilter.php
  class LimitLinesFilter (line 8) | class LimitLinesFilter extends Filter
    method filter (line 18) | public function filter(string $data): string
    method getType (line 27) | public function getType(): FilterType
    method getData (line 35) | public function getData(): array

FILE: src/Filter/Pattern/Modifier.php
  method jsonSerialize (line 12) | public function jsonSerialize(): string

FILE: src/Filter/Pattern/Pattern.php
  class Pattern (line 5) | class Pattern implements \JsonSerializable
    method __construct (line 13) | public function __construct(
    method get (line 25) | public function get(): string
    method getPattern (line 34) | public function getPattern(): string
    method getModifiers (line 39) | public function getModifiers(): array
    method jsonSerialize (line 44) | public function jsonSerialize(): array

FILE: src/Filter/Pattern/PatternWithReplacement.php
  class PatternWithReplacement (line 5) | class PatternWithReplacement extends Pattern
    method __construct (line 7) | public function __construct(string $pattern, protected string $replace...
    method getReplacement (line 12) | public function getReplacement(): string
    method jsonSerialize (line 17) | public function jsonSerialize(): array

FILE: src/Filter/RegexFilter.php
  class RegexFilter (line 8) | abstract class RegexFilter extends Filter
    method getPatterns (line 13) | abstract protected function getPatterns(): array;
    method getExemptions (line 18) | protected function getExemptions(): array
    method getType (line 26) | public function getType(): FilterType
    method getData (line 34) | public function getData(): array
    method filter (line 45) | public function filter(string $data): string

FILE: src/Filter/TrimFilter.php
  class TrimFilter (line 5) | class TrimFilter extends Filter
    method filter (line 15) | public function filter(string $data): string
    method getType (line 20) | public function getType(): FilterType
    method getData (line 25) | public function getData(): object

FILE: src/Filter/UsernameFilter.php
  class UsernameFilter (line 7) | class UsernameFilter extends RegexFilter
    method getPatterns (line 12) | protected function getPatterns(): array

FILE: src/Frontend/Action/ApiDocsAction.php
  class ApiDocsAction (line 7) | class ApiDocsAction extends Action
    method run (line 9) | public function run(): bool

FILE: src/Frontend/Action/CreateLogAction.php
  class CreateLogAction (line 7) | class CreateLogAction extends \Aternos\Mclogs\Api\Action\CreateLogAction
    method getAllowedOrigin (line 12) | protected function getAllowedOrigin(): string
    method shouldAllowCredentials (line 17) | protected function shouldAllowCredentials(): bool

FILE: src/Frontend/Action/DeleteLogAction.php
  class DeleteLogAction (line 9) | class DeleteLogAction extends \Aternos\Mclogs\Api\Action\DeleteLogAction
    method getAllowedOrigin (line 11) | protected function getAllowedOrigin(): string
    method shouldAllowCredentials (line 16) | protected function shouldAllowCredentials(): bool
    method getRequestToken (line 21) | protected function getRequestToken(): ?string
    method handleDeletedLog (line 26) | protected function handleDeletedLog(Log $log): void

FILE: src/Frontend/Action/FaviconAction.php
  class FaviconAction (line 7) | class FaviconAction extends Action
    method run (line 9) | public function run(): bool

FILE: src/Frontend/Action/NotFoundAction.php
  class NotFoundAction (line 7) | class NotFoundAction extends Action
    method run (line 9) | public function run(): bool

FILE: src/Frontend/Action/StartAction.php
  class StartAction (line 7) | class StartAction extends Action
    method run (line 9) | public function run(): bool

FILE: src/Frontend/Action/ViewLogAction.php
  class ViewLogAction (line 10) | class ViewLogAction extends Action
    method run (line 12) | public function run(): bool

FILE: src/Frontend/Assets/Asset.php
  class Asset (line 8) | class Asset implements \JsonSerializable
    method fromObject (line 16) | public static function fromObject(object $data): ?static
    method __construct (line 30) | public function __construct(
    method getPath (line 38) | public function getPath(): string
    method getPathWithVersion (line 43) | public function getPathWithVersion(): string
    method getAbsoluteBasePath (line 48) | protected function getAbsoluteBasePath(): string
    method getAbsolutePath (line 53) | protected function getAbsolutePath(): string
    method buildHash (line 58) | protected function buildHash(): string
    method getHash (line 63) | protected function getHash(): string
    method getBase64Hash (line 71) | protected function getBase64Hash(): string
    method jsonSerialize (line 76) | public function jsonSerialize(): array
    method getType (line 85) | public function getType(): AssetType
    method getHTML (line 90) | public function getHTML(): string
    method getIntegrityAttribute (line 98) | protected function getIntegrityAttribute(): string

FILE: src/Frontend/Assets/AssetLoader.php
  class AssetLoader (line 7) | class AssetLoader
    method __construct (line 18) | protected function __construct()
    method getHTML (line 28) | public function getHTML(AssetType $assetType, string $path): string
    method getAsset (line 38) | protected function getAsset(AssetType $assetType, string $path): Asset
    method findCachedAsset (line 52) | protected function findCachedAsset(AssetType $assetType, string $path)...
    method loadCache (line 62) | protected function loadCache(): void
    method writeCache (line 90) | public function writeCache(): void

FILE: src/Frontend/Cookie/Cookie.php
  class Cookie (line 7) | abstract class Cookie
    method getKey (line 14) | abstract protected function getKey(): string;
    method getDomain (line 19) | protected function getDomain(): string
    method getMaxAge (line 27) | protected function getMaxAge(): ?int
    method getPath (line 35) | protected function getPath(): string
    method isSecure (line 43) | protected function isSecure(): bool
    method isHttpOnly (line 51) | protected function isHttpOnly(): bool
    method getSameSite (line 59) | protected function getSameSite(): string
    method __construct (line 64) | public function __construct()
    method set (line 73) | public function set(string $value): bool
    method delete (line 100) | public function delete(): bool
    method getValue (line 127) | public function getValue(): ?string
    method exists (line 135) | public function exists(): bool

FILE: src/Frontend/Cookie/SettingsCookie.php
  class SettingsCookie (line 5) | class SettingsCookie extends Cookie
    method getKey (line 10) | protected function getKey(): string

FILE: src/Frontend/Cookie/TokenCookie.php
  class TokenCookie (line 9) | class TokenCookie extends Cookie
    method setLog (line 15) | public function setLog(Log $log): static
    method getKey (line 24) | protected function getKey(): string
    method __construct (line 32) | public function __construct(protected ?Log $log = null)
    method getPath (line 40) | protected function getPath(): string
    method getMaxAge (line 48) | protected function getMaxAge(): ?int

FILE: src/Frontend/FrontendRouter.php
  class FrontendRouter (line 9) | class FrontendRouter extends Router
    method __construct (line 11) | protected function __construct()

FILE: src/Frontend/Settings/Setting.php
  method getLabel (line 16) | function getLabel(): string
  method getBodyClass (line 29) | function getBodyClass(): ?string

FILE: src/Frontend/Settings/Settings.php
  class Settings (line 7) | class Settings
    method __construct (line 14) | public function __construct()
    method get (line 29) | public function get(Setting $key): bool
    method getBodyClasses (line 41) | public function getBodyClasses(): array
    method getBodyClassesString (line 58) | public function getBodyClassesString(): string

FILE: src/Id.php
  class Id (line 7) | class Id implements \JsonSerializable
    method __construct (line 15) | public function __construct(protected ?string $id = null)
    method generate (line 27) | protected function generate(): string
    method get (line 43) | public function get(): string
    method __toString (line 51) | public function __toString(): string
    method jsonSerialize (line 56) | public function jsonSerialize(): string

FILE: src/Log.php
  class Log (line 22) | class Log
    method find (line 46) | public static function find(Id $id, bool $includeContent = true): ?static
    method findAll (line 61) | public static function findAll(array $ids, bool $includeContent = true...
    method fromObject (line 78) | protected static function fromObject(Id $id, object $data): static
    method create (line 97) | public static function create(string $content, array $metadata = [], ?...
    method __construct (line 109) | public function __construct(protected ?Id $id = null)
    method setToken (line 117) | public function setToken(?Token $token): static
    method setMetadata (line 127) | public function setMetadata(array $metadata): static
    method addMetadata (line 137) | public function addMetadata(MetadataEntry $metadataEntry): static
    method setSource (line 147) | public function setSource(?string $source): static
    method getSource (line 159) | public function getSource(): ?string
    method setCreated (line 168) | public function setCreated(?UTCDateTime $created): static
    method setExpires (line 178) | public function setExpires(?UTCDateTime $expires): static
    method getCreated (line 187) | public function getCreated(): ?UTCDateTime
    method getExpires (line 195) | public function getExpires(): ?UTCDateTime
    method setContent (line 204) | public function setContent(string $content): static
    method getContent (line 210) | public function getContent(): string
    method processAndDeobfuscate (line 215) | protected function processAndDeobfuscate(string $data): void
    method process (line 224) | protected function process($data): void
    method getCodexLog (line 238) | public function getCodexLog(): LogInterface
    method getAnalysis (line 248) | public function getAnalysis(): ?Analysis
    method getPrinter (line 260) | public function getPrinter(): Printer
    method getLinesCount (line 273) | public function getLinesCount(): int
    method getLinesString (line 286) | public function getLinesString(): string
    method getSize (line 295) | public function getSize(): int
    method getErrorsCount (line 305) | public function getErrorsCount(): int
    method hasErrors (line 321) | public function hasErrors(): bool
    method getErrorsString (line 329) | public function getErrorsString(): string
    method generateId (line 335) | protected function generateId(): Id
    method save (line 348) | public function save(string $content): static
    method getExpiryTimestamp (line 372) | protected function getExpiryTimestamp(): UTCDateTime
    method renew (line 384) | public function renew(): bool
    method getURL (line 397) | public function getURL(): Uri
    method getDisplayURL (line 406) | public function getDisplayURL(): string
    method getRawURL (line 415) | public function getRawURL(): Uri
    method getId (line 423) | public function getId(): ?Id
    method getToken (line 431) | public function getToken(): ?Token
    method delete (line 439) | public function delete(): bool
    method getMetadata (line 447) | public function getMetadata(): array
    method getVisibleMetadata (line 455) | public function getVisibleMetadata(): array
    method setTokenCookie (line 465) | public function setTokenCookie(): bool
    method hasValidTokenCookie (line 476) | public function hasValidTokenCookie(): bool
    method getPageTitle (line 489) | public function getPageTitle(): string
    method getPageDescription (line 497) | public function getPageDescription(): string

FILE: src/Printer/FormatModification.php
  class FormatModification (line 10) | class FormatModification extends \Aternos\Codex\Minecraft\Printer\Format...
    method getClasses (line 16) | protected function getClasses(string $format): string

FILE: src/Printer/Printer.php
  class Printer (line 17) | class Printer extends ModifiableDefaultPrinter
    method __construct (line 19) | public function __construct()
    method setId (line 33) | public function setId(Id $id): static
    method printLog (line 42) | protected function printLog(): string
    method printEntry (line 52) | protected function printEntry(?EntryInterface $entry = null): string
    method printLine (line 84) | protected function printLine(LineInterface $line): string

FILE: src/Router/Action.php
  class Action (line 5) | abstract class Action
    method run (line 7) | abstract public function run(): bool;

FILE: src/Router/Method.php
  method getCurrent (line 13) | public static function getCurrent(): self

FILE: src/Router/Route.php
  class Route (line 5) | class Route
    method __construct (line 7) | public function __construct(
    method matches (line 20) | public function matches(Method $method, string $path): bool
    method getMethod (line 31) | public function getMethod(): Method
    method getPattern (line 39) | public function getPattern(): string
    method getAction (line 47) | public function getAction(): Action

FILE: src/Router/Router.php
  class Router (line 8) | class Router
    method register (line 25) | public function register(Method $method, string $pattern, Action $acti...
    method setDefaultAction (line 35) | public function setDefaultAction(Action $defaultAction): static
    method run (line 44) | public function run(): static
    method findRoute (line 61) | protected function findRoute(): ?Route

FILE: src/Storage/MongoDBClient.php
  class MongoDBClient (line 14) | class MongoDBClient
    method getConnectionURL (line 27) | protected function getConnectionURL(): string
    method connect (line 50) | protected function connect(): void
    method ensureIndexes (line 64) | public function ensureIndexes(): void
    method reset (line 76) | public function reset(): void
    method getLogsCollection (line 88) | public function getLogsCollection(): Collection
    method findLog (line 102) | public function findLog(string $id, bool $includeContent = true): ?object
    method findLogs (line 123) | public function findLogs(array $ids, bool $includeContent = true): array
    method deleteLog (line 162) | public function deleteLog(string $id): bool
    method deleteLogs (line 178) | public function deleteLogs(array $ids): int
    method hasLog (line 201) | public function hasLog(string $id): bool
    method setLogExpires (line 211) | public function setLogExpires(string $id, UTCDateTime $expires): bool
    method getCacheCollection (line 226) | public function getCacheCollection(): Collection

FILE: src/Util/Singleton.php
  type Singleton (line 5) | trait Singleton
    method getInstance (line 12) | public static function getInstance(): static
    method __clone (line 27) | protected function __clone()
    method __construct (line 34) | protected function __construct()

FILE: src/Util/TimeInterval.php
  class TimeInterval (line 5) | class TimeInterval
    method formatUnit (line 24) | protected function formatUnit(int $value, string $unit): string
    method format (line 38) | public function format(int $duration, string $separator = ", "): string

FILE: src/Util/URL.php
  class URL (line 7) | class URL
    method clear (line 15) | public static function clear(): void
    method readProtocol (line 25) | protected static function readProtocol(): string
    method getProtocol (line 52) | protected static function getProtocol(): string
    method getBase (line 66) | public static function getBase(): Uri
    method getApi (line 83) | public static function getApi(): Uri
    method getCurrent (line 95) | public static function getCurrent(): Uri
    method isApi (line 109) | public static function isApi(): bool
    method getLastPathPart (line 119) | public static function getLastPathPart(): string

FILE: web/public/js/log.js
  function updateLineNumber (line 9) | function updateLineNumber(id) {
  function scrollToHeight (line 44) | function scrollToHeight(top, smoothScrollLimit = 10000) {
  function toggleErrors (line 56) | function toggleErrors() {
  function collapseAllErrors (line 66) | function collapseAllErrors() {
  function uncollapseAllErrors (line 91) | function uncollapseAllErrors() {
  function handleCollapsedClick (line 96) | function handleCollapsedClick(e) {
  function generateCollapsedLines (line 116) | function generateCollapsedLines(start, end) {
  function handleSettingChange (line 167) | function handleSettingChange(e) {
  function applySetting (line 179) | function applySetting(checkbox) {
  function getCurrentSettings (line 193) | function getCurrentSettings() {
  function saveSettings (line 201) | function saveSettings() {
  function handleCopyButtonClick (line 214) | async function handleCopyButtonClick(e) {
  function handleDeleteButtonClick (line 240) | async function handleDeleteButtonClick() {
  function syncScroll (line 285) | function syncScroll(source, target) {
  function initFloatingScrollbar (line 291) | function initFloatingScrollbar() {
  function updateFloatingScrollbarWidths (line 299) | function updateFloatingScrollbarWidths() {

FILE: web/public/js/start.js
  function sendLog (line 33) | async function sendLog() {
  function applyFilters (line 104) | function applyFilters(text) {
  function applyFilter (line 114) | function applyFilter(text, filter) {
  function pasteFromClipboard (line 145) | async function pasteFromClipboard() {
  function reevaluateContentStatus (line 159) | function reevaluateContentStatus() {
  function showError (line 170) | function showError(message) {
  function clearError (line 176) | function clearError() {
  function handlePasteEvent (line 183) | async function handlePasteEvent(e) {
  function readFile (line 194) | function readFile(file) {
  function loadFileContents (line 204) | async function loadFileContents(file) {
  function selectLogFile (line 227) | function selectLogFile() {
  function isGzSupported (line 242) | function isGzSupported() {
  function packGz (line 250) | async function packGz(raw) {
  function unpackGz (line 267) | async function unpackGz(data) {
  function isDragEventValid (line 279) | function isDragEventValid(e) {
  function updateWindowDragCount (line 335) | function updateWindowDragCount(amount) {
  function updateDropZoneDragCount (line 344) | function updateDropZoneDragCount(amount) {
  function handleDropEvent (line 353) | async function handleDropEvent(e) {
Condensed preview — 98 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (240K chars).
[
  {
    "path": ".dockerignore",
    "chars": 33,
    "preview": "vendor/\n.git/\n.github/\nDockerfile"
  },
  {
    "path": ".github/workflows/publish.yml",
    "chars": 1799,
    "preview": "name: Publish Docker Image\n\non:\n  push:\n    branches:\n      - 'main'\n    tags:\n      - 'v*'\n\nenv:\n  REGISTRY: ghcr.io\n  "
  },
  {
    "path": ".gitignore",
    "chars": 40,
    "preview": "*.log\n*.cache\n.idea\n/vendor/\nconfig.json"
  },
  {
    "path": "Dockerfile",
    "chars": 941,
    "preview": "FROM dunglas/frankenphp:1-php8.5\n\n# System Setup\nRUN install-php-extensions mongodb zip\n\nARG USER=mclogs\nRUN useradd ${U"
  },
  {
    "path": "LICENSE",
    "chars": 1061,
    "preview": "Copyright (c) 2018-2024 Aternos GmbH\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
  },
  {
    "path": "README.md",
    "chars": 5447,
    "preview": "<p align=\"center\">\n    <img src=\"web/public/img/logo.svg\" width=\"260\">\n</p>\n<p align=\"center\">\n    <strong>Paste, share "
  },
  {
    "path": "build.php",
    "chars": 128,
    "preview": "<?php\n\nrequire_once __DIR__ . '/vendor/autoload.php';\n\n\\Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader::getInstance()->write"
  },
  {
    "path": "composer.json",
    "chars": 682,
    "preview": "{\n    \"name\": \"aternos/mclogs\",\n    \"description\": \"Paste, share and analyse Minecraft logs\",\n    \"authors\": [\n        {"
  },
  {
    "path": "dev/compose.yaml",
    "chars": 606,
    "preview": "name: \"mclogs\"\nservices:\n  web:\n    build:\n      context: ..\n      dockerfile: ./Dockerfile\n    environment:\n      - MCL"
  },
  {
    "path": "dev/dev.ini",
    "chars": 111,
    "preview": "opcache.enable=1\nopcache.enable_cli=1\n\nopcache.validate_timestamps=1\nopcache.revalidate_freq=0\n\nopcache.jit=off"
  },
  {
    "path": "docker/Caddyfile",
    "chars": 453,
    "preview": "{\n    servers {\n        trusted_proxies static {$TRUSTED_PROXIES:private_ranges}\n        trusted_proxies_strict\n    }\n\n "
  },
  {
    "path": "docker/compose.production.yaml",
    "chars": 1111,
    "preview": "services:\n  web:\n    image: ghcr.io/aternosorg/mclogs:2\n    restart: always\n    ports:\n      # Expose HTTP (80) and HTTP"
  },
  {
    "path": "docker/mclogs.ini",
    "chars": 171,
    "preview": "post_max_size = 50M\n\nerror_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\ndisplay_errors = Off\ndisplay_startup_e"
  },
  {
    "path": "example.config.json",
    "chars": 766,
    "preview": "{\n    \"storage\": {\n        \"ttl\": 7776000,\n        \"limit\": {\n            \"bytes\": 10485760,\n            \"lines\": 25000\n"
  },
  {
    "path": "src/Api/Action/AnalyseLogAction.php",
    "chars": 624,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\LogContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\Ap"
  },
  {
    "path": "src/Api/Action/ApiAction.php",
    "chars": 906,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\ContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiRe"
  },
  {
    "path": "src/Api/Action/BulkDeleteLogsAction.php",
    "chars": 2343,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\ContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiEr"
  },
  {
    "path": "src/Api/Action/CreateLogAction.php",
    "chars": 1155,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\LogContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\Ap"
  },
  {
    "path": "src/Api/Action/DeleteLogAction.php",
    "chars": 1398,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/Action/EmptyAction.php",
    "chars": 181,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass EmptyAction extends Action\n{\n    p"
  },
  {
    "path": "src/Api/Action/EndpointNotFoundAction.php",
    "chars": 302,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/Action/GetFiltersAction.php",
    "chars": 279,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Respons"
  },
  {
    "path": "src/Api/Action/GetLimitsAction.php",
    "chars": 276,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Respons"
  },
  {
    "path": "src/Api/Action/LogInfoAction.php",
    "chars": 599,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/Action/LogInsightsAction.php",
    "chars": 705,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/Action/RateLimitErrorAction.php",
    "chars": 409,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/Action/RawLogAction.php",
    "chars": 604,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\A"
  },
  {
    "path": "src/Api/ApiRouter.php",
    "chars": 1439,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Router\\Router;\nuse Aternos\\Mclogs\\Frontend;\nuse Aternos\\Mclogs\\"
  },
  {
    "path": "src/Api/ContentParser.php",
    "chars": 2940,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Config\\Config;\nuse At"
  },
  {
    "path": "src/Api/LogContentParser.php",
    "chars": 748,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\n\nclass LogContentParser extends ContentP"
  },
  {
    "path": "src/Api/Response/ApiError.php",
    "chars": 445,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass ApiError extends ApiResponse\n{\n    protected bool $success = false;"
  },
  {
    "path": "src/Api/Response/ApiResponse.php",
    "chars": 1173,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass ApiResponse implements \\JsonSerializable\n{\n    protected int $httpC"
  },
  {
    "path": "src/Api/Response/CodexLogResponse.php",
    "chars": 351,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Codex\\Log\\LogInterface;\n\nclass CodexLogResponse extends ApiRe"
  },
  {
    "path": "src/Api/Response/FiltersResponse.php",
    "chars": 215,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Filter\\Filter;\n\nclass FiltersResponse extends ApiRespo"
  },
  {
    "path": "src/Api/Response/LimitsResponse.php",
    "chars": 540,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nc"
  },
  {
    "path": "src/Api/Response/LogResponse.php",
    "chars": 2119,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass LogResponse e"
  },
  {
    "path": "src/Api/Response/MultiResponse.php",
    "chars": 899,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass MultiResponse extends ApiResponse\n{\n    protected int $httpCode = 2"
  },
  {
    "path": "src/Api/Response/RawLogResponse.php",
    "chars": 347,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Log;\n\nclass RawLogResponse extends ApiResponse\n{\n    p"
  },
  {
    "path": "src/Cache/CacheEntry.php",
    "chars": 931,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Cache;\n\nuse Aternos\\Mclogs\\Storage\\MongoDBClient;\nuse MongoDB\\BSON\\UTCDateTime;\n\nclass C"
  },
  {
    "path": "src/Config/Config.php",
    "chars": 2049,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Config;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass Config\n{"
  },
  {
    "path": "src/Config/ConfigKey.php",
    "chars": 1889,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Config;\n\nenum ConfigKey\n{\n    case STORAGE_TTL;\n    case STORAGE_LIMIT_BYTES;\n    case S"
  },
  {
    "path": "src/Data/Deobfuscator.php",
    "chars": 4372,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse Aternos\\Codex\\Analysis\\Information;\nuse Aternos\\Codex\\Log\\AnalysableLog;\nuse "
  },
  {
    "path": "src/Data/MetadataEntry.php",
    "chars": 5041,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse MongoDB\\BSON\\Serializable;\nuse MongoDB\\Model\\BSONDocument;\n\nclass MetadataEnt"
  },
  {
    "path": "src/Data/Token.php",
    "chars": 756,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse Random\\RandomException;\n\nclass Token implements \\JsonSerializable\n{\n    publi"
  },
  {
    "path": "src/Detective.php",
    "chars": 418,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\MinecraftLog;\n\nclass Detective extends \\Ater"
  },
  {
    "path": "src/Filter/AccessTokenFilter.php",
    "chars": 720,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass AccessTokenFil"
  },
  {
    "path": "src/Filter/Filter.php",
    "chars": 1567,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nabstract class Filter implements \\JsonSerializable\n{\n    /**\n     * @var Filter"
  },
  {
    "path": "src/Filter/FilterType.php",
    "chars": 195,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nenum FilterType: string\n{\n    case TRIM = 'trim';\n    case LIMIT_BYTES = 'limit"
  },
  {
    "path": "src/Filter/IPv4Filter.php",
    "chars": 756,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pa"
  },
  {
    "path": "src/Filter/IPv6Filter.php",
    "chars": 1647,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pa"
  },
  {
    "path": "src/Filter/LimitBytesFilter.php",
    "chars": 847,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass L"
  },
  {
    "path": "src/Filter/LimitLinesFilter.php",
    "chars": 875,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass L"
  },
  {
    "path": "src/Filter/Pattern/Modifier.php",
    "chars": 292,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\n/**\n * https://www.php.net/manual/en/reference.pcre.pattern.modifiers.p"
  },
  {
    "path": "src/Filter/Pattern/Pattern.php",
    "chars": 1106,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\nclass Pattern implements \\JsonSerializable\n{\n    protected const string"
  },
  {
    "path": "src/Filter/Pattern/PatternWithReplacement.php",
    "chars": 591,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\nclass PatternWithReplacement extends Pattern\n{\n    public function __co"
  },
  {
    "path": "src/Filter/RegexFilter.php",
    "chars": 1339,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pa"
  },
  {
    "path": "src/Filter/TrimFilter.php",
    "chars": 497,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nclass TrimFilter extends Filter\n{\n    /**\n     * Filter the $data string and re"
  },
  {
    "path": "src/Filter/UsernameFilter.php",
    "chars": 950,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass UsernameFilter"
  },
  {
    "path": "src/Frontend/Action/ApiDocsAction.php",
    "chars": 253,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass ApiDocsAction extends Action\n"
  },
  {
    "path": "src/Frontend/Action/CreateLogAction.php",
    "chars": 434,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass CreateLogAction extends \\Aternos\\M"
  },
  {
    "path": "src/Frontend/Action/DeleteLogAction.php",
    "chars": 650,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Frontend\\Cookie\\TokenCookie;\nuse Aternos\\Mclogs\\Log"
  },
  {
    "path": "src/Frontend/Action/FaviconAction.php",
    "chars": 305,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass FaviconAction extends Action\n"
  },
  {
    "path": "src/Frontend/Action/NotFoundAction.php",
    "chars": 282,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass NotFoundAction extends Action"
  },
  {
    "path": "src/Frontend/Action/StartAction.php",
    "chars": 248,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass StartAction extends Action\n{\n"
  },
  {
    "path": "src/Frontend/Action/ViewLogAction.php",
    "chars": 483,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Rout"
  },
  {
    "path": "src/Frontend/Assets/Asset.php",
    "chars": 2677,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Assets;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;"
  },
  {
    "path": "src/Frontend/Assets/AssetLoader.php",
    "chars": 2447,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Assets;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\n\nclass AssetLoader\n{\n    use Single"
  },
  {
    "path": "src/Frontend/Assets/AssetType.php",
    "chars": 118,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Assets;\n\nenum AssetType: string\n{\n    case CSS = \"css\";\n    case JS = \"js\";\n}"
  },
  {
    "path": "src/Frontend/Cookie/Cookie.php",
    "chars": 2550,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nuse Aternos\\Mclogs\\Util\\URL;\n\nabstract class Cookie\n{\n    protected ?s"
  },
  {
    "path": "src/Frontend/Cookie/SettingsCookie.php",
    "chars": 210,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nclass SettingsCookie extends Cookie\n{\n    /**\n     * @inheritDoc\n     "
  },
  {
    "path": "src/Frontend/Cookie/TokenCookie.php",
    "chars": 942,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;"
  },
  {
    "path": "src/Frontend/FrontendRouter.php",
    "chars": 740,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend;\n\nuse Aternos\\Mclogs\\Router\\Router;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\R"
  },
  {
    "path": "src/Frontend/Settings/Setting.php",
    "chars": 948,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Settings;\n\nenum Setting: string\n{\n    case FULL_WIDTH = \"fullWidth\";\n    case N"
  },
  {
    "path": "src/Frontend/Settings/Settings.php",
    "chars": 1432,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Settings;\n\nuse Aternos\\Mclogs\\Frontend\\Cookie\\SettingsCookie;\n\nclass Settings\n{"
  },
  {
    "path": "src/Id.php",
    "chars": 1219,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass Id implements \\JsonSerializable\n{\n    publ"
  },
  {
    "path": "src/Log.php",
    "chars": 12070,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Codex\\Analysis\\Analysis;\nuse Aternos\\Codex\\Log\\AnalysableLogInterface;\nuse"
  },
  {
    "path": "src/Printer/FormatModification.php",
    "chars": 369,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Printer;\n\n/**\n * Class FormatModification\n *\n * @package Printer\n */\nclass FormatModific"
  },
  {
    "path": "src/Printer/Printer.php",
    "chars": 2484,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Printer;\n\nuse Aternos\\Codex\\Log\\Entry;\nuse Aternos\\Codex\\Log\\EntryInterface;\nuse Aternos"
  },
  {
    "path": "src/Router/Action.php",
    "chars": 108,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nabstract class Action\n{\n    abstract public function run(): bool;\n}"
  },
  {
    "path": "src/Router/Method.php",
    "chars": 320,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nenum Method: string\n{\n    case GET = 'GET';\n    case POST = 'POST';\n    case PU"
  },
  {
    "path": "src/Router/Route.php",
    "chars": 886,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nclass Route\n{\n    public function __construct(\n        protected Method $method"
  },
  {
    "path": "src/Router/Router.php",
    "chars": 1515,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass Router\n{"
  },
  {
    "path": "src/Storage/MongoDBClient.php",
    "chars": 6559,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Storage;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Ate"
  },
  {
    "path": "src/Util/Singleton.php",
    "chars": 589,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\ntrait Singleton\n{\n    /**\n     * @var static[]\n     */\n    protected static array"
  },
  {
    "path": "src/Util/TimeInterval.php",
    "chars": 1296,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\nclass TimeInterval\n{\n    use Singleton;\n\n    protected const array UNITS = [\n    "
  },
  {
    "path": "src/Util/URL.php",
    "chars": 3305,
    "preview": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\nuse Uri\\Rfc3986\\Uri;\n\nclass URL\n{\n    protected const string API_SUBDOMAIN = \"api"
  },
  {
    "path": "web/frontend/404.php",
    "chars": 801,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <?php include __DIR__ . '/parts/head.php'; ?>\n        <title>404 - P"
  },
  {
    "path": "web/frontend/api-docs.php",
    "chars": 30748,
    "preview": "<?php\n\nuse Aternos\\Mclogs\\Api\\Action\\BulkDeleteLogsAction;\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\"
  },
  {
    "path": "web/frontend/log.php",
    "chars": 13905,
    "preview": "<?php\n\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetType;\nuse Aternos\\Mclogs\\"
  },
  {
    "path": "web/frontend/parts/favicon.php",
    "chars": 470,
    "preview": "<svg width=\"41\" height=\"42\" viewBox=\"0 0 41 42\" fill=\"<?=htmlspecialchars(\\Aternos\\Mclogs\\Config\\Config::getInstance()->"
  },
  {
    "path": "web/frontend/parts/footer.php",
    "chars": 1470,
    "preview": "<?php\nuse Aternos\\Mclogs\\Config\\Config;use Aternos\\Mclogs\\Config\\ConfigKey;use Aternos\\Mclogs\\Util\\URL;\n\n$imprintUrl = C"
  },
  {
    "path": "web/frontend/parts/head.php",
    "chars": 1878,
    "preview": "<?php\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLo"
  },
  {
    "path": "web/frontend/parts/header.php",
    "chars": 2147,
    "preview": "<header>\n    <a href=\"<?=htmlspecialchars(\\Aternos\\Mclogs\\Util\\URL::getBase()->toString()); ?>\" class=\"logo\">\n        <s"
  },
  {
    "path": "web/frontend/start.php",
    "chars": 2125,
    "preview": "<?php\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Filter\\Filter;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader"
  },
  {
    "path": "web/public/css/mclogs.css",
    "chars": 41514,
    "preview": "/* plus-jakarta-sans-regular - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Plus Jakarta Sans';\n    f"
  },
  {
    "path": "web/public/js/log.js",
    "chars": 9847,
    "preview": "/* line numbers */\nupdateLineNumber(location.hash);\n\nfor (let line of document.querySelectorAll('.line-number')) {\n    l"
  },
  {
    "path": "web/public/js/start.js",
    "chars": 10544,
    "preview": "/* Paste area */\nconst source = document.body.dataset.name || location.host;\nconst pasteArea = document.getElementById('"
  },
  {
    "path": "worker.php",
    "chars": 913,
    "preview": "<?php\n\nuse Aternos\\Mclogs\\Api\\ApiRouter;\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Ater"
  }
]

About this extraction

This page contains the full source code of the aternosorg/mclogs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 98 files (217.8 KB), approximately 58.5k tokens, and a symbol index with 357 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!