[
  {
    "path": ".dockerignore",
    "content": "vendor/\n.git/\n.github/\nDockerfile"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Docker Image\n\non:\n  push:\n    branches:\n      - 'main'\n    tags:\n      - 'v*'\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels)\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            # Branch Name (e.g. 'two', 'main')\n            type=ref,event=branch\n            # Full Version (e.g. '1.2.3')\n            type=semver,pattern={{version}}\n            # Major Version (e.g. '1')\n            type=semver,pattern={{major}}\n            # Major.Minor (e.g. '1.2')\n            type=semver,pattern={{major}}.{{minor}}\n            # Latest (Only on release tags)\n            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n          cache-from: |\n            type=gha\n            type=gha,scope=refs/heads/main\n          cache-to: type=gha,mode=max"
  },
  {
    "path": ".gitignore",
    "content": "*.log\n*.cache\n.idea\n/vendor/\nconfig.json"
  },
  {
    "path": "Dockerfile",
    "content": "FROM dunglas/frankenphp:1-php8.5\n\n# System Setup\nRUN install-php-extensions mongodb zip\n\nARG USER=mclogs\nRUN useradd ${USER} && \\\n    setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp\n\nCOPY --from=composer/composer:2-bin /composer /usr/bin/composer\n\nWORKDIR /app\n\n# Dependencies (Cached)\nCOPY composer.json composer.lock ./\n\nRUN --mount=type=cache,target=/tmp/cache/composer \\\n    COMPOSER_CACHE_DIR=/tmp/cache/composer \\\n    composer install --no-dev --no-interaction --no-scripts --no-autoloader --prefer-dist --ignore-platform-req=ext-frankenphp\n\n# Application Setup\nCOPY docker/Caddyfile /etc/frankenphp/Caddyfile\nCOPY docker/mclogs.ini /usr/local/etc/php/conf.d/mclogs.ini\n\nCOPY . .\n\nRUN composer dump-autoload --optimize --no-dev --classmap-authoritative\nRUN php build.php\n\n# Permissions & Runtime\nRUN chown -R ${USER}:${USER} /config/caddy /data/caddy /app\n\nUSER ${USER}\n\nEXPOSE 80\nEXPOSE 443\nEXPOSE 443/udp\n\nVOLUME [\"/data\"]"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2018-2024 Aternos GmbH\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <img src=\"web/public/img/logo.svg\" width=\"260\">\n</p>\n<p align=\"center\">\n    <strong>Paste, share & analyse your logs</strong><br>\n    Built for Minecraft & Hytale\n</p>\n\n## Features\n* Share logs by pasting or uploading files\n* Automatic removal of sensitive information (e.g. IP addresses)\n* Short URLs for easy sharing\n* Syntax highlighting\n* Line numbers\n* Direct links to specific lines\n* Analysis and parsing using [codex](https://github.com/aternosorg/codex-minecraft)\n\n### For developers\n* Upload your logs using the API\n* Add metadata to shared logs, e.g. version numbers, server ids, etc.\n* Retrieve logs and their metadata from the API\n* Open source and self-hostable\n\n## Self-hosting\nYou 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`. \nA MongoDB instance is also required to run mclogs.\n\nAn example docker compose files for self-hosting can be found here: [docker/compose.production.yaml](docker/compose.production.yaml).\n\n### Config\nYou can configure mclogs by creating a `config.json` file in the root directory, see [example.config.json](example.config.json) or by setting\nenvironment variables. Environment variables override settings in the config file.\n\nHere is a list of all available config options:\n\n| Variable / JSON Path                                                | Default             | Description                                 |\n|:--------------------------------------------------------------------|:--------------------|:--------------------------------------------|\n| `MCLOGS_STORAGE_TTL` <br> `storage.ttl`                             | `7776000` (90d)     | Time until logs are deleted after last view |\n| `MCLOGS_STORAGE_LIMIT_BYTES` <br> `storage.limit.bytes`             | `10485760` (10 MiB) | Maximum size of a log in bytes              |\n| `MCLOGS_STORAGE_LIMIT_LINES` <br> `storage.limit.lines`             | `25000`             | Maximum number of lines in a log            |\n| `MCLOGS_MONGODB_URL` <br> `mongodb.url`                             | `\"mongodb://mongo\"` | MongoDB connection URL                      |\n| `MCLOGS_MONGODB_DATABASE` <br> `mongodb.database`                   | `\"mclogs\"`          | Name of the MongoDB database                |\n| `MCLOGS_ID_LENGTH` <br> `id.length`                                 | `7`                 | The default length for new IDs              |\n| `MCLOGS_LEGAL_ABUSE` <br> `legal.abuse`                             | `null`              | Public email address to report abuse        |\n| `MCLOGS_LEGAL_IMPRINT` <br> `legal.imprint`                         | `null`              | The imprint URL                             |\n| `MCLOGS_LEGAL_PRIVACY` <br> `legal.privacy`                         | `null`              | The privacy policy URL                      |\n| `MCLOGS_FRONTEND_NAME` <br> `frontend.name`                         | `null`              | Instance name (defaults to domain)          |\n| `MCLOGS_FRONTEND_COLOR_ACCENT` <br> `frontend.color.accent`         | `#5cb85c`           | The accent/primary color                    |\n| `MCLOGS_FRONTEND_COLOR_BACKGROUND` <br> `frontend.color.background` | `#1a1a1a`           | The background color                        |\n| `MCLOGS_FRONTEND_COLOR_TEXT` <br> `frontend.color.text`             | `#e8e8e8`           | The text color                              |\n| `MCLOGS_FRONTEND_COLOR_ERROR` <br> `frontend.color.error`           | `#f62451`           | The error color                             |\n| `MCLOGS_WORKER_REQUESTS` <br> `worker.requests`                     | `500`               | Max requests per single worker              |\n\nThere are a few more environment variables that can be set to modify the FrankenPHP/Caddy setup directly:\n\n| Variable             | Default            | Description                                                                                                                                |\n|----------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------|\n| `SERVER_NAME`        | `\":80\"`            | Set the Caddy server name, set this to your domain for [automatic SSL](https://caddyserver.com/docs/automatic-https#hostname-requirements) |\n| `TRUSTED_PROXIES`    | `\"private_ranges\"` | Set [trusted proxy](https://caddyserver.com/docs/caddyfile/options#trusted-proxies) address ranges                                         |\n| `FRANKENPHP_WORKERS` | `16`               | The number of [FrankenPHP workers](https://frankenphp.dev/docs/worker/)                                                                    |                                                                                                                                            |\n\n\n## Development setup\n### Prerequisites\n* [Docker](https://www.docker.com/get-started/) and [Docker Compose](https://docs.docker.com/compose/install/)\n* [PHP 8.5+](https://www.php.net/downloads)\n* [Composer](https://getcomposer.org/download/)\n\n### Installation\n```bash\n# clone repository\ngit clone git@github.com:aternosorg/mclogs.git\n\n# install composer dependencies\ncd mclogs\ncomposer install\n\n# start development environment\ncd dev\ndocker compose up\n```\nOpen http://localhost in browser and enjoy."
  },
  {
    "path": "build.php",
    "content": "<?php\n\nrequire_once __DIR__ . '/vendor/autoload.php';\n\n\\Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader::getInstance()->writeCache();"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"aternos/mclogs\",\n    \"description\": \"Paste, share and analyse Minecraft logs\",\n    \"authors\": [\n        {\n            \"name\": \"Matthias Neid\",\n            \"email\": \"matthias@aternos.org\"\n        }\n    ],\n    \"require\": {\n        \"php\": \">=8.5\",\n        \"ext-frankenphp\": \"*\",\n        \"ext-json\": \"*\",\n        \"ext-mbstring\": \"*\",\n        \"ext-mongodb\": \"*\",\n        \"ext-uri\": \"*\",\n        \"ext-zlib\": \"*\",\n        \"aternos/codex-hytale\": \"^2.0\",\n        \"aternos/codex-minecraft\": \"^5.0.1\",\n        \"aternos/sherlock\": \"^1.0.2\",\n        \"mongodb/mongodb\": \"2.1.2\"\n    },\n    \"autoload\": {\n        \"psr-4\": {\n            \"Aternos\\\\Mclogs\\\\\": \"src/\"\n        }\n    }\n}\n"
  },
  {
    "path": "dev/compose.yaml",
    "content": "name: \"mclogs\"\nservices:\n  web:\n    build:\n      context: ..\n      dockerfile: ./Dockerfile\n    environment:\n      - MCLOGS_WORKER_REQUESTS=1\n      - FRANKENPHP_WORKERS=4\n    ports:\n      - \"80:80\"\n    volumes:\n      - ../:/app\n      - ./dev.ini:/usr/local/etc/php/conf.d/dev.ini\n    user: root\n    depends_on:\n      mongo:\n        condition: service_healthy\n\n  mongo:\n    image: mongo\n    volumes:\n      - mongo:/data/db\n    healthcheck:\n      test: [ \"CMD\", \"mongosh\", \"--eval\", \"db.adminCommand('ping')\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 5\n      start_period: 10s\n\nvolumes:\n  mongo:\n"
  },
  {
    "path": "dev/dev.ini",
    "content": "opcache.enable=1\nopcache.enable_cli=1\n\nopcache.validate_timestamps=1\nopcache.revalidate_freq=0\n\nopcache.jit=off"
  },
  {
    "path": "docker/Caddyfile",
    "content": "{\n    servers {\n        trusted_proxies static {$TRUSTED_PROXIES:private_ranges}\n        trusted_proxies_strict\n    }\n\n    frankenphp {\n        worker /app/worker.php {\n            num {$FRANKENPHP_WORKERS:16}\n        }\n    }\n}\n\n{$SERVER_NAME::80} {\n    root * /app/web/public\n    encode zstd br gzip\n\n    @static file\n    handle @static {\n        file_server\n    }\n\n    handle {\n       root * /app\n       rewrite * /worker.php\n       php_server\n    }\n}"
  },
  {
    "path": "docker/compose.production.yaml",
    "content": "services:\n  web:\n    image: ghcr.io/aternosorg/mclogs:2\n    restart: always\n    ports:\n      # Expose HTTP (80) and HTTPS (443)\n      # Port 443/udp is required for HTTP/3 (QUIC)\n      - \"80:80\"\n      - \"443:443\"\n      - \"443:443/udp\"\n    environment:\n      # Set this to your domain (e.g., mclogs.example.com) to enable Auto-SSL.\n      # If running behind a proxy (Cloudflare/Nginx), set to \":80\" to disable Auto-SSL.\n      SERVER_NAME: :80\n\n      MCLOGS_MONGODB_URL: mongodb://mongo:27017\n      MCLOGS_MONGODB_DATABASE: mclogs\n\n      # Optional MCLOGS configuration\n      # See README.md for full list of available options\n      # MCLOGS_FRONTEND_NAME: \"mclogs\"\n\n    volumes:\n      # For caddy cache (SSL certificates)\n      - web-data:/data\n\n    depends_on:\n      mongo:\n        condition: service_healthy\n\n  mongo:\n    image: mongo\n    restart: always\n    volumes:\n      - mongo-data:/data/db\n\n    healthcheck:\n      test: echo 'db.runCommand(\"ping\").ok' | mongosh localhost:27017/test --quiet\n      interval: 10s\n      timeout: 5s\n      retries: 5\n      start_period: 5s\n\nvolumes:\n  web-data:\n  mongo-data:"
  },
  {
    "path": "docker/mclogs.ini",
    "content": "post_max_size = 50M\n\nerror_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED\ndisplay_errors = Off\ndisplay_startup_errors = Off\nlog_errors = On\nerror_log = /dev/stderr"
  },
  {
    "path": "example.config.json",
    "content": "{\n    \"storage\": {\n        \"ttl\": 7776000,\n        \"limit\": {\n            \"bytes\": 10485760,\n            \"lines\": 25000\n        }\n    },\n    \"mongodb\": {\n        \"url\": \"mongodb://127.0.0.1:27017\",\n        \"database\": \"mclogs\"\n    },\n    \"id\": {\n        \"length\": 7\n    },\n    \"legal\": {\n        \"abuse\": \"abuse@aternos.org\",\n        \"imprint\": \"https://aternos.gmbh/imprint/\",\n        \"privacy\": \"https://aternos.gmbh/en/mclogs/privacy\"\n    },\n    \"frontend\": {\n        \"name\": \"mclo.gs\",\n        \"assets\": {\n            \"integrity\": true\n        },\n        \"color\": {\n            \"background\": \"#1a1a1a\",\n            \"text\": \"#e8e8e8\",\n            \"accent\": \"#5cb85c\",\n            \"error\": \"#f62451\"\n        }\n    },\n    \"worker\": {\n        \"requests\": 500\n    }\n}"
  },
  {
    "path": "src/Api/Action/AnalyseLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\LogContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\CodexLogResponse;\nuse Aternos\\Mclogs\\Log;\n\nclass AnalyseLogAction extends ApiAction\n{\n    public function runApi(): ApiResponse\n    {\n        $data = new LogContentParser()->getContent();\n\n        if ($data instanceof ApiError) {\n            return $data;\n        }\n\n        $content = $data['content'];\n        $log = new Log()->setContent($content);\n\n        return new CodexLogResponse($log->getCodexLog());\n    }\n}\n"
  },
  {
    "path": "src/Api/Action/ApiAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\ContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Router\\Action;\n\nabstract class ApiAction extends Action\n{\n    abstract protected function runApi(): ApiResponse;\n\n    protected function getAllowedOrigin(): string\n    {\n        return '*';\n    }\n\n    protected function shouldAllowCredentials(): bool\n    {\n        return false;\n    }\n\n    public function run(): bool\n    {\n        header('Access-Control-Allow-Origin: ' . $this->getAllowedOrigin());\n        header('Access-Control-Allow-Headers: *');\n        if ($this->shouldAllowCredentials()) {\n            header('Access-Control-Allow-Credentials: true');\n        }\n        header(\"Accept-Encoding: \" . implode(\",\", ContentParser::getSupportedEncodings()));\n\n        $response = $this->runApi();\n        $response->output();\n\n        return true;\n    }\n}"
  },
  {
    "path": "src/Api/Action/BulkDeleteLogsAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\ContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\MultiResponse;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Storage\\MongoDBClient;\n\nclass BulkDeleteLogsAction extends ApiAction\n{\n    public const int MAX_IDS = 256;\n\n    /**\n     * @return ApiResponse\n     */\n    protected function runApi(): ApiResponse\n    {\n        $data = new ContentParser()->getContent();\n        if ($data instanceof ApiError) {\n            return $data;\n        }\n\n        if (count($data) === 0) {\n            return new ApiError(400, \"No logs provided.\");\n        }\n        if (count($data) > static::MAX_IDS) {\n            return new ApiError(400, \"Too many logs. Maximum is \" . static::MAX_IDS . \".\");\n        }\n\n        $ids = [];\n        foreach ($data as $log) {\n            if (!is_array($log)) {\n                return new ApiError(400, \"Each entry must be an object with 'id' and 'token' fields.\");\n            }\n            if (!isset($log[\"id\"]) || !is_string($log[\"id\"]) ||\n                !preg_match(\"/^\" . Id::PATTERN . \"$/\", $log[\"id\"])) {\n                return new ApiError(400, \"Each log must have a valid 'id' field.\");\n            }\n            if (!isset($log[\"token\"]) || !is_string($log[\"token\"])) {\n                return new ApiError(400, \"Each log must have a valid 'token' field.\");\n            }\n            $ids[] = $log[\"id\"];\n        }\n\n        $logs = Log::findAll($ids, false);\n\n        $deleteIds = [];\n        $response = new MultiResponse();\n        foreach ($data as $log) {\n            $id = $log[\"id\"];\n            $token = $log[\"token\"];\n\n            $log = $logs[$id] ?? null;\n            if (!$log) {\n                $response->addResponse($id, new ApiError(404, \"Log not found.\"));\n                continue;\n            }\n\n            $logToken = $log->getToken();\n            if (!$logToken || !$logToken->matches($token)) {\n                $response->addResponse($id, new ApiError(403, \"Invalid token.\"));\n                continue;\n            }\n\n            $deleteIds[] = $id;\n            $response->addResponse($id, new ApiResponse());\n        }\n\n        MongoDBClient::getInstance()->deleteLogs($deleteIds);\n\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Api/Action/CreateLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\LogContentParser;\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\LogResponse;\nuse Aternos\\Mclogs\\Data\\MetadataEntry;\nuse Aternos\\Mclogs\\Log;\n\nclass CreateLogAction extends ApiAction\n{\n    protected bool $includeCookie = false;\n    protected bool $includeToken = true;\n\n    public function runApi(): ApiResponse\n    {\n        $data = new LogContentParser()->getContent();\n\n        if ($data instanceof ApiError) {\n            return $data;\n        }\n\n        $content = $data['content'];\n        $metadata = [];\n        if (isset($data['metadata']) && is_array($data['metadata'])) {\n            $metadata = MetadataEntry::allFromArray($data['metadata']);\n        }\n        $source = null;\n        if (isset($data['source']) && is_string($data['source'])) {\n            $source = $data['source'];\n        }\n\n        $log = Log::create($content, $metadata, $source);\n\n        if ($this->includeCookie) {\n            $log->setTokenCookie();\n        }\n\n        return new LogResponse($log, $this->includeToken);\n    }\n}\n"
  },
  {
    "path": "src/Api/Action/DeleteLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass DeleteLogAction extends ApiAction\n{\n    protected function getRequestToken(): ?string\n    {\n        $authorizationHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? null;\n        if (!$authorizationHeader) {\n            return null;\n        }\n        $parts = explode(\" \", $authorizationHeader);\n        return $parts[1] ?? null;\n    }\n\n    /**\n     * @return ApiResponse\n     */\n    protected function runApi(): ApiResponse\n    {\n        $requestToken = $this->getRequestToken();\n\n        if (!$requestToken) {\n            return new ApiError(400, \"Missing token.\");\n        }\n\n        $id = new Id(URL::getLastPathPart());\n        $log = Log::find($id);\n\n        if (!$log) {\n            return new ApiError(404, \"Log not found.\");\n        }\n\n        $token = $log->getToken();\n        if (!$token || !$token->matches($requestToken)) {\n            return new ApiError(403, \"Invalid token.\");\n        }\n\n        $deleted = $log->delete();\n        if (!$deleted) {\n            return new ApiError(500, \"Failed to delete log.\");\n        }\n\n        $this->handleDeletedLog($log);\n\n        return new ApiResponse();\n    }\n\n    protected function handleDeletedLog(Log $log): void\n    {\n\n    }\n}\n"
  },
  {
    "path": "src/Api/Action/EmptyAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass EmptyAction extends Action\n{\n    public function run(): bool\n    {\n        return true;\n    }\n}"
  },
  {
    "path": "src/Api/Action/EndpointNotFoundAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\n\nclass EndpointNotFoundAction extends ApiAction\n{\n    protected function runApi(): ApiResponse\n    {\n        return new ApiError(404, \"Could not find endpoint.\");\n    }\n}"
  },
  {
    "path": "src/Api/Action/GetFiltersAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\FiltersResponse;\n\nclass GetFiltersAction extends ApiAction\n{\n    protected function runApi(): ApiResponse\n    {\n        return new FiltersResponse();\n    }\n}"
  },
  {
    "path": "src/Api/Action/GetLimitsAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\LimitsResponse;\n\nclass GetLimitsAction extends ApiAction\n{\n    protected function runApi(): ApiResponse\n    {\n        return new LimitsResponse();\n    }\n}"
  },
  {
    "path": "src/Api/Action/LogInfoAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\LogResponse;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass LogInfoAction extends ApiAction\n{\n    /**\n     * @return ApiResponse\n     */\n    protected function runApi(): ApiResponse\n    {\n        $id = new Id(URL::getLastPathPart());\n        $log = Log::find($id);\n\n        if (!$log) {\n            return new ApiError(404, \"Log not found.\");\n        }\n\n        return new LogResponse($log);\n    }\n}"
  },
  {
    "path": "src/Api/Action/LogInsightsAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\CodexLogResponse;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass LogInsightsAction extends ApiAction\n{\n    /**\n     * @return ApiResponse\n     */\n    protected function runApi(): ApiResponse\n    {\n        $id = new Id(URL::getLastPathPart());\n        $log = Log::find($id);\n\n        if (!$log) {\n            return new ApiError(404, \"Log not found.\");\n        }\n\n        $codexLog = $log->getCodexLog();\n        $codexLog->setIncludeEntries(false);\n\n        return new CodexLogResponse($codexLog);\n    }\n}"
  },
  {
    "path": "src/Api/Action/RateLimitErrorAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\n\nclass RateLimitErrorAction extends ApiAction\n{\n    protected function runApi(): ApiResponse\n    {\n        return new ApiError(\n            429,\n            \"Unfortunately you have exceeded the rate limit for the current time period. Please try again later.\"\n        );\n    }\n}"
  },
  {
    "path": "src/Api/Action/RawLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Action;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\RawLogResponse;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass RawLogAction extends ApiAction\n{\n    /**\n     * @return ApiResponse\n     */\n    protected function runApi(): ApiResponse\n    {\n        $id = new Id(URL::getLastPathPart());\n        $log = Log::find($id);\n\n        if (!$log) {\n            return new ApiError(404, \"Log not found.\");\n        }\n\n        return new RawLogResponse($log);\n    }\n}"
  },
  {
    "path": "src/Api/ApiRouter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Router\\Router;\nuse Aternos\\Mclogs\\Frontend;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Router\\Method;\n\nclass ApiRouter extends Router\n{\n    protected function __construct()\n    {\n        parent::__construct();\n        $this->register(Method::GET, \"#^/$#\", new Frontend\\Action\\ApiDocsAction())\n            ->register(Method::OPTIONS, \"#^/.*$#\", new Action\\EmptyAction())\n            ->register(Method::POST, \"#^/1/log/?$#\", new Action\\CreateLogAction())\n            ->register(Method::GET, \"#^/1/log/\" . Id::PATTERN . \"$#\", new Action\\LogInfoAction())\n            ->register(Method::DELETE, \"#^/1/log/\" . Id::PATTERN . \"$#\", new Action\\DeleteLogAction())\n            ->register(Method::POST, \"#^/1/bulk/log/delete/?$#\", new Action\\BulkDeleteLogsAction())\n            ->register(Method::GET, \"#^/1/insights/\" . Id::PATTERN . \"$#\", new Action\\LogInsightsAction())\n            ->register(Method::GET, \"#^/1/raw/\" . Id::PATTERN . \"$#\", new Action\\RawLogAction())\n            ->register(Method::POST, \"#^/1/analyse/?$#\", new Action\\AnalyseLogAction())\n            ->register(Method::GET, \"#^/1/errors/rate$#\", new Action\\RateLimitErrorAction())\n            ->register(Method::GET, \"#^/1/limits$#\", new Action\\GetLimitsAction())\n            ->register(Method::GET, \"#^/1/filters#\", new Action\\GetFiltersAction())\n            ->setDefaultAction(new Action\\EndpointNotFoundAction());\n    }\n}\n"
  },
  {
    "path": "src/Api/ContentParser.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\n/**\n * Utility class for reading log content from the http request\n */\nclass ContentParser\n{\n    protected const int MAX_ENCODING_STEPS = 5;\n\n    /**\n     * Get all supported content encodings\n     * @return string[]\n     */\n    public static function getSupportedEncodings(): array\n    {\n        return [\"deflate\", \"gzip\", \"x-gzip\"];\n    }\n\n    /**\n     * Get the content from the http request\n     *\n     * @return array|ApiError An array with the content or an ApiError on failure\n     */\n    public function getContent(): array|ApiError\n    {\n        $limit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES) * 2;\n        $body = file_get_contents('php://input', length: $limit + 1);\n        if ($body === false) {\n            return new ApiError(500, \"Failed to read request body.\");\n        }\n        if (strlen($body) > $limit) {\n            return new ApiError(413, \"Request body exceeds maximum allowed size.\");\n        }\n\n        $encodingHeader = $_SERVER['HTTP_CONTENT_ENCODING'] ?? '';\n        if ($encodingHeader) {\n            $encodingSteps = explode(',', $encodingHeader);\n            if (count($encodingSteps) > static::MAX_ENCODING_STEPS) {\n                return new ApiError(400, \"Too many Content-Encoding steps.\");\n            }\n            foreach (array_reverse($encodingSteps) as $step) {\n                switch (trim(strtolower($step))) {\n                    case \"deflate\":\n                        $body = @gzinflate($body, $limit);\n                        break;\n                    case \"x-gzip\":\n                    case \"gzip\":\n                        $body = @gzdecode($body, $limit);\n                        break;\n                    default:\n                        return new ApiError(415, \"Unsupported Content-Encoding: \" . htmlspecialchars($step));\n                }\n                if ($body === false) {\n                    return new ApiError(400, \"Failed to decode request body with encoding: \" . htmlspecialchars($step));\n                }\n            }\n        }\n\n        $contentTypeHeader = $_SERVER['CONTENT_TYPE'] ?? '';\n        if ($pos = strpos($contentTypeHeader, ';')) {\n            $contentTypeHeader = substr($contentTypeHeader, 0, $pos);\n        }\n        switch ($contentTypeHeader) {\n            case \"application/x-www-form-urlencoded\":\n                parse_str($body, $data);\n                break;\n            case \"application/json\":\n                $data = @json_decode($body, true);\n                if (!is_array($data)) {\n                    return new ApiError(400, \"Failed to parse JSON body.\");\n                }\n                break;\n            default:\n                return new ApiError(415, \"Unsupported Content-Type: \" . htmlspecialchars($contentTypeHeader));\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "src/Api/LogContentParser.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api;\n\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\n\nclass LogContentParser extends ContentParser\n{\n    /**\n     * @inheritDoc\n     */\n    public function getContent(): array|ApiError\n    {\n        $data = parent::getContent();\n        if ($data instanceof ApiError) {\n            return $data;\n        }\n\n        if (!isset($data['content'])) {\n            return new ApiError(400, \"Required field 'content' not found.\");\n        }\n\n        if (empty($data['content'])) {\n            return new ApiError(400, \"Required field 'content' is empty.\");\n        }\n\n        if (!is_string($data['content'])) {\n            return new ApiError(400, \"Field 'content' must be a string.\");\n        }\n\n        return $data;\n    }\n}\n"
  },
  {
    "path": "src/Api/Response/ApiError.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass ApiError extends ApiResponse\n{\n    protected bool $success = false;\n\n    public function __construct(\n        int              $httpCode,\n        protected string $message,\n    )\n    {\n        $this->setHttpCode($httpCode);\n    }\n\n    public function jsonSerialize(): array\n    {\n        $data = parent::jsonSerialize();\n        $data['error'] = $this->message;\n        return $data;\n    }\n}\n"
  },
  {
    "path": "src/Api/Response/ApiResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass ApiResponse implements \\JsonSerializable\n{\n    protected int $httpCode = 200;\n    protected bool $success = true;\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'success' => $this->success,\n        ];\n    }\n\n    /**\n     * @param int $httpCode\n     * @return $this\n     */\n    public function setHttpCode(int $httpCode): static\n    {\n        $this->httpCode = $httpCode;\n        return $this;\n    }\n\n    /**\n     * @return int\n     */\n    public function getHttpCode(): int\n    {\n        return $this->httpCode;\n    }\n\n    /**\n     * @param bool $success\n     * @return $this\n     */\n    public function setSuccess(bool $success): static\n    {\n        $this->success = $success;\n        return $this;\n    }\n\n    /**\n     * @return bool\n     */\n    public function isSuccess(): bool\n    {\n        return $this->success;\n    }\n\n    /**\n     * @return $this\n     */\n    public function output(): static\n    {\n        header('Content-Type: application/json');\n        http_response_code($this->httpCode);\n        echo json_encode($this, JSON_UNESCAPED_SLASHES);\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Api/Response/CodexLogResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Codex\\Log\\LogInterface;\n\nclass CodexLogResponse extends ApiResponse\n{\n    public function __construct(protected LogInterface $codexLog)\n    {\n    }\n\n    public function jsonSerialize(): array\n    {\n        return array_merge(parent::jsonSerialize(), $this->codexLog->jsonSerialize());\n    }\n}"
  },
  {
    "path": "src/Api/Response/FiltersResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Filter\\Filter;\n\nclass FiltersResponse extends ApiResponse\n{\n    public function jsonSerialize(): array\n    {\n        return Filter::getAll();\n    }\n}"
  },
  {
    "path": "src/Api/Response/LimitsResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass LimitsResponse extends ApiResponse\n{\n    public function jsonSerialize(): array\n    {\n        $config = Config::getInstance();\n        $data = parent::jsonSerialize();\n        $data['storageTime'] = $config->get(ConfigKey::STORAGE_TTL);\n        $data['maxLength'] = $config->get(ConfigKey::STORAGE_LIMIT_BYTES);\n        $data['maxLines'] = $config->get(ConfigKey::STORAGE_LIMIT_LINES);\n        return $data;\n    }\n}"
  },
  {
    "path": "src/Api/Response/LogResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass LogResponse extends ApiResponse\n{\n    public function __construct(\n        protected Log  $log,\n        protected bool $withToken = false,\n        protected bool $withInsights = false,\n        protected bool $withRaw = false,\n        protected bool $withParsed = false)\n    {\n        $this->loadFromGet();\n    }\n\n    public function loadFromGet(): static\n    {\n        $url = URL::getCurrent();\n        $query = $url->getQuery();\n        if (empty($query)) {\n            return $this;\n        }\n        parse_str($url->getQuery(), $get);\n        $this->withInsights = isset($get['insights']);\n        $this->withRaw = isset($get['raw']);\n        $this->withParsed = isset($get['parsed']);\n        return $this;\n    }\n\n    public function jsonSerialize(): array\n    {\n        $data = parent::jsonSerialize();\n        $data['id'] = $this->log->getId();\n        $data['source'] = $this->log->getSource();\n        $data['created'] = $this->log->getCreated()?->toDateTime()->getTimestamp();\n        $data['expires'] = $this->log->getExpires()?->toDateTime()->getTimestamp();\n        $data['size'] = $this->log->getSize();\n        $data['lines'] = $this->log->getLinesCount();\n        $data['errors'] = $this->log->getErrorsCount();\n        $data['url'] = $this->log->getUrl()->toString();\n        $data['raw'] = $this->log->getRawURL()->toString();\n        if ($this->withToken) {\n            $data['token'] = $this->log->getToken();\n        }\n        $data['metadata'] = $this->log->getMetadata();\n        if ($this->withInsights || $this->withRaw || $this->withParsed) {\n            $data['content'] = [];\n        }\n        if ($this->withInsights) {\n            $data['content']['insights'] = $this->log->getCodexLog()->setIncludeEntries(false);\n        }\n        if ($this->withRaw) {\n            $data['content']['raw'] = $this->log->getContent();\n        }\n        if ($this->withParsed) {\n            $data['content']['parsed'] = $this->log->getCodexLog()->getEntries();\n        }\n        return $data;\n    }\n}"
  },
  {
    "path": "src/Api/Response/MultiResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nclass MultiResponse extends ApiResponse\n{\n    protected int $httpCode = 207;\n\n    /**\n     * @var ApiResponse[]\n     */\n    protected array $responses = [];\n\n    /**\n     * @param string $id\n     * @param ApiResponse $response\n     * @return $this\n     */\n    public function addResponse(string $id, ApiResponse $response): static\n    {\n        $this->responses[$id] = $response;\n        return $this;\n    }\n\n    public function jsonSerialize(): array\n    {\n        $response = parent::jsonSerialize();\n        $results = [];\n        foreach ($this->responses as $id => $apiResponse) {\n            $result = $apiResponse->jsonSerialize();\n            $result[\"id\"] = $id;\n            $result[\"status\"] = $apiResponse->getHttpCode();\n            $results[] = $result;\n        }\n        $response[\"results\"] = $results;\n        return $response;\n    }\n}\n"
  },
  {
    "path": "src/Api/Response/RawLogResponse.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Api\\Response;\n\nuse Aternos\\Mclogs\\Log;\n\nclass RawLogResponse extends ApiResponse\n{\n    public function __construct(\n        protected Log  $log)\n    {\n    }\n\n    public function output(): static\n    {\n        header('Content-Type: text/plain');\n        echo $this->log->getContent();\n\n        return $this;\n    }\n\n}"
  },
  {
    "path": "src/Cache/CacheEntry.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Cache;\n\nuse Aternos\\Mclogs\\Storage\\MongoDBClient;\nuse MongoDB\\BSON\\UTCDateTime;\n\nclass CacheEntry\n{\n    public function __construct(protected string $key)\n    {\n    }\n\n    /**\n     * @return string|null\n     */\n    public function get(): ?string\n    {\n        $result = MongoDBClient::getInstance()->getCacheCollection()->findOne([\n            \"_id\" => $this->key\n        ]);\n        return $result?->data;\n    }\n\n    /**\n     * @param string $data\n     * @param int $ttl\n     * @return $this\n     */\n    public function set(string $data, int $ttl = 24 * 60 * 60): static\n    {\n        MongoDBClient::getInstance()->getCacheCollection()->updateOne(\n            [\"_id\" => $this->key],\n            ['$set' => [\n                'data' => $data,\n                'expires' => new UTCDateTime((time() + $ttl) * 1000)\n            ]],\n            ['upsert' => true]\n        );\n        return $this;\n    }\n\n}"
  },
  {
    "path": "src/Config/Config.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Config;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass Config\n{\n    use Singleton;\n\n    protected array $jsonData = [];\n\n    protected function __construct()\n    {\n        $configPath = __DIR__ . \"/../../config.json\";\n        if (file_exists($configPath)) {\n            $jsonContent = file_get_contents($configPath);\n            $data = @json_decode($jsonContent, true);\n            if (is_array($data)) {\n                $this->jsonData = $data;\n            }\n        }\n    }\n\n    /**\n     * Get config value by checking environment variable, then config file, then default value\n     *\n     * @param ConfigKey $key\n     * @return mixed\n     */\n    public function get(ConfigKey $key): mixed\n    {\n        $env = getenv($key->getEnvironmentVariable());\n        if ($env !== false) {\n            if ($env === \"true\") {\n                return true;\n            } else if ($env === \"false\") {\n                return false;\n            }\n            return $env;\n        }\n\n        $json = $this->getJsonValue($key->getJSONPath());\n        if ($json !== null) {\n            return $json;\n        }\n\n        return $key->getDefaultValue();\n    }\n\n    /**\n     * @return string\n     */\n    public function getName(): string\n    {\n        return $this->get(ConfigKey::FRONTEND_NAME) ?? URL::getBase()->getHost();\n    }\n\n    /**\n     * Recursively get a value from the json data by path\n     *\n     * @param array $path\n     * @param array|null $data\n     * @return mixed\n     */\n    protected function getJsonValue(array $path, ?array $data = null): mixed\n    {\n        if ($data === null) {\n            $data = $this->jsonData;\n        }\n\n        $nextKey = array_shift($path);\n\n        if (!isset($data[$nextKey])) {\n            return null;\n        }\n\n        $nextData = $data[$nextKey];\n        if (count($path) === 0) {\n            return $nextData;\n        }\n        if (!is_array($nextData)) {\n            return null;\n        }\n        return $this->getJsonValue($path, $nextData);\n    }\n}"
  },
  {
    "path": "src/Config/ConfigKey.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Config;\n\nenum ConfigKey\n{\n    case STORAGE_TTL;\n    case STORAGE_LIMIT_BYTES;\n    case STORAGE_LIMIT_LINES;\n\n    case MONGODB_URL;\n    case MONGODB_DATABASE;\n\n    case ID_LENGTH;\n\n    case LEGAL_ABUSE;\n    case LEGAL_IMPRINT;\n    case LEGAL_PRIVACY;\n\n    case FRONTEND_NAME;\n    case FRONTEND_ANALYTICS;\n    case FRONTEND_ASSETS_INTEGRITY;\n    case FRONTEND_COLOR_BACKGROUND;\n    case FRONTEND_COLOR_TEXT;\n    case FRONTEND_COLOR_ACCENT;\n    case FRONTEND_COLOR_ERROR;\n\n    case WORKER_REQUESTS;\n\n    /**\n     * Get the default value for the config key\n     *\n     * @return string|int|null\n     */\n    public function getDefaultValue(): string|int|null\n    {\n        return match ($this) {\n            ConfigKey::STORAGE_TTL => 90 * 24 * 60 * 60,\n            ConfigKey::STORAGE_LIMIT_BYTES => 10 * 1024 * 1024,\n            ConfigKey::STORAGE_LIMIT_LINES => 25000,\n\n            ConfigKey::MONGODB_URL => 'mongodb://mongo:27017',\n            ConfigKey::MONGODB_DATABASE => 'mclogs',\n\n            ConfigKey::ID_LENGTH => 7,\n\n            ConfigKey::FRONTEND_ANALYTICS => false,\n\n            ConfigKey::FRONTEND_ASSETS_INTEGRITY => true,\n\n            ConfigKey::FRONTEND_COLOR_BACKGROUND => \"#1a1a1a\",\n            ConfigKey::FRONTEND_COLOR_TEXT => \"#e8e8e8\",\n            ConfigKey::FRONTEND_COLOR_ACCENT => \"#5cb85c\",\n            ConfigKey::FRONTEND_COLOR_ERROR => \"#f62451\",\n\n            ConfigKey::WORKER_REQUESTS => 500,\n\n            default => null\n        };\n    }\n\n    /**\n     * Get environment variable name\n     *\n     * @return string\n     */\n    public function getEnvironmentVariable(): string\n    {\n        return \"MCLOGS_\" . $this->name;\n    }\n\n    /**\n     * @return array\n     */\n    public function getJSONPath(): array\n    {\n        $parts = explode(\"_\", $this->name);\n        return array_map(fn($part) => strtolower($part), $parts);\n    }\n}\n"
  },
  {
    "path": "src/Data/Deobfuscator.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse Aternos\\Codex\\Analysis\\Information;\nuse Aternos\\Codex\\Log\\AnalysableLog;\nuse Aternos\\Codex\\Log\\LogInterface;\nuse Aternos\\Codex\\Minecraft\\Analysis\\Information\\Vanilla\\VanillaVersionInformation;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\Fabric\\FabricLog;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\VanillaClientLog;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\VanillaCrashReportLog;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\VanillaLog;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\VanillaNetworkProtocolErrorReportLog;\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\Vanilla\\VanillaServerLog;\nuse Aternos\\Mclogs\\Cache\\CacheEntry;\nuse Aternos\\Sherlock\\MapLocator\\FabricMavenMapLocator;\nuse Aternos\\Sherlock\\MapLocator\\LauncherMetaMapLocator;\nuse Aternos\\Sherlock\\Maps\\GZURLYarnMap;\nuse Aternos\\Sherlock\\Maps\\ObfuscationMap;\nuse Aternos\\Sherlock\\Maps\\URLVanillaObfuscationMap;\nuse Aternos\\Sherlock\\Maps\\VanillaObfuscationMap;\nuse Aternos\\Sherlock\\Maps\\YarnMap;\nuse Aternos\\Sherlock\\ObfuscatedString;\nuse Exception;\n\nclass Deobfuscator\n{\n    public function __construct(protected LogInterface $codexLog)\n    {\n    }\n\n    public function deobfuscate(): ?string\n    {\n        if (!$this->codexLog instanceof AnalysableLog) {\n            return null;\n        }\n        if (!$this->codexLog instanceof VanillaLog) {\n            return null;\n        }\n        $analysis = $this->codexLog->analyse();\n\n        /**\n         * @var ?Information $version\n         */\n        $version = $analysis->getFilteredInsights(VanillaVersionInformation::class)[0] ?? null;\n        if (!$version) {\n            return null;\n        }\n        $version = $version->getValue();\n\n        try {\n            $map = $this->getObfuscationMap($version);\n        } catch (Exception) {\n            $map = null;\n        }\n\n        if ($map === null) {\n            return null;\n        }\n\n        $obfuscatedContent = new ObfuscatedString($this->codexLog->getLogFile()->getContent(), $map);\n        if ($content = $obfuscatedContent->getMappedContent()) {\n            return $content;\n        }\n        return null;\n    }\n\n    /**\n     * Get the obfuscation map matching this log\n     *\n     * @param $version\n     * @return ObfuscationMap|null\n     * @throws Exception\n     */\n    protected function getObfuscationMap($version): ?ObfuscationMap\n    {\n        if (in_array(get_class($this->codexLog), [\n            VanillaServerLog::class,\n            VanillaClientLog::class,\n            VanillaCrashReportLog::class,\n            VanillaNetworkProtocolErrorReportLog::class\n        ])) {\n            $urlCache = new CacheEntry(\"sherlock:vanilla:$version:client\");\n\n            $mapURL = $urlCache->get();\n            if (!$mapURL) {\n                $mapURL = new LauncherMetaMapLocator($version, \"client\")->findMappingURL();\n\n                if (!$mapURL) {\n                    return null;\n                }\n\n                $urlCache->set($mapURL, 30 * 24 * 60 * 60);\n            }\n\n            try {\n                $mapCache = new CacheEntry(\"sherlock:$mapURL\");\n                if ($mapContent = $mapCache->get()) {\n                    $map = new VanillaObfuscationMap($mapContent);\n                } else {\n                    $map = new URLVanillaObfuscationMap($mapURL);\n                    $mapCache->set($map->getContent());\n                }\n            } catch (Exception) {\n            }\n            return $map ?? null;\n        }\n\n        if ($this->codexLog instanceof FabricLog) {\n            $urlCache = new CacheEntry(\"sherlock:yarn:$version:server\");\n\n            $mapURL = $urlCache->get();\n            if (!$mapURL) {\n                $mapURL = new FabricMavenMapLocator($version)->findMappingURL();\n\n                if (!$mapURL) {\n                    return null;\n                }\n\n                $urlCache->set($mapURL, 24 * 60 * 60);\n            }\n\n            try {\n                $mapCache = new CacheEntry(\"sherlock:$mapURL\");\n                if ($mapContent = $mapCache->get()) {\n                    $map = new YarnMap($mapContent);\n                } else {\n                    $map = new GZURLYarnMap($mapURL);\n                    $mapCache->set($map->getContent());\n                }\n            } catch (Exception) {\n            }\n            return $map ?? null;\n        }\n\n        return null;\n    }\n}"
  },
  {
    "path": "src/Data/MetadataEntry.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse MongoDB\\BSON\\Serializable;\nuse MongoDB\\Model\\BSONDocument;\n\nclass MetadataEntry implements \\JsonSerializable, Serializable\n{\n    public const int MAX_ENTRIES = 100;\n    protected const int MAX_KEY_LENGTH = 64;\n    protected const int MAX_LABEL_LENGTH = 128;\n    protected const int MAX_VALUE_LENGTH = 1024;\n\n    protected ?string $key = null;\n    protected mixed $value = null;\n    protected ?string $label = null;\n    protected bool $visible = true;\n\n    /**\n     * @param iterable|null $dataArray\n     * @return MetadataEntry[]\n     */\n    public static function allFromArray(?iterable $dataArray): array\n    {\n        if ($dataArray === null) {\n            return [];\n        }\n        $entries = [];\n        foreach ($dataArray as $data) {\n            if (is_array($data)) {\n                $entry = static::fromArray($data);\n            } else if (is_object($data)) {\n                $entry = static::fromObject($data);\n            } else {\n                continue;\n            }\n            if ($entry !== null) {\n                $entries[] = $entry;\n            }\n            if (count($entries) >= static::MAX_ENTRIES) {\n                break;\n            }\n        }\n        return $entries;\n    }\n\n    /**\n     * @param array $data\n     * @return MetadataEntry|null\n     */\n    public static function fromArray(array $data): ?MetadataEntry\n    {\n        $entry = new MetadataEntry()->setFromArray($data);\n        if (!$entry->isValid()) {\n            return null;\n        }\n        return $entry;\n    }\n\n    /**\n     * @param object $data\n     * @return MetadataEntry|null\n     */\n    public static function fromObject(object $data): ?MetadataEntry\n    {\n        if ($data instanceof BSONDocument) {\n            $arrayData = $data->getArrayCopy();\n        } else {\n            $arrayData = get_object_vars($data);\n        }\n        return static::fromArray($arrayData);\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            \"key\" => $this->key,\n            \"value\" => $this->value,\n            \"label\" => $this->label,\n            \"visible\" => $this->visible,\n        ];\n    }\n\n    public function bsonSerialize(): array\n    {\n        return $this->jsonSerialize();\n    }\n\n    public function getKey(): ?string\n    {\n        return $this->key;\n    }\n\n    public function setKey(?string $key): static\n    {\n        if (is_string($key) && strlen($key) > static::MAX_KEY_LENGTH) {\n            $key = substr($key, 0, static::MAX_KEY_LENGTH);\n        }\n        $this->key = $key;\n        return $this;\n    }\n\n    public function getValue(): mixed\n    {\n        return $this->value;\n    }\n\n    /**\n     * @param mixed $value\n     * @return $this\n     */\n    public function setValue(mixed $value): static\n    {\n        if (is_string($value)) {\n            if (strlen($value) > static::MAX_VALUE_LENGTH) {\n                $value = substr($value, 0, static::MAX_VALUE_LENGTH);\n            }\n            $this->value = $value;\n            return $this;\n        }\n        if (is_int($value) || is_float($value) || is_bool($value) || is_null($value)) {\n            $this->value = $value;\n            return $this;\n        }\n        $encodedValue = @json_encode($value);\n        if ($encodedValue === false) {\n            $this->value = null;\n            return $this;\n        }\n        if (strlen($encodedValue) > static::MAX_VALUE_LENGTH) {\n            $encodedValue = substr($encodedValue, 0, static::MAX_VALUE_LENGTH);\n        }\n        $this->value = $encodedValue;\n        return $this;\n    }\n\n    public function getLabel(): ?string\n    {\n        return $this->label;\n    }\n\n    public function getDisplayLabel(): ?string\n    {\n        return $this->label ?? $this->key;\n    }\n\n    public function getDisplayValue(): string\n    {\n        return $this->value;\n    }\n\n    public function setLabel(?string $label): static\n    {\n        if (is_string($label) && strlen($label) > static::MAX_LABEL_LENGTH) {\n            $label = substr($label, 0, static::MAX_LABEL_LENGTH);\n        }\n        $this->label = $label;\n        return $this;\n    }\n\n    public function isVisible(): bool\n    {\n        return $this->visible;\n    }\n\n    public function setVisible(bool $visible): static\n    {\n        $this->visible = $visible;\n        return $this;\n    }\n\n    public function isValid(): bool\n    {\n        return $this->key !== null && $this->value !== null;\n    }\n\n    /**\n     * @param array $data\n     * @return $this\n     */\n    public function setFromArray(array $data): static\n    {\n        if (isset($data['key']) && is_string($data['key'])) {\n            $this->setKey($data['key']);\n        }\n        if (isset($data['value'])) {\n            $this->setValue($data['value']);\n        }\n        if (isset($data['label']) && is_string($data['label'])) {\n            $this->setLabel($data['label']);\n        }\n        if (isset($data['visible']) && is_bool($data['visible'])) {\n            $this->setVisible($data['visible']);\n        }\n        return $this;\n    }\n}\n"
  },
  {
    "path": "src/Data/Token.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Data;\n\nuse Random\\RandomException;\n\nclass Token implements \\JsonSerializable\n{\n    public function __construct(protected ?string $value = null)\n    {\n        if ($this->value === null) {\n            $this->generate();\n        }\n    }\n\n    /**\n     * @param string $token\n     * @return bool\n     */\n    public function matches(string $token): bool\n    {\n        return hash_equals($this->value, $token);\n    }\n\n    public function jsonSerialize(): string\n    {\n        return $this->value;\n    }\n\n    /**\n     * @throws RandomException\n     */\n    protected function generate(): void\n    {\n        $this->value = bin2hex(random_bytes(32));\n    }\n\n    public function get(): ?string\n    {\n        return $this->value;\n    }\n}"
  },
  {
    "path": "src/Detective.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Codex\\Minecraft\\Log\\Minecraft\\MinecraftLog;\n\nclass Detective extends \\Aternos\\Codex\\Detective\\Detective\n{\n    protected string $defaultLogClass = MinecraftLog::class;\n\n    public function __construct()\n    {\n        $this->addDetective(new \\Aternos\\Codex\\Minecraft\\Detective\\Detective())\n            ->addDetective(new \\Aternos\\Codex\\Hytale\\Detective\\Detective());\n    }\n}"
  },
  {
    "path": "src/Filter/AccessTokenFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass AccessTokenFilter extends RegexFilter\n{\n    /**\n     * @inheritDoc\n     */\n    protected function getPatterns(): array\n    {\n        return [\n            new PatternWithReplacement('\\(Session ID is token:[^:]+\\:[^)]+\\)', '(Session ID is token:****************:****************)'),\n            new PatternWithReplacement('--accessToken [^ ]+', '--accessToken ****************:****************'),\n            new PatternWithReplacement('\"authToken\":\"[^\"]+\"', '\"authToken\":\"****************\"'),\n            new PatternWithReplacement('\"refreshToken\":\"[^\"]+\"', '\"refreshToken\":\"****************\"'),\n        ];\n    }\n}\n"
  },
  {
    "path": "src/Filter/Filter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nabstract class Filter implements \\JsonSerializable\n{\n    /**\n     * @var Filter[]|null\n     */\n    protected static ?array $filter = null;\n\n    /**\n     * Get all filters\n     *\n     * @return Filter[]\n     */\n    public static function getAll(): array\n    {\n        if (static::$filter !== null) {\n            return static::$filter;\n        }\n        return static::$filter = [\n            new TrimFilter(),\n            new LimitBytesFilter(),\n            new LimitLinesFilter(),\n            new IPv4Filter(),\n            new IPv6Filter(),\n            new UsernameFilter(),\n            new AccessTokenFilter(),\n        ];\n    }\n\n    /**\n     * Filter the $data string with all filters and return it\n     *\n     * @param string $data\n     * @return string\n     */\n    public static function filterAll(string $data): string\n    {\n        foreach (static::getAll() as $filter) {\n            $data = $filter->filter($data);\n        }\n        return $data;\n    }\n\n    /**\n     * @return FilterType\n     */\n    abstract public function getType(): FilterType;\n\n    /**\n     * @return array\n     */\n    abstract public function getData(): mixed;\n\n    /**\n     * @return array\n     */\n    public function jsonSerialize(): array\n    {\n        return [\n            \"type\" => $this->getType()->value,\n            \"data\" => $this->getData(),\n        ];\n    }\n\n    /**\n     * Filter the $data string and return it\n     *\n     * @param string $data\n     * @return string\n     */\n    abstract public function filter(string $data): string;\n}"
  },
  {
    "path": "src/Filter/FilterType.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nenum FilterType: string\n{\n    case TRIM = 'trim';\n    case LIMIT_BYTES = 'limit-bytes';\n    case LIMIT_LINES = 'limit-lines';\n    case REGEX = 'regex';\n}\n"
  },
  {
    "path": "src/Filter/IPv4Filter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass IPv4Filter extends RegexFilter\n{\n    /**\n     * @inheritDoc\n     */\n    protected function getPatterns(): array\n    {\n        return [\n            new PatternWithReplacement('(?<!version:? )(?<!([0-9]|-|\\w))([0-9]{1,3}\\.){3}[0-9]{1,3}(?![0-9])', '**.**.**.**'),\n        ];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    protected function getExemptions(): array\n    {\n        return [\n            new Pattern('^127\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$'),\n            new Pattern('^0\\.0\\.0\\.0$'),\n            new Pattern('^1\\.[01]\\.[01]\\.1$'),\n            new Pattern('^8\\.8\\.[84]\\.[84]$'),\n        ];\n    }\n}"
  },
  {
    "path": "src/Filter/IPv6Filter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass IPv6Filter extends RegexFilter\n{\n    /**\n     * @inheritDoc\n     */\n    protected function getPatterns(): array\n    {\n        return [\n            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)',\n            '****:****:****:****:****:****:****:****')\n        ];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    protected function getExemptions(): array\n    {\n        return [\n            new Pattern('^[0:]+1?$')\n        ];\n    }\n}"
  },
  {
    "path": "src/Filter/LimitBytesFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass LimitBytesFilter extends Filter\n{\n    /**\n     * Filter the $data string and return it\n     *\n     * Cuts the length down to maxLength\n     *\n     * @param string $data\n     * @return string\n     */\n    public function filter(string $data): string\n    {\n        $lengthLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES);\n        return mb_strcut($data, 0, $lengthLimit);\n    }\n\n    /**\n     * @return FilterType\n     */\n    public function getType(): FilterType\n    {\n        return FilterType::LIMIT_BYTES;\n    }\n\n    /**\n     * @return array\n     */\n    public function getData(): array\n    {\n        return [\n            \"limit\" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES)\n        ];\n    }\n}"
  },
  {
    "path": "src/Filter/LimitLinesFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass LimitLinesFilter extends Filter\n{\n    /**\n     * Filter the $data string and return it\n     *\n     * Cuts the lines down to maxLines\n     *\n     * @param string $data\n     * @return string\n     */\n    public function filter(string $data): string\n    {\n        $linesLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES);\n        return implode(\"\\n\", array_slice(explode(\"\\n\", $data), 0, $linesLimit));\n    }\n\n    /**\n     * @return FilterType\n     */\n    public function getType(): FilterType\n    {\n        return FilterType::LIMIT_LINES;\n    }\n\n    /**\n     * @return array\n     */\n    public function getData(): array\n    {\n        return [\n            \"limit\" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES)\n        ];\n    }\n}"
  },
  {
    "path": "src/Filter/Pattern/Modifier.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\n/**\n * https://www.php.net/manual/en/reference.pcre.pattern.modifiers.php\n */\nenum Modifier: string implements \\JsonSerializable\n{\n    case CASELESS = 'i';\n\n    public function jsonSerialize(): string\n    {\n        return $this->value;\n    }\n}"
  },
  {
    "path": "src/Filter/Pattern/Pattern.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\nclass Pattern implements \\JsonSerializable\n{\n    protected const string DELIMITER = '/';\n\n    /**\n     * @param string $pattern\n     * @param Modifier[] $modifiers\n     */\n    public function __construct(\n        protected string $pattern,\n        protected array  $modifiers = [Modifier::CASELESS]\n    )\n    {\n    }\n\n    /**\n     * Get the full regex pattern with delimiters and modifiers\n     *\n     * @return string\n     */\n    public function get(): string\n    {\n        $modifiersString = '';\n        foreach ($this->modifiers as $modifier) {\n            $modifiersString .= $modifier->value;\n        }\n        return static::DELIMITER . $this->pattern . static::DELIMITER . $modifiersString;\n    }\n\n    public function getPattern(): string\n    {\n        return $this->pattern;\n    }\n\n    public function getModifiers(): array\n    {\n        return $this->modifiers;\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'pattern' => $this->getPattern(),\n            'modifiers' => $this->getModifiers()\n        ];\n    }\n}"
  },
  {
    "path": "src/Filter/Pattern/PatternWithReplacement.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter\\Pattern;\n\nclass PatternWithReplacement extends Pattern\n{\n    public function __construct(string $pattern, protected string $replacement, array $modifiers = [Modifier::CASELESS])\n    {\n        parent::__construct($pattern, $modifiers);\n    }\n\n    public function getReplacement(): string\n    {\n        return $this->replacement;\n    }\n\n    public function jsonSerialize(): array\n    {\n        return array_merge(\n            parent::jsonSerialize(),\n            [\n                'replacement' => $this->getReplacement()\n            ]\n        );\n    }\n}"
  },
  {
    "path": "src/Filter/RegexFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\Pattern;\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nabstract class RegexFilter extends Filter\n{\n    /**\n     * @return PatternWithReplacement[]\n     */\n    abstract protected function getPatterns(): array;\n\n    /**\n     * @return Pattern[]\n     */\n    protected function getExemptions(): array\n    {\n        return [];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getType(): FilterType\n    {\n        return FilterType::REGEX;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function getData(): array\n    {\n        return [\n            \"patterns\" => $this->getPatterns(),\n            \"exemptions\" => $this->getExemptions(),\n        ];\n    }\n\n    /**\n     * @inheritDoc\n     */\n    public function filter(string $data): string\n    {\n        foreach ($this->getPatterns() as $pattern) {\n            $data = preg_replace_callback($pattern->get(), function ($matches) use ($pattern) {\n                foreach ($this->getExemptions() as $exemptionPattern) {\n                    if (preg_match($exemptionPattern->get(), $matches[0])) {\n                        return $matches[0];\n                    }\n                }\n                return $pattern->getReplacement();\n            }, $data);\n        }\n        return $data;\n    }\n}"
  },
  {
    "path": "src/Filter/TrimFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nclass TrimFilter extends Filter\n{\n    /**\n     * Filter the $data string and return it\n     *\n     * Trim pre and after whitespace\n     *\n     * @param string $data\n     * @return string\n     */\n    public function filter(string $data): string\n    {\n        return trim($data);\n    }\n\n    public function getType(): FilterType\n    {\n        return FilterType::TRIM;\n    }\n\n    public function getData(): object\n    {\n        return new \\stdClass();\n    }\n}"
  },
  {
    "path": "src/Filter/UsernameFilter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Filter;\n\nuse Aternos\\Mclogs\\Filter\\Pattern\\PatternWithReplacement;\n\nclass UsernameFilter extends RegexFilter\n{\n    /**\n     * @inheritDoc\n     */\n    protected function getPatterns(): array\n    {\n        return [\n            new PatternWithReplacement(\"C:\\\\\\\\Users\\\\\\\\([^\\\\\\\\]+)\\\\\\\\\", \"C:\\\\Users\\\\********\\\\\"), // windows\n            new PatternWithReplacement(\"C:\\\\\\\\\\\\\\\\Users\\\\\\\\\\\\\\\\([^\\\\\\\\]+)\\\\\\\\\\\\\\\\\", \"C:\\\\\\\\Users\\\\\\\\********\\\\\\\\\"), // windows with double backslashes\n            new PatternWithReplacement(\"C:\\\\/Users\\\\/([^\\\\/]+)\\\\/\", \"C:/Users/********/\"), // windows with forward slashes\n            new PatternWithReplacement(\"(?<!\\\\w)\\\\/home\\\\/[^\\\\/]+\\\\/\", \"/home/********/\"), // linux\n            new PatternWithReplacement(\"(?<!\\\\w)\\\\/Users\\\\/[^\\\\/]+\\\\/\", \"/Users/********/\"), // macos\n            new PatternWithReplacement(\"USERNAME=\\\\w+\", \"USERNAME=********\"), // environment variable\n        ];\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/ApiDocsAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass ApiDocsAction extends Action\n{\n    public function run(): bool\n    {\n        require __DIR__ . \"/../../../web/frontend/api-docs.php\";\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/CreateLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass CreateLogAction extends \\Aternos\\Mclogs\\Api\\Action\\CreateLogAction\n{\n    protected bool $includeCookie = true;\n    protected bool $includeToken = false;\n\n    protected function getAllowedOrigin(): string\n    {\n        return URL::getBase()->toString();\n    }\n\n    protected function shouldAllowCredentials(): bool\n    {\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/DeleteLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Frontend\\Cookie\\TokenCookie;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass DeleteLogAction extends \\Aternos\\Mclogs\\Api\\Action\\DeleteLogAction\n{\n    protected function getAllowedOrigin(): string\n    {\n        return URL::getBase()->toString();\n    }\n\n    protected function shouldAllowCredentials(): bool\n    {\n        return true;\n    }\n\n    protected function getRequestToken(): ?string\n    {\n        return new TokenCookie()->getValue();\n    }\n\n    protected function handleDeletedLog(Log $log): void\n    {\n        new TokenCookie()->setLog($log)->delete();\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/FaviconAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass FaviconAction extends Action\n{\n    public function run(): bool\n    {\n        header('Content-Type: image/svg+xml');\n        require __DIR__ . \"/../../../web/frontend/parts/favicon.php\";\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/NotFoundAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass NotFoundAction extends Action\n{\n    public function run(): bool\n    {\n        http_response_code(404);\n        require __DIR__ . \"/../../../web/frontend/404.php\";\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/StartAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Router\\Action;\n\nclass StartAction extends Action\n{\n    public function run(): bool\n    {\n        require __DIR__ . \"/../../../web/frontend/start.php\";\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Action/ViewLogAction.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Action;\n\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Router\\Action;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass ViewLogAction extends Action\n{\n    public function run(): bool\n    {\n        $id = new Id(URL::getLastPathPart());\n        $log = Log::find($id);\n        if (!$log) {\n            return false;\n        }\n\n        $log->renew();\n\n        require __DIR__ . \"/../../../web/frontend/log.php\";\n        return true;\n    }\n}"
  },
  {
    "path": "src/Frontend/Assets/Asset.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Assets;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass Asset implements \\JsonSerializable\n{\n    protected const string HASH_ALGORITHM = 'sha384';\n\n    /**\n     * @param object $data\n     * @return static|null\n     */\n    public static function fromObject(object $data): ?static\n    {\n        if (!isset($data->type) || !isset($data->path) || !isset($data->hash)) {\n            return null;\n        }\n\n        $type = AssetType::tryFrom($data->type);\n        if ($type === null) {\n            return null;\n        }\n\n        return new static($type, $data->path, $data->hash);\n    }\n\n    public function __construct(\n        protected AssetType $type,\n        protected string    $path,\n        protected ?string   $hash = null)\n    {\n        $this->path = ltrim($this->path, '/');\n    }\n\n    public function getPath(): string\n    {\n        return $this->path;\n    }\n\n    public function getPathWithVersion(): string\n    {\n        return $this->path . '?v=' . rawurlencode(substr($this->getHash(), 0, 16));\n    }\n\n    protected function getAbsoluteBasePath(): string\n    {\n        return __DIR__ . \"/../../../web/public/\";\n    }\n\n    protected function getAbsolutePath(): string\n    {\n        return $this->getAbsoluteBasePath() . $this->path;\n    }\n\n    protected function buildHash(): string\n    {\n        return hash_file(static::HASH_ALGORITHM, $this->getAbsolutePath());\n    }\n\n    protected function getHash(): string\n    {\n        if ($this->hash === null) {\n            return $this->buildHash();\n        }\n        return $this->hash;\n    }\n\n    protected function getBase64Hash(): string\n    {\n        return base64_encode(hex2bin($this->getHash()));\n    }\n\n    public function jsonSerialize(): array\n    {\n        return [\n            'type' => $this->getType()->value,\n            'path' => $this->getPath(),\n            'hash' => $this->getHash(),\n        ];\n    }\n\n    public function getType(): AssetType\n    {\n        return $this->type;\n    }\n\n    public function getHTML(): string\n    {\n        return match ($this->type) {\n            AssetType::CSS => '<link rel=\"stylesheet\" href=\"/' . $this->getPathWithVersion() . '\"' . $this->getIntegrityAttribute() . ' />',\n            AssetType::JS => '<script src=\"/' . $this->getPathWithVersion() . '\"' . $this->getIntegrityAttribute() . '></script>'\n        };\n    }\n\n    protected function getIntegrityAttribute(): string\n    {\n        if (!Config::getInstance()->get(ConfigKey::FRONTEND_ASSETS_INTEGRITY)) {\n            return '';\n        }\n        return ' integrity=\"' . static::HASH_ALGORITHM . '-' . $this->getBase64Hash() . '\"';\n    }\n}"
  },
  {
    "path": "src/Frontend/Assets/AssetLoader.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Assets;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\n\nclass AssetLoader\n{\n    use Singleton;\n\n    protected const string CACHE_PATH = __DIR__ . \"/../../../assets.cache\";\n\n    /**\n     * @var Asset[]\n     */\n    protected array $cachedAssets = [];\n\n    protected function __construct()\n    {\n        $this->loadCache();\n    }\n\n    /**\n     * @param AssetType $assetType\n     * @param string $path\n     * @return string\n     */\n    public function getHTML(AssetType $assetType, string $path): string\n    {\n        return $this->getAsset($assetType, $path)->getHTML();\n    }\n\n    /**\n     * @param AssetType $assetType\n     * @param string $path\n     * @return Asset\n     */\n    protected function getAsset(AssetType $assetType, string $path): Asset\n    {\n        $cachedAsset = $this->findCachedAsset($assetType, $path);\n        if ($cachedAsset !== null) {\n            return $cachedAsset;\n        }\n        return new Asset($assetType, $path);\n    }\n\n    /**\n     * @param AssetType $assetType\n     * @param string $path\n     * @return Asset|null\n     */\n    protected function findCachedAsset(AssetType $assetType, string $path): ?Asset\n    {\n        foreach ($this->cachedAssets as $asset) {\n            if ($asset->getPath() === $path && $asset->getType() === $assetType) {\n                return $asset;\n            }\n        }\n        return null;\n    }\n\n    protected function loadCache(): void\n    {\n        if (!file_exists(self::CACHE_PATH)) {\n            return;\n        }\n\n        $content = file_get_contents(self::CACHE_PATH);\n        if ($content === false) {\n            return;\n        }\n\n        $data = json_decode($content);\n        if (!is_array($data)) {\n            return;\n        }\n\n        foreach ($data as $assetData) {\n            if (!is_object($assetData)) {\n                continue;\n            }\n            $asset = Asset::fromObject($assetData);\n            if ($asset === null) {\n                continue;\n            }\n            $this->cachedAssets[] = $asset;\n        }\n    }\n\n    public function writeCache(): void\n    {\n        $assets = [\n            new Asset(AssetType::CSS, \"css/mclogs.css\"),\n            new Asset(AssetType::JS, \"js/start.js\"),\n            new Asset(AssetType::JS, \"js/log.js\"),\n            new Asset(AssetType::CSS, \"vendor/fontawesome/css/fontawesome.min.css\")\n        ];\n\n        file_put_contents(static::CACHE_PATH, json_encode($assets));\n    }\n}"
  },
  {
    "path": "src/Frontend/Assets/AssetType.php",
    "content": "<?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",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nuse Aternos\\Mclogs\\Util\\URL;\n\nabstract class Cookie\n{\n    protected ?string $value = null;\n\n    /**\n     * @return string\n     */\n    abstract protected function getKey(): string;\n\n    /**\n     * @return string\n     */\n    protected function getDomain(): string\n    {\n        return \"\";\n    }\n\n    /**\n     * @return int|null\n     */\n    protected function getMaxAge(): ?int\n    {\n        return null;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getPath(): string\n    {\n        return \"/\";\n    }\n\n    /**\n     * @return bool\n     */\n    protected function isSecure(): bool\n    {\n        return URL::getCurrent()->getScheme() === \"https\";\n    }\n\n    /**\n     * @return bool\n     */\n    protected function isHttpOnly(): bool\n    {\n        return true;\n    }\n\n    /**\n     * @return string\n     */\n    protected function getSameSite(): string\n    {\n        return \"Lax\";\n    }\n\n    public function __construct()\n    {\n        $this->value = $_COOKIE[$this->getKey()] ?? null;\n    }\n\n    /**\n     * @param string $value\n     * @return bool\n     */\n    public function set(string $value): bool\n    {\n        $options = [\n            'expires' => $this->getMaxAge() !== null ? time() + $this->getMaxAge() : 0,\n            'path' => $this->getPath(),\n            'domain' => $this->getDomain(),\n            'secure' => $this->isSecure(),\n            'httponly' => $this->isHttpOnly(),\n            'samesite' => $this->getSameSite()\n        ];\n\n        $result = setcookie(\n            $this->getKey(),\n            $value,\n            $options\n        );\n\n        if ($result) {\n            $this->value = $value;\n        }\n\n        return $result;\n    }\n\n    /**\n     * @return bool\n     */\n    public function delete(): bool\n    {\n        $options = [\n            'expires' => time() - 3600,\n            'path' => $this->getPath(),\n            'domain' => $this->getDomain(),\n            'secure' => $this->isSecure(),\n            'httponly' => $this->isHttpOnly(),\n            'samesite' => $this->getSameSite()\n        ];\n\n        $result = setcookie(\n            $this->getKey(),\n            '',\n            $options\n        );\n\n        if ($result) {\n            $this->value = null;\n        }\n\n        return $result;\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getValue(): ?string\n    {\n        return $this->value;\n    }\n\n    /**\n     * @return bool\n     */\n    public function exists(): bool\n    {\n        return $this->getValue() !== null;\n    }\n}"
  },
  {
    "path": "src/Frontend/Cookie/SettingsCookie.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nclass SettingsCookie extends Cookie\n{\n    /**\n     * @inheritDoc\n     */\n    protected function getKey(): string\n    {\n        return \"MCLOGS_SETTINGS\";\n    }\n}"
  },
  {
    "path": "src/Frontend/Cookie/TokenCookie.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Cookie;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Log;\n\nclass TokenCookie extends Cookie\n{\n    /**\n     * @param Log $log\n     * @return $this\n     */\n    public function setLog(Log $log): static\n    {\n        $this->log = $log;\n        return $this;\n    }\n\n    /**\n     * @inheritDoc\n     */\n    protected function getKey(): string\n    {\n        return \"MCLOGS_LOG_TOKEN\";\n    }\n\n    /**\n     * @param Log|null $log\n     */\n    public function __construct(protected ?Log $log = null)\n    {\n        parent::__construct();\n    }\n\n    /**\n     * @return string\n     */\n    protected function getPath(): string\n    {\n        if (!$this->log) {\n            return \"/\";\n        }\n        return \"/\" . $this->log->getId()->get();\n    }\n\n    protected function getMaxAge(): ?int\n    {\n        return Config::getInstance()->get(ConfigKey::STORAGE_TTL);\n    }\n}"
  },
  {
    "path": "src/Frontend/FrontendRouter.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend;\n\nuse Aternos\\Mclogs\\Router\\Router;\nuse Aternos\\Mclogs\\Id;\nuse Aternos\\Mclogs\\Router\\Method;\n\nclass FrontendRouter extends Router\n{\n    protected function __construct()\n    {\n        parent::__construct();\n        $this->register(Method::GET, \"#^/$#\", new Action\\StartAction())\n            ->register(Method::GET, \"#^/\" . Id::PATTERN . \"$#\", new Action\\ViewLogAction())\n            ->register(Method::POST, \"#^/new$#\", new Action\\CreateLogAction())\n            ->register(Method::DELETE, \"#^/\" . Id::PATTERN . \"$#\", new Action\\DeleteLogAction())\n            ->register(Method::GET, \"#^/favicon\\.svg$#\", new Action\\FaviconAction())\n            ->setDefaultAction(new Action\\NotFoundAction());\n    }\n}"
  },
  {
    "path": "src/Frontend/Settings/Setting.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Settings;\n\nenum Setting: string\n{\n    case FULL_WIDTH = \"fullWidth\";\n    case NO_WRAP = \"noWrap\";\n    case FLOATING_SCROLLBAR = \"floatingScrollbar\";\n    case OVERFLOW = \"overflow\";\n\n\n    /**\n     * @return string\n     */\n    function getLabel(): string\n    {\n        return match ($this) {\n            Setting::FULL_WIDTH => \"Full Width\",\n            Setting::NO_WRAP => \"No Wrap\",\n            Setting::FLOATING_SCROLLBAR => \"Floating Scrollbar\",\n            Setting::OVERFLOW => \"Overflow\"\n        };\n    }\n\n    /**\n     * @return string|null\n     */\n    function getBodyClass(): ?string\n    {\n        return match ($this) {\n            Setting::FULL_WIDTH => \"setting-full-width\",\n            Setting::NO_WRAP => \"setting-no-wrap\",\n            Setting::FLOATING_SCROLLBAR => \"setting-floating-scrollbar\",\n            Setting::OVERFLOW => \"setting-overflow\",\n            default => null\n        };\n    }\n}\n"
  },
  {
    "path": "src/Frontend/Settings/Settings.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Frontend\\Settings;\n\nuse Aternos\\Mclogs\\Frontend\\Cookie\\SettingsCookie;\n\nclass Settings\n{\n    /**\n     * @var array<string, mixed>\n     */\n    protected array $data = [];\n\n    public function __construct()\n    {\n        $rawData = new SettingsCookie()->getValue();\n        if ($rawData) {\n            $parsedData = json_decode($rawData, true);\n            if (is_array($parsedData)) {\n                $this->data = $parsedData;\n            }\n        }\n    }\n\n    /**\n     * @param Setting $key\n     * @return bool\n     */\n    public function get(Setting $key): bool\n    {\n        $value = $this->data[$key->value] ?? false;\n        if (is_bool($value)) {\n            return $value;\n        }\n        return false;\n    }\n\n    /**\n     * @return string[]\n     */\n    public function getBodyClasses(): array\n    {\n        $classes = [];\n        foreach (Setting::cases() as $setting) {\n            if ($this->get($setting)) {\n                $bodyClass = $setting->getBodyClass();\n                if ($bodyClass) {\n                    $classes[] = $bodyClass;\n                }\n            }\n        }\n        return $classes;\n    }\n\n    /**\n     * @return string\n     */\n    public function getBodyClassesString(): string\n    {\n        $classes = $this->getBodyClasses();\n        if (empty($classes)) {\n            return \"\";\n        }\n        return \" \" . implode(\" \", $this->getBodyClasses());\n    }\n}"
  },
  {
    "path": "src/Id.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Mclogs\\Config\\ConfigKey;\n\nclass Id implements \\JsonSerializable\n{\n    public const string PATTERN = '[a-zA-Z0-9]+';\n    protected const string CHARACTERS = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\";\n\n    /**\n     * @param string|null $id\n     */\n    public function __construct(protected ?string $id = null)\n    {\n        if ($this->id === null) {\n            $this->generate();\n        }\n    }\n\n    /**\n     * Generates a new id\n     *\n     * @return string\n     */\n    protected function generate(): string\n    {\n        $config = \\Aternos\\Mclogs\\Config\\Config::getInstance();\n        $idLength = $config->get(ConfigKey::ID_LENGTH);\n\n        $newId = \"\";\n        for ($i = 0; $i < $idLength; $i++) {\n            $newId .= static::CHARACTERS[rand(0, strlen(static::CHARACTERS) - 1)];\n        }\n\n        return $this->id = $newId;\n    }\n\n    /**\n     * @return string\n     */\n    public function get(): string\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return string\n     */\n    public function __toString(): string\n    {\n        return $this->id;\n    }\n\n    public function jsonSerialize(): string\n    {\n        return $this->id;\n    }\n}"
  },
  {
    "path": "src/Log.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs;\n\nuse Aternos\\Codex\\Analysis\\Analysis;\nuse Aternos\\Codex\\Log\\AnalysableLogInterface;\nuse Aternos\\Codex\\Log\\File\\StringLogFile;\nuse Aternos\\Codex\\Log\\Level;\nuse Aternos\\Codex\\Log\\LogInterface;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Data\\Deobfuscator;\nuse Aternos\\Mclogs\\Data\\MetadataEntry;\nuse Aternos\\Mclogs\\Data\\Token;\nuse Aternos\\Mclogs\\Filter\\Filter;\nuse Aternos\\Mclogs\\Frontend\\Cookie\\TokenCookie;\nuse Aternos\\Mclogs\\Printer\\Printer;\nuse Aternos\\Mclogs\\Storage\\MongoDBClient;\nuse Aternos\\Mclogs\\Util\\URL;\nuse MongoDB\\BSON\\UTCDateTime;\nuse Uri\\Rfc3986\\Uri;\n\nclass Log\n{\n    protected const int SOURCE_MAX_LENGTH = 64;\n\n    protected ?string $source = null;\n    protected ?UTCDateTime $expires = null;\n    protected ?UTCDateTime $created = null;\n    protected ?Token $token = null;\n\n    /**\n     * @var MetadataEntry[]\n     */\n    protected array $metadata = [];\n\n    protected ?LogInterface $log = null;\n    protected ?Printer $printer = null;\n\n    /**\n     * Find a log by its id\n     *\n     * @param Id $id\n     * @param bool $includeContent\n     * @return static|null\n     */\n    public static function find(Id $id, bool $includeContent = true): ?static\n    {\n        $data = MongoDBClient::getInstance()->findLog($id, $includeContent);\n        if ($data === null) {\n            return null;\n        }\n\n        return static::fromObject($id, $data);\n    }\n\n    /**\n     * @param (string|Id)[] $ids\n     * @param bool $includeContent\n     * @return array<string, Log>\n     */\n    public static function findAll(array $ids, bool $includeContent = true): array\n    {\n        $ids = array_map(fn($id) => (string)$id, $ids);\n        $objects = MongoDBClient::getInstance()->findLogs($ids, $includeContent);\n        $logs = [];\n        foreach ($objects as $data) {\n            $id = new Id($data->_id);\n            $logs[$id->get()] = static::fromObject($id, $data);\n        }\n        return $logs;\n    }\n\n    /**\n     * @param Id $id\n     * @param object $data\n     * @return static\n     */\n    protected static function fromObject(Id $id, object $data): static\n    {\n        return new static($id)\n            ->setContent($data->data ?? \"\")\n            ->setToken(isset($data->token) ? new Token($data->token) : null)\n            ->setMetadata(MetadataEntry::allFromArray($data->metadata ?? []))\n            ->setSource($data->source ?? null)\n            ->setCreated($data->created ?? null)\n            ->setExpires($data->expires ?? null);\n    }\n\n    /**\n     * Create and save a new log\n     *\n     * @param string $content\n     * @param MetadataEntry[] $metadata\n     * @param string|null $source\n     * @return static\n     */\n    public static function create(string $content, array $metadata = [], ?string $source = null): static\n    {\n        return new static()\n            ->setMetadata($metadata)\n            ->setSource($source)\n            ->setToken(new Token())\n            ->save($content);\n    }\n\n    /**\n     * @param Id|null $id\n     */\n    public function __construct(protected ?Id $id = null)\n    {\n    }\n\n    /**\n     * @param Token|null $token\n     * @return $this\n     */\n    public function setToken(?Token $token): static\n    {\n        $this->token = $token;\n        return $this;\n    }\n\n    /**\n     * @param MetadataEntry[] $metadata\n     * @return $this\n     */\n    public function setMetadata(array $metadata): static\n    {\n        $this->metadata = $metadata;\n        return $this;\n    }\n\n    /**\n     * @param MetadataEntry $metadataEntry\n     * @return $this\n     */\n    public function addMetadata(MetadataEntry $metadataEntry): static\n    {\n        $this->metadata[] = $metadataEntry;\n        return $this;\n    }\n\n    /**\n     * @param string|null $source\n     * @return $this\n     */\n    public function setSource(?string $source): static\n    {\n        if (is_string($source) && strlen($source) > static::SOURCE_MAX_LENGTH) {\n            $source = substr($source, 0, static::SOURCE_MAX_LENGTH);\n        }\n        $this->source = $source;\n        return $this;\n    }\n\n    /**\n     * @return string|null\n     */\n    public function getSource(): ?string\n    {\n        return $this->source;\n    }\n\n    /**\n     * @param UTCDateTime|null $created\n     * @return $this\n     */\n    public function setCreated(?UTCDateTime $created): static\n    {\n        $this->created = $created;\n        return $this;\n    }\n\n    /**\n     * @param UTCDateTime|null $expires\n     * @return $this\n     */\n    public function setExpires(?UTCDateTime $expires): static\n    {\n        $this->expires = $expires;\n        return $this;\n    }\n\n    /**\n     * @return UTCDateTime|null\n     */\n    public function getCreated(): ?UTCDateTime\n    {\n        return $this->created;\n    }\n\n    /**\n     * @return UTCDateTime|null\n     */\n    public function getExpires(): ?UTCDateTime\n    {\n        return $this->expires;\n    }\n\n    /**\n     * @param string $content\n     * @return $this\n     */\n    public function setContent(string $content): static\n    {\n        $this->processAndDeobfuscate($content);\n        return $this;\n    }\n\n    public function getContent(): string\n    {\n        return $this->log->getLogFile()->getContent();\n    }\n\n    protected function processAndDeobfuscate(string $data): void\n    {\n        $this->process($data);\n        $deobfuscator = new Deobfuscator($this->getCodexLog());\n        if ($deobfuscatedData = $deobfuscator->deobfuscate()) {\n            $this->process($deobfuscatedData);\n        }\n    }\n\n    protected function process($data): void\n    {\n        $this->log = new Detective()->setLogFile(new StringLogFile($data))->detect();\n        $this->log->parse();\n        if ($this->log instanceof AnalysableLogInterface) {\n            $this->log->analyse();\n        }\n    }\n\n    /**\n     * Get the codex log object\n     *\n     * @return LogInterface\n     */\n    public function getCodexLog(): LogInterface\n    {\n        return $this->log;\n    }\n\n    /**\n     * Get the log analysis\n     *\n     * @return Analysis|null\n     */\n    public function getAnalysis(): ?Analysis\n    {\n        $log = $this->getCodexLog();\n        if ($log instanceof AnalysableLogInterface) {\n            return $log->analyse();\n        }\n        return null;\n    }\n\n    /**\n     * @return Printer\n     */\n    public function getPrinter(): Printer\n    {\n        if ($this->printer === null) {\n            $this->printer = new Printer()->setLog($this->log)->setId($this->id);\n        }\n        return $this->printer;\n    }\n\n    /**\n     * Get the amount of lines in this log\n     *\n     * @return int\n     */\n    public function getLinesCount(): int\n    {\n        $codexLog = $this->getCodexLog();\n        $lines = 0;\n        foreach ($codexLog as $entry) {\n            $lines += count($entry);\n        }\n        return $lines;\n    }\n\n    /**\n     * @return string\n     */\n    public function getLinesString(): string\n    {\n        $lineCount = $this->getLinesCount();\n        return $lineCount . ($lineCount === 1 ? \" line\" : \" lines\");\n    }\n\n    /**\n     * @return int\n     */\n    public function getSize(): int\n    {\n        return strlen($this->getContent());\n    }\n\n    /**\n     * Get the amount of error entries in the log\n     *\n     * @return int\n     */\n    public function getErrorsCount(): int\n    {\n        $errorCount = 0;\n\n        foreach ($this->log as $entry) {\n            if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {\n                $errorCount++;\n            }\n        }\n\n        return $errorCount;\n    }\n\n    /**\n     * @return bool\n     */\n    public function hasErrors(): bool\n    {\n        return $this->getErrorsCount() > 0;\n    }\n\n    /**\n     * @return string\n     */\n    public function getErrorsString(): string\n    {\n        $errorCount = $this->getErrorsCount();\n        return $errorCount . ($errorCount === 1 ? \" error\" : \" errors\");\n    }\n\n    protected function generateId(): Id\n    {\n        do {\n            $this->id = new Id();\n        } while (MongoDBClient::getInstance()->hasLog($this->id));\n        return $this->id;\n    }\n\n    /**\n     * Save the log to the database\n     *\n     * @return $this\n     */\n    public function save(string $content): static\n    {\n        if ($this->id === null) {\n            $this->generateId();\n        }\n\n        $content = Filter::filterAll($content);\n\n        MongoDBClient::getInstance()->getLogsCollection()->insertOne([\n            \"_id\" => $this->id->get(),\n            \"data\" => $content,\n            \"token\" => $this->token?->get(),\n            \"source\" => $this->source,\n            \"metadata\" => $this->metadata,\n            \"expires\" => $this->expires = $this->getExpiryTimestamp(),\n            \"created\" => $this->created = new UTCDateTime()\n        ]);\n\n        return $this->setContent($content);\n    }\n\n    /**\n     * @return UTCDateTime\n     */\n    protected function getExpiryTimestamp(): UTCDateTime\n    {\n        $ttl = \\Aternos\\Mclogs\\Config\\Config::getInstance()->get(ConfigKey::STORAGE_TTL);\n        $expires = time() + $ttl;\n        return new UTCDateTime($expires * 1000);\n    }\n\n    /**\n     * Renew the expiry timestamp to expand the ttl\n     *\n     * @return bool\n     */\n    public function renew(): bool\n    {\n        $expires = $this->getExpiryTimestamp();\n        $result = MongoDBClient::getInstance()->setLogExpires($this->id, $expires);\n        if ($result) {\n            $this->expires = $expires;\n        }\n        return $result;\n    }\n\n    /**\n     * @return Uri\n     */\n    public function getURL(): Uri\n    {\n        return URL::getBase()->withPath(\"/\" . $this->id->get());\n    }\n\n    /**\n     *\n     * @return string\n     */\n    public function getDisplayURL(): string\n    {\n        $url = $this->getURL();\n        return $url->getHost() . $url->getPath();\n    }\n\n    /**\n     * @return Uri\n     */\n    public function getRawURL(): Uri\n    {\n        return URL::getApi()->withPath(\"/1/raw/\" . $this->id->get());\n    }\n\n    /**\n     * @return Id|null\n     */\n    public function getId(): ?Id\n    {\n        return $this->id;\n    }\n\n    /**\n     * @return Token|null\n     */\n    public function getToken(): ?Token\n    {\n        return $this->token;\n    }\n\n    /**\n     * @return bool\n     */\n    public function delete(): bool\n    {\n        return MongoDBClient::getInstance()->deleteLog($this->id->get());\n    }\n\n    /**\n     * @return MetadataEntry[]\n     */\n    public function getMetadata(): array\n    {\n        return $this->metadata;\n    }\n\n    /**\n     * @return MetadataEntry[]\n     */\n    public function getVisibleMetadata(): array\n    {\n        return array_filter($this->metadata, function (MetadataEntry $entry) {\n            return $entry->isVisible();\n        });\n    }\n\n    /**\n     * @return bool\n     */\n    public function setTokenCookie(): bool\n    {\n        if (!$this->getToken()) {\n            return false;\n        }\n        return new TokenCookie($this)->set($this->getToken()->get());\n    }\n\n    /**\n     * @return bool\n     */\n    public function hasValidTokenCookie(): bool\n    {\n        $tokenCookie = new TokenCookie();\n        $cookieValue = $tokenCookie->getValue();\n        if ($cookieValue === null || !$this->getToken()) {\n            return false;\n        }\n        return $this->getToken()->matches($cookieValue);\n    }\n\n    /**\n     * @return string\n     */\n    public function getPageTitle(): string\n    {\n        return $this->getCodexLog()?->getTitle() . \" [#\" . $this->getId()?->get() . \"]\";\n    }\n\n    /**\n     * @return string\n     */\n    public function getPageDescription(): string\n    {\n        $description = $this->getLinesString();\n        if ($this->hasErrors()) {\n            $description .= \" | \" . $this->getErrorsString();\n        }\n\n        $problems = $this->getAnalysis()->getProblems();\n\n        if (count($problems) > 0) {\n            $problemString = \"problems\";\n            if (count($problems) === 1) {\n                $problemString = \"problem\";\n            }\n            $description .= \" | \" . count($problems) . \" \" . $problemString . \" automatically detected\";\n        }\n\n        return $description;\n    }\n}\n"
  },
  {
    "path": "src/Printer/FormatModification.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Printer;\n\n/**\n * Class FormatModification\n *\n * @package Printer\n */\nclass FormatModification extends \\Aternos\\Codex\\Minecraft\\Printer\\FormatModification\n{\n    /**\n     * @param string $format\n     * @return string\n     */\n    protected function getClasses(string $format): string\n    {\n        return \"format format-\" . $format;\n    }\n}"
  },
  {
    "path": "src/Printer/Printer.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Printer;\n\nuse Aternos\\Codex\\Log\\Entry;\nuse Aternos\\Codex\\Log\\EntryInterface;\nuse Aternos\\Codex\\Log\\Level;\nuse Aternos\\Codex\\Log\\LineInterface;\nuse Aternos\\Codex\\Printer\\ModifiableDefaultPrinter;\nuse Aternos\\Mclogs\\Id;\n\n/**\n * Class Printer\n *\n * @package Printer\n */\nclass Printer extends ModifiableDefaultPrinter\n{\n    public function __construct()\n    {\n        $this->addModification(new FormatModification());\n    }\n\n    /**\n     * @var Id\n     */\n    protected Id $id;\n\n    /**\n     * @param Id $id\n     * @return Printer\n     */\n    public function setId(Id $id): static\n    {\n        $this->id = $id;\n        return $this;\n    }\n\n    /**\n     * @return string\n     */\n    protected function printLog(): string\n    {\n        return '<div class=\"log-inner\">' . parent::printLog() . '</div>';\n    }\n\n    /**\n     * @param EntryInterface|null $entry\n     * @return string\n     * @throws \\Exception\n     */\n    protected function printEntry(?EntryInterface $entry = null): string\n    {\n        $entry = $entry ?? $this->entry;\n        /** @var Entry $entry */\n        $return = '';\n        $first = true;\n        foreach ($entry as $line) {\n            $entryClass = \"entry-no-error\";\n            if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {\n                $entryClass = \"entry-error\";\n            }\n            $return .= '<div class=\"entry ' . $entryClass . '\">';\n            $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>';\n            $return .= '<div class=\"line-content\"><span class=\"level level-' . $entry->getLevel()->asString() . ((!$first) ? \" multiline\" : \"\") . '\">';\n            $lineString = $this->printLine($line);\n            if ($entry->getPrefix() !== null) {\n                $prefix = htmlentities($entry->getPrefix());\n                $lineString = str_replace($prefix, '<span class=\"level-prefix\">' . $prefix . '</span>', $lineString);\n            }\n            $return .= $lineString;\n            $return .= '</span></div>';\n            $return .= '</div>';\n            $first = false;\n        }\n\n        return $return;\n    }\n\n    /**\n     * @param LineInterface $line\n     * @return string\n     */\n    protected function printLine(LineInterface $line): string\n    {\n        return $this->runModifications(htmlentities($line->getText())) . PHP_EOL;\n    }\n}\n"
  },
  {
    "path": "src/Router/Action.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nabstract class Action\n{\n    abstract public function run(): bool;\n}"
  },
  {
    "path": "src/Router/Method.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nenum Method: string\n{\n    case GET = 'GET';\n    case POST = 'POST';\n    case PUT = 'PUT';\n    case DELETE = 'DELETE';\n    case OPTIONS = 'OPTIONS';\n\n    public static function getCurrent(): self\n    {\n        return self::tryFrom($_SERVER['REQUEST_METHOD']) ?? self::GET;\n    }\n}"
  },
  {
    "path": "src/Router/Route.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nclass Route\n{\n    public function __construct(\n        protected Method $method,\n        protected string $pattern,\n        protected Action $action\n    )\n    {\n    }\n\n    /**\n     * @param Method $method\n     * @param string $path\n     * @return bool\n     */\n    public function matches(Method $method, string $path): bool\n    {\n        if ($this->getMethod() !== $method) {\n            return false;\n        }\n        return preg_match($this->getPattern(), $path) === 1;\n    }\n\n    /**\n     * @return Method\n     */\n    public function getMethod(): Method\n    {\n        return $this->method;\n    }\n\n    /**\n     * @return string\n     */\n    public function getPattern(): string\n    {\n        return $this->pattern;\n    }\n\n    /**\n     * @return Action\n     */\n    public function getAction(): Action\n    {\n        return $this->action;\n    }\n}"
  },
  {
    "path": "src/Router/Router.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Router;\n\nuse Aternos\\Mclogs\\Util\\Singleton;\nuse Aternos\\Mclogs\\Util\\URL;\n\nclass Router\n{\n    use Singleton;\n\n    /**\n     * @var Route[]\n     */\n    protected array $routes = [];\n\n    protected ?Action $defaultAction = null;\n\n    /**\n     * @param Method $method\n     * @param string $pattern\n     * @param Action $action\n     * @return $this\n     */\n    public function register(Method $method, string $pattern, Action $action): static\n    {\n        $this->routes[] = new Route($method, $pattern, $action);\n        return $this;\n    }\n\n    /**\n     * @param Action $defaultAction\n     * @return $this\n     */\n    public function setDefaultAction(Action $defaultAction): static\n    {\n        $this->defaultAction = $defaultAction;\n        return $this;\n    }\n\n    /**\n     * @return $this\n     */\n    public function run(): static\n    {\n        $route = $this->findRoute();\n        if (!$route) {\n            $this->defaultAction?->run();\n            return $this;\n        }\n        $result = $route->getAction()->run();\n        if (!$result) {\n            $this->defaultAction?->run();\n        }\n        return $this;\n    }\n\n    /**\n     * @return Route|null\n     */\n    protected function findRoute(): ?Route\n    {\n        $path = URL::getCurrent()->getPath();\n        $method = Method::getCurrent();\n\n        foreach ($this->routes as $route) {\n            if ($route->matches($method, $path)) {\n                return $route;\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/Storage/MongoDBClient.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Storage;\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Util\\Singleton;\nuse MongoDB\\BSON\\UTCDateTime;\nuse MongoDB\\Client;\nuse MongoDB\\Collection;\nuse MongoDB\\Database;\nuse Uri\\Rfc3986\\Uri;\n\nclass MongoDBClient\n{\n    use Singleton;\n\n    protected ?Client $connection = null;\n    protected Database $database;\n\n    protected ?Collection $logs = null;\n    protected ?Collection $cache = null;\n\n    /**\n     * @return string\n     */\n    protected function getConnectionURL(): string\n    {\n        $configUrl = Config::getInstance()->get(ConfigKey::MONGODB_URL);\n        $url = new Uri($configUrl);\n        $query = $url->getQuery();\n        $queryParams = [];\n        if ($query !== null) {\n            parse_str($query, $queryParams);\n        }\n        if (!isset($queryParams['serverSelectionTimeoutMS'])) {\n            $queryParams['serverSelectionTimeoutMS'] = 5_000;\n        }\n        if (!isset($queryParams['socketTimeoutMS'])) {\n            $queryParams['socketTimeoutMS'] = 60_000;\n        }\n        $newQuery = http_build_query($queryParams);\n        $newUrl = $url->withQuery($newQuery);\n        return $newUrl->toString();\n    }\n\n    /**\n     * Connect to MongoDB\n     */\n    protected function connect(): void\n    {\n        if ($this->connection === null) {\n            $config = Config::getInstance();\n            $this->connection = new Client($this->getConnectionURL());\n            $this->database = $this->connection->getDatabase($config->get(ConfigKey::MONGODB_DATABASE));\n        }\n    }\n\n    /**\n     * Ensure indexes exist\n     *\n     * @return void\n     */\n    public function ensureIndexes(): void\n    {\n        $logs = $this->getLogsCollection();\n        $logs->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);\n\n        $cache = $this->getCacheCollection();\n        $cache->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);\n    }\n\n    /**\n     * @return void\n     */\n    public function reset(): void\n    {\n        $this->connection = null;\n        $this->logs = null;\n        $this->cache = null;\n    }\n\n    /**\n     * Get the collection for logs\n     *\n     * @return Collection\n     */\n    public function getLogsCollection(): Collection\n    {\n        if ($this->logs === null) {\n            $this->connect();\n            $this->logs = $this->database->getCollection('logs');\n        }\n        return $this->logs;\n    }\n\n    /**\n     * @param string $id\n     * @param bool $includeContent\n     * @return object|null\n     */\n    public function findLog(string $id, bool $includeContent = true): ?object\n    {\n        $options = [];\n        if (!$includeContent) {\n            $options['projection'] = ['data' => 0];\n        }\n\n        $collection = $this->getLogsCollection();\n        $result = $collection->findOne(['_id' => $id], $options);\n        if ($result === null) {\n            // Check for legacy ID without the first character\n            return $collection->findOne(['_id' => substr($id, 1)], $options);\n        }\n        return $result;\n    }\n\n    /**\n     * @param string[] $ids\n     * @param bool $includeContent\n     * @return object[]\n     */\n    public function findLogs(array $ids, bool $includeContent = true): array\n    {\n        $options = [];\n        if (!$includeContent) {\n            $options['projection'] = ['data' => 0];\n        }\n\n        $collection = $this->getLogsCollection();\n        $results = $collection->find(['_id' => ['$in' => $ids]], $options)->toArray();\n        $foundIds = [];\n        foreach ($results as $result) {\n            $foundIds[] = (string)$result->_id;\n        }\n\n        $missingIds = array_diff($ids, $foundIds);\n        if (!empty($missingIds)) {\n            $legacyIds = [];\n            foreach ($missingIds as $id) {\n                $legacyIds[substr($id, 1)] = $id;\n            }\n\n            // Check for legacy IDs without the first character\n            $legacyResults = $collection->find(['_id' => ['$in' => array_keys($legacyIds)]], $options)->toArray();\n            foreach ($legacyResults as $result) {\n                // Map the legacy ID back to the original ID\n                $originalId = $legacyIds[(string)$result->_id];\n                $result->_id = $originalId;\n\n                // Add the found legacy results to the main results array\n                $results[] = $result;\n            }\n        }\n        return $results;\n    }\n\n    /**\n     * @param string $id\n     * @return bool\n     */\n    public function deleteLog(string $id): bool\n    {\n        $collection = $this->getLogsCollection();\n        $result = $collection->deleteOne(['_id' => $id]);\n        if ($result->getDeletedCount() === 0) {\n            // Check for legacy ID without the first character\n            $result = $collection->deleteOne(['_id' => substr($id, 1)]);\n            return $result->getDeletedCount() === 1;\n        }\n        return true;\n    }\n\n    /**\n     * @param array $ids\n     * @return int Number of logs deleted\n     */\n    public function deleteLogs(array $ids): int\n    {\n        $collection = $this->getLogsCollection();\n        $result = $collection->deleteMany(['_id' => ['$in' => $ids]]);\n        $deletedCount = $result->getDeletedCount();\n\n        if ($deletedCount === count($ids)) {\n            return $deletedCount;\n        }\n\n        // Check for legacy IDs without the first character\n        $legacyIds = [];\n        foreach ($ids as $id) {\n            $legacyIds[] = substr($id, 1);\n        }\n        $legacyResult = $collection->deleteMany(['_id' => ['$in' => $legacyIds]]);\n        return $deletedCount + $legacyResult->getDeletedCount();\n    }\n\n    /**\n     * @param string $id\n     * @return bool\n     */\n    public function hasLog(string $id): bool\n    {\n        return $this->findLog($id) !== null;\n    }\n\n    /**\n     * @param string $id\n     * @param UTCDateTime $expires\n     * @return bool\n     */\n    public function setLogExpires(string $id, UTCDateTime $expires): bool\n    {\n        $collection = $this->getLogsCollection();\n        $result = $collection->updateOne(\n            ['_id' => $id],\n            ['$set' => ['expires' => $expires]]\n        );\n        return $result->getModifiedCount() === 1;\n    }\n\n    /**\n     * Get the collection for caching\n     *\n     * @return Collection\n     */\n    public function getCacheCollection(): Collection\n    {\n        if ($this->cache === null) {\n            $this->connect();\n            $this->cache = $this->database->getCollection('cache');\n        }\n        return $this->cache;\n    }\n}\n"
  },
  {
    "path": "src/Util/Singleton.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\ntrait Singleton\n{\n    /**\n     * @var static[]\n     */\n    protected static array $instances = [];\n\n    public static function getInstance(): static\n    {\n        $class = get_called_class();\n\n        if (!isset(static::$instances[$class])) {\n            static::$instances[$class] = new static;\n        }\n\n        return static::$instances[$class];\n    }\n\n\n    /**\n     * Prohibited for singleton\n     */\n    protected function __clone()\n    {\n    }\n\n    /**\n     * Prohibited for singleton\n     */\n    protected function __construct()\n    {\n    }\n}"
  },
  {
    "path": "src/Util/TimeInterval.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\nclass TimeInterval\n{\n    use Singleton;\n\n    protected const array UNITS = [\n        \"year\"   => 365 * 24 * 60 * 60,\n        \"month\"  => 30 * 24 * 60 * 60,\n        \"week\"   => 7 * 24 * 60 * 60,\n        \"day\"    => 24 * 60 * 60,\n        \"hour\"   => 60 * 60,\n        \"minute\" => 60,\n        \"second\" => 1,\n    ];\n\n    /**\n     * @param int $value\n     * @param string $unit\n     * @return string\n     */\n    protected function formatUnit(int $value, string $unit): string\n    {\n        if ($value === 1) {\n            return $value . \" \" . $unit;\n        } else {\n            return $value . \" \" . $unit . \"s\";\n        }\n    }\n\n    /**\n     * @param int $duration\n     * @param string $separator\n     * @return string\n     */\n    public function format(int $duration, string $separator = \", \"): string\n    {\n        $parts = [];\n        while ($duration > 0) {\n            foreach (self::UNITS as $unit => $seconds) {\n                if ($duration >= $seconds) {\n                    $value = intdiv($duration, $seconds);\n                    $duration -= $value * $seconds;\n                    $parts[] = $this->formatUnit($value, $unit);\n                    break;\n                }\n            }\n        }\n        return implode($separator, $parts);\n    }\n}\n"
  },
  {
    "path": "src/Util/URL.php",
    "content": "<?php\n\nnamespace Aternos\\Mclogs\\Util;\n\nuse Uri\\Rfc3986\\Uri;\n\nclass URL\n{\n    protected const string API_SUBDOMAIN = \"api.\";\n\n    protected static ?Uri $base = null;\n    protected static ?Uri $api = null;\n    protected static ?Uri $current = null;\n\n    public static function clear(): void\n    {\n        static::$base = null;\n        static::$api = null;\n        static::$current = null;\n    }\n\n    /**\n     * @return string\n     */\n    protected static function readProtocol(): string\n    {\n        if (isset($_SERVER['HTTP_FORWARDED'])) {\n            $forwarded = explode(';', $_SERVER['HTTP_FORWARDED']);\n            foreach ($forwarded as $part) {\n                $part = trim($part);\n                $partParts = explode('=', $part, 2);\n                if (count($partParts) === 2 && strtolower($partParts[0]) === 'proto') {\n                    $protocol = $partParts[1];\n                    $protocol = trim($protocol, '\"\\'');\n                    return strtolower($protocol);\n                }\n            }\n        }\n        if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {\n            $protoParts = explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO']);\n            return strtolower(trim($protoParts[0]));\n        }\n        if (isset($_SERVER['REQUEST_SCHEME'])) {\n            return strtolower($_SERVER['REQUEST_SCHEME']);\n        }\n        return 'http';\n    }\n\n    /**\n     * @return string\n     */\n    protected static function getProtocol(): string\n    {\n        $protocol = static::readProtocol();\n        if ($protocol === 'https') {\n            return 'https';\n        }\n        return 'http';\n    }\n\n    /**\n     * Get base URL\n     *\n     * @return Uri\n     */\n    public static function getBase(): Uri\n    {\n        if (static::$base) {\n            return static::$base;\n        }\n        $host = $_SERVER['HTTP_HOST'];\n        if (str_starts_with($host, static::API_SUBDOMAIN)) {\n            $host = substr($host, strlen(static::API_SUBDOMAIN));\n        }\n        return static::$base = new Uri(static::getProtocol() . \"://\" . $host);\n    }\n\n    /**\n     * Get API URL\n     *\n     * @return Uri\n     */\n    public static function getApi(): Uri\n    {\n        if (static::$api) {\n            return static::$api;\n        }\n        $base = static::getBase();\n        return static::$api = $base->withHost(static::API_SUBDOMAIN . $base->getHost());\n    }\n\n    /**\n     * @return Uri\n     */\n    public static function getCurrent(): Uri\n    {\n        if (static::$current) {\n            return static::$current;\n        }\n        $scheme = $_SERVER['REQUEST_SCHEME'];\n        $host = $_SERVER['HTTP_HOST'];\n        $requestUri = $_SERVER['REQUEST_URI'];\n        return static::$current = new Uri(\"$scheme://$host$requestUri\");\n    }\n\n    /**\n     * @return bool\n     */\n    public static function isApi(): bool\n    {\n        $currentHost = static::getCurrent()->getHost();\n        $apiHost = static::getApi()->getHost();\n        return $currentHost === $apiHost;\n    }\n\n    /**\n     * @return string\n     */\n    public static function getLastPathPart(): string\n    {\n        $path = static::getCurrent()->getPath();\n        $parts = explode(\"/\", $path);\n        do {\n            $part = trim(array_pop($parts));\n        } while ($part === \"\" && count($parts) > 0);\n        return $part;\n    }\n}"
  },
  {
    "path": "web/frontend/404.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <?php include __DIR__ . '/parts/head.php'; ?>\n        <title>404 - Page not found</title>\n    </head>\n    <body>\n    <?php include __DIR__ . '/parts/header.php'; ?>\n            <main>\n                <div class=\"error-page\">\n                    <div class=\"error-code\">404</div>\n                    <div class=\"error-message\">Page not found</div>\n                    <p class=\"error-description\">The log you're looking for doesn't exist or has expired.</p>\n                    <a href=\"/\" class=\"btn btn-blue\">\n                        <i class=\"fa-solid fa-home\"></i>\n                        Back to Home\n                    </a>\n                </div>\n            </main>\n        <?php include __DIR__ . '/parts/footer.php'; ?>\n    </body>\n</html>\n"
  },
  {
    "path": "web/frontend/api-docs.php",
    "content": "<?php\n\nuse Aternos\\Mclogs\\Api\\Action\\BulkDeleteLogsAction;\nuse Aternos\\Mclogs\\Api\\Response\\ApiError;\nuse Aternos\\Mclogs\\Api\\Response\\ApiResponse;\nuse Aternos\\Mclogs\\Api\\Response\\MultiResponse;\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Util\\URL;\n\n$config = Config::getInstance();\n?>\n<!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <?php include __DIR__ . '/parts/head.php'; ?>\n        <title>API Documentation - <?= htmlspecialchars($config->getName()); ?></title>\n        <meta name=\"description\" content=\"API documentation for <?= htmlspecialchars($config->getName()); ?> - Integrate log sharing directly into your server panel or hosting software.\" />\n    </head>\n    <body>\n    <?php include __DIR__ . '/parts/header.php'; ?>\n            <main>\n                <div class=\"api-docs-header\">\n                    <div class=\"api-docs-header-content\">\n                        <h1>API Documentation</h1>\n                        <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>\n                    </div>\n                </div>\n                <div class=\"api-docs-toc\">\n                    <h3>Quick Links</h3>\n                    <nav class=\"api-docs-toc-nav\">\n                        <a href=\"#create-log\">Create a log</a>\n                        <a href=\"#get-log-info\">Get log info and content</a>\n                        <a href=\"#delete-log\">Delete a log</a>\n                    </nav>\n                </div>\n                <div class=\"api-docs-section\" id=\"create-log\">\n                    <h2>Create a log</h2>\n\n                    <div class=\"api-endpoint\">\n                        <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>\n                    </div>\n                    <div class=\"api-note\">\n                        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.\n                    </div>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Required</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">content</td>\n                            <td class=\"api-required required\"><i class=\"fa-solid fa-square-check\"></i></td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">\n                                The raw log file content as string.\n                                Limited to <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_BYTES) / 1024 / 1024, 2); ?> MiB and <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_LINES)); ?> lines.\n                                Will be truncated if possible and necessary, but truncating on the client side is recommended.\n                            </td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">source</td>\n                            <td class=\"api-required\"><i class=\"fa-solid fa-square-xmark\"></i></td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The name of the source, e.g. a domain or software name.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">metadata</td>\n                            <td class=\"api-required\"><i class=\"fa-solid fa-square-xmark\"></i></td>\n                            <td class=\"api-type\">array</td>\n                            <td class=\"api-description\">An array of metadata entries.</td>\n                        </tr>\n                    </table>\n\n                    <h3>Example body <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">{\n    \"content\": \"[log file content...]\",\n    \"source\": \"example.org\"\n}</pre>\n\n                    <h3>Metadata</h3>\n                    <p>\n                        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.\n                        This is entirely optional, but can help to provide additional context, e.g. internal server IDs, software versions etc.\n                    </p>\n                    <p>\n                        A metadata entry is an object with the following fields:\n                    </p>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Required</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">key</td>\n                            <td class=\"api-required required\"><i class=\"fa-solid fa-square-check\"></i></td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The metadata key. Can be used to identify the entry in your code later.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">value</td>\n                            <td class=\"api-required required\"><i class=\"fa-solid fa-square-check\"></i></td>\n                            <td class=\"api-type\">string|int|float|bool|null</td>\n                            <td class=\"api-description\">The metadata value.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">label</td>\n                            <td class=\"api-required\"><i class=\"fa-solid fa-square-xmark\"></i></td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The display label. If not provided, the key will be used as label.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">visible</td>\n                            <td class=\"api-required\"><i class=\"fa-solid fa-square-xmark\"></i></td>\n                            <td class=\"api-type\">bool</td>\n                            <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>\n                        </tr>\n                    </table>\n\n                    <h3>Example body with metadata <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">{\n    \"content\": \"[log file content...]\",\n    \"source\": \"example.org\",\n    \"metadata\": [\n        {\n            \"key\": \"server_id\",\n            \"value\": 12345,\n            \"visible\": false\n        },\n        {\n            \"key\": \"software_version\",\n            \"value\": \"1.2.3\",\n            \"label\": \"Software Version\",\n            \"visible\": true\n        }\n    ]\n}</pre>\n\n                    <h3>Responses</h3>\n                    <h4>Success <span class=\"content-type\">application/json</span></h4>\n                    <div class=\"api-note\">\n                        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.\n                    </div>\n                    <pre class=\"api-code\">{\n    \"success\":true,\n    \"id\":\"WnMMikq\",\n    \"source\":null,\n    \"created\":1769597979,\n    \"expires\":1777373979,\n    \"size\":157369,\n    \"lines\":1201,\n    \"errors\":8,\n    \"url\": \"<?= htmlspecialchars(URL::getBase()->withPath(\"/WnMMikq\")->toString()); ?>\",\n    \"raw\": \"<?= htmlspecialchars(URL::getApi()->withPath(\"/1/raw/WnMMikq\")->toString()); ?>\",\n    \"token\":\"78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771\",\n    \"metadata\": [\n        {\n            \"key\": \"server_id\",\n            \"value\": 12345,\n            \"visible\": false\n        },\n        {\n            \"key\": \"software_version\",\n            \"value\": \"1.2.3\",\n            \"label\": \"Software Version\",\n            \"visible\": true\n        }\n    ]\n}</pre>\n                    <h4>Error <span class=\"content-type\">application/json</span></h4>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Required field 'content' not found.\"\n}</pre>\n                </div>\n\n                <div class=\"api-docs-section\" id=\"get-log-info\">\n                    <h2>Get log info and content</h2>\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">GET</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>\n                    </div>\n                    <p>\n                        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\n                        formats using GET parameters. You can combine multiple parameters to get multiple content formats in one request, but keep in mind that this will\n                        increase the response size.\n                    </p>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>GET Parameter</th>\n                            <th>Response field</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">raw</td>\n                            <td class=\"api-type\">content.raw</td>\n                            <td class=\"api-description\">Includes the raw log content as string in the response.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">parsed</td>\n                            <td class=\"api-type\">content.parsed</td>\n                            <td class=\"api-description\">Includes the parsed log content as array/objects in the response.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">insights</td>\n                            <td class=\"api-type\">content.insights</td>\n                            <td class=\"api-description\">Includes the automatically detected insights in the response.</td>\n                        </tr>\n                    </table>\n                    <h3>Responses</h3>\n                    <h4>Success <span class=\"content-type\">application/json</span></h4>\n                    <div class=\"api-note\">\n                        All content fields are only included if the corresponding GET parameter is provided.\n                        If no content parameter is provided, the entire content object is omitted from the response.\n                    </div>\n                    <pre class=\"api-code\">{\n    \"success\":true,\n    \"id\":\"WnMMikq\",\n    \"source\":null,\n    \"created\":1769597979,\n    \"expires\":1777373979,\n    \"size\":157369,\n    \"lines\":1201,\n    \"errors\":8,\n    \"url\": \"<?= htmlspecialchars(URL::getBase()->withPath(\"/WnMMikq\")->toString()); ?>\",\n    \"raw\": \"<?= htmlspecialchars(URL::getApi()->withPath(\"/1/raw/WnMMikq\")->toString()); ?>\",\n    \"metadata\": [\n        {\n            \"key\": \"server_id\",\n            \"value\": 12345,\n            \"visible\": false\n        },\n        {\n            \"key\": \"software_version\",\n            \"value\": \"1.2.3\",\n            \"label\": \"Software Version\",\n            \"visible\": true\n        }\n    ],\n    \"content\": {\n        \"raw\": \"[log file content...]\",\n        \"parsed\": [ /* parsed log entries */ ],\n        \"insights\": { \"problems\": [ /* detected problems */ ], \"information\": [ /* detected information */ ] }\n    }\n}</pre>\n                    <h4>Error <span class=\"content-type\">application/json</span></h4>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Log not found.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"delete-log\">\n                    <h2>Delete a log</h2>\n                    <div class=\"api-note\">\n                        Deleting a log requires the token that was provided when creating the log.\n                    </div>\n\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">DELETE</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>\n                    </div>\n\n                    <h3>Headers</h3>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Header</th>\n                            <th>Example</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">Authorization</td>\n                            <td class=\"api-type\">Authorization: Bearer 78351fafe495398163f...</td>\n                            <td class=\"api-description\">The type (always \"Bearer\") and the log token received when creating the log.</td>\n                        </tr>\n                    </table>\n\n                    <h3>Responses</h3>\n                    <h4>Success <span class=\"content-type\">application/json</span></h4>\n                    <pre class=\"api-code\">{\n    \"success\": true\n}</pre>\n                    <h4>Error <span class=\"content-type\">application/json</span></h4>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Invalid token.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"bulk-delete-log\">\n                    <h2>Bulk delete multiple logs</h2>\n                    <div class=\"api-note\">\n                        This method allows deleting up to <?= BulkDeleteLogsAction::MAX_IDS; ?> at once.\n                        Deleting logs requires the tokens that were provided when the logs were created.\n                    </div>\n\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">POST</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/bulk/log/delete</span>\n                    </div>\n\n                    <h3>Example body <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\"><?= json_encode([\n                                [\n                                        \"id\" => \"6wexMDE\",\n                                        \"token\" => \"78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771\"\n                                ],\n                                [\n                                        \"id\" => \"OahzhMG\",\n                                        \"token\" => \"6520dd42ec3d5fd0e83f28220974fb83d3bdc0746853f5022373f8e5b062651b\"\n                                ],\n                        ], JSON_PRETTY_PRINT); ?></pre>\n\n                    <h3>Responses</h3>\n                    <h4>Success <span class=\"content-type\">application/json</span></h4>\n                    <div class=\"api-note\">\n                        The bulk delete request will return a successful result and status code <code>207</code>,\n                        indicating that the request was processed.\n                        Results for the individual operations are included in the response body.\n                    </div>\n                    <pre class=\"api-code\"><?=json_encode(new MultiResponse()\n                                ->addResponse(\"6wexMDE\", new ApiResponse())\n                                ->addResponse(\"OahzhMG\", new ApiResponse()), JSON_PRETTY_PRINT); ?></pre>\n                    <h4>Partial success <span class=\"content-type\">application/json</span></h4>\n                    <div class=\"api-note\">\n                        If a bulk delete request is valid, but not all logs can be deleted (e.g. due to invalid tokens or non-existing logs),\n                        it will still overall be considered successful, but the response body will include error results for the logs that could not be deleted.\n                    </div>\n                    <pre class=\"api-code\"><?=json_encode(new MultiResponse()\n                                ->addResponse(\"6wexMDE\", new ApiResponse())\n                                ->addResponse(\"OahzhMG\", new ApiError(404, \"Log not found.\")), JSON_PRETTY_PRINT); ?></pre>\n                    <h4>Error <span class=\"content-type\">application/json</span></h4>\n                    <div class=\"api-note\">\n                        If a bulk delete request is malformed or invalid, the entire request will be\n                        rejected with an error response and no logs will be deleted.\n                    </div>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"No logs provided.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"get-raw\">\n                    <h2>Get the raw log file content</h2>\n                    <div class=\"api-note\">\n                        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.\n                    </div>\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">GET</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/raw/[id]</span>\n                    </div>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">[id]</td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>\n                        </tr>\n                    </table>\n\n                    <h3>Success <span class=\"content-type\">text/plain</span></h3>\n                    <pre class=\"api-code\">\n[18:25:33] [Server thread/INFO]: Starting minecraft server version 1.16.2\n[18:25:33] [Server thread/INFO]: Loading properties\n[18:25:34] [Server thread/INFO]: Default game type: SURVIVAL\n...\n</pre>\n                    <h3>Error <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Log not found.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"get-insights\">\n                    <h2>Get insights</h2>\n                    <div class=\"api-note\">\n                        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.\n                    </div>\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">GET</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/insights/[id]</span>\n                    </div>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">[id]</td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>\n                        </tr>\n                    </table>\n\n                    <h3>Success <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n  \"id\": \"name/type\",\n  \"name\": \"Software name, e.g. Vanilla\",\n  \"type\": \"Type name, e.g. Server Log\",\n  \"version\": \"Version, e.g. 1.12.2\",\n  \"title\": \"Combined title, e.g. Vanilla 1.12.2 Server Log\",\n  \"analysis\": {\n    \"problems\": [\n      {\n        \"message\": \"A message explaining the problem.\",\n        \"counter\": 1,\n        \"entry\": {\n          \"level\": 6,\n          \"time\": null,\n          \"prefix\": \"The prefix of this entry, usually the part containing time and loglevel.\",\n          \"lines\": [\n            {\n              \"number\": 1,\n              \"content\": \"The full content of the line.\"\n            }\n          ]\n        },\n        \"solutions\": [\n          {\n            \"message\": \"A message explaining a possible solution.\"\n          }\n        ]\n      }\n    ],\n    \"information\": [\n      {\n        \"message\": \"Label: value\",\n        \"counter\": 1,\n        \"label\": \"The label of this information, e.g. Minecraft version\",\n        \"value\": \"The value of this information, e.g. 1.12.2\",\n        \"entry\": {\n          \"level\": 6,\n          \"time\": null,\n          \"prefix\": \"The prefix of this entry, usually the part containing time and loglevel.\",\n          \"lines\": [\n            {\n              \"number\": 6,\n              \"content\": \"The full content of the line.\"\n            }\n          ]\n        }\n      }\n    ]\n  }\n}</pre>\n                    <h3>Error <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Log not found.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"analyse\">\n                    <h2>Analyse a log without saving it</h2>\n                    <p>\n                        If you only want to use the analysis features of this service without saving the log, you can use this endpoint.\n                        Please do not save logs that you only want to analyse, as this wastes storage space and resources.\n                    </p>\n\n                    <div class=\"api-endpoint\">\n                        <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>\n                    </div>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">content</td>\n                            <td class=\"api-type\">string</td>\n                            <td class=\"api-description\">The raw log file content as string. Maximum length is 10MiB and 25k lines, will be shortened if necessary.</td>\n                        </tr>\n                    </table>\n\n                    <h3>Success <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n  \"id\": \"name/type\",\n  \"name\": \"Software name, e.g. Vanilla\",\n  \"type\": \"Type name, e.g. Server Log\",\n  \"version\": \"Version, e.g. 1.12.2\",\n  \"title\": \"Combined title, e.g. Vanilla 1.12.2 Server Log\",\n  \"analysis\": {\n    \"problems\": [\n      {\n        \"message\": \"A message explaining the problem.\",\n        \"counter\": 1,\n        \"entry\": {\n          \"level\": 6,\n          \"time\": null,\n          \"prefix\": \"The prefix of this entry, usually the part containing time and loglevel.\",\n          \"lines\": [\n            {\n              \"number\": 1,\n              \"content\": \"The full content of the line.\"\n            }\n          ]\n        },\n        \"solutions\": [\n          {\n            \"message\": \"A message explaining a possible solution.\"\n          }\n        ]\n      }\n    ],\n    \"information\": [\n      {\n        \"message\": \"Label: value\",\n        \"counter\": 1,\n        \"label\": \"The label of this information, e.g. Minecraft version\",\n        \"value\": \"The value of this information, e.g. 1.12.2\",\n        \"entry\": {\n          \"level\": 6,\n          \"time\": null,\n          \"prefix\": \"The prefix of this entry, usually the part containing time and loglevel.\",\n          \"lines\": [\n            {\n              \"number\": 6,\n              \"content\": \"The full content of the line.\"\n            }\n          ]\n        }\n      }\n    ]\n  }\n}</pre>\n                    <h3>Error <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n    \"success\": false,\n    \"error\": \"Required field 'content' is empty.\"\n}</pre>\n                </div>\n                <div class=\"api-docs-section\" id=\"check-limits\">\n                    <h2>Check storage limits</h2>\n\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">GET</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->withPath(\"/1/limits\")->toString()); ?></span>\n                    </div>\n                    <h3>Success <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n{\n  \"storageTime\": 7776000,\n  \"maxLength\": 10485760,\n  \"maxLines\": 25000\n}</pre>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Field</th>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">storageTime</td>\n                            <td class=\"api-type\">integer</td>\n                            <td class=\"api-description\">The duration in seconds that a log is stored for after the last view.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">maxLength</td>\n                            <td class=\"api-type\">integer</td>\n                            <td class=\"api-description\">Maximum file length in bytes. Logs over this limit will be truncated to this length.</td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">maxLines</td>\n                            <td class=\"api-type\">integer</td>\n                            <td class=\"api-description\">Maximum number of lines. Additional lines will be removed.</td>\n                        </tr>\n                    </table>\n                </div>\n                <div class=\"api-docs-section\" id=\"check-limits\">\n                    <h2>Get filters</h2>\n                    <p>\n                        Filters modify the log content before storing it. They are applied automatically when creating a new log on the server side.\n                        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.\n                    </p>\n                    <div class=\"api-endpoint\">\n                        <span class=\"api-method\">GET</span> <span class=\"api-url\"><?= htmlspecialchars(URL::getApi()->withPath(\"/1/filters\")->toString()); ?></span>\n                    </div>\n                    <h3>Success <span class=\"content-type\">application/json</span></h3>\n                    <pre class=\"api-code\">\n<?=htmlspecialchars(json_encode(\\Aternos\\Mclogs\\Filter\\Filter::getAll(), JSON_PRETTY_PRINT)); ?></pre>\n                    <h3>Filter types</h3>\n                    <table class=\"api-table\">\n                        <tr>\n                            <th>Type</th>\n                            <th>Description</th>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">trim</td>\n                            <td class=\"api-description\">\n                                Trim any whitespace characters from the beginning and end of the log content.\n                            </td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">limit-bytes</td>\n                            <td class=\"api-description\">\n                                Limit the log content to a maximum number of bytes (data.limit). Content exceeding this limit will be truncated.\n                            </td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">limit-lines</td>\n                            <td class=\"api-description\">\n                                Limit the log content to a maximum number of lines (data.limit). Additional lines will be removed.\n                            </td>\n                        </tr>\n                        <tr>\n                            <td class=\"api-field\">regex</td>\n                            <td class=\"api-description\">\n                                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.\n                            </td>\n                        </tr>\n                    </table>\n                    <div class=\"api-note\">\n                        Make sure to handle any filter error, e.g. unknown filter types gracefully, as new filter types may be added in the future.\n                    </div>\n                </div>\n                <div class=\"api-docs-notes\">\n                    <div class=\"api-docs-notes-content\">\n                        <h2>Notes</h2>\n                        <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>\n                        <div class=\"api-docs-notes-actions\">\n                            <a class=\"btn btn-small\" href=\"mailto:matthias@aternos.org\">\n                                <i class=\"fa-solid fa-envelope\"></i> Contact via mail\n                            </a>\n                        </div>\n                    </div>\n                </div>\n            </main>\n        <?php include __DIR__ . '/parts/footer.php'; ?>\n    </body>\n</html>\n"
  },
  {
    "path": "web/frontend/log.php",
    "content": "<?php\n\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetType;\nuse Aternos\\Mclogs\\Log;\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Frontend\\Settings\\Setting;\nuse Aternos\\Mclogs\\Frontend\\Settings\\Settings;\nuse Aternos\\Mclogs\\Util\\TimeInterval;\n\n/** @var Log $log */\n\n$settings = new Settings();\n?><!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <?php include __DIR__ . '/parts/head.php'; ?>\n        <title><?=htmlspecialchars($log->getPageTitle()); ?></title>\n        <meta name=\"description\" content=\"<?=htmlspecialchars($log->getPageDescription()); ?>\" />\n    </head>\n    <body class=\"log-body<?=$settings->getBodyClassesString(); ?>\">\n    <?php include __DIR__ . '/parts/header.php'; ?>\n            <main>\n                <div class=\"log-header\">\n                   <div class=\"log-header-inner\">\n                       <div class=\"left\">\n                           <div class=\"log-title\">\n                               <h1>\n                                   <i class=\"fas fa-file-lines\"></i>\n                                   <?=htmlspecialchars($log->getCodexLog()->getTitle()); ?>\n                               </h1>\n                               <button class=\"log-url-btn\" data-clipboard=\"<?=htmlspecialchars($log->getURL()->toString()); ?>\" title=\"Copy log URL to clipboard\">\n                                   <span class=\"log-url\"><?=htmlspecialchars($log->getDisplayURL()); ?></span>\n                                   <i class=\"fa-solid fa-copy\"></i>\n                               </button>\n                           </div>\n                       </div>\n                       <div class=\"right\">\n                           <div class=\"details\">\n                               <div class=\"log-info-actions\">\n                                   <?php if($log->hasErrors()): ?>\n                                       <div class=\"btn btn-danger btn-small\" id=\"error-toggle\">\n                                           <i class=\"fa fa-exclamation-circle\"></i>\n                                           <?=htmlspecialchars($log->getErrorsString()); ?>\n                                       </div>\n                                   <?php endif; ?>\n                                   <div class=\"btn btn-dark btn-small\" id=\"down-button\">\n                                       <i class=\"fa fa-arrow-circle-down\"></i>\n                                       <?=htmlspecialchars($log->getLinesString()); ?>\n                                   </div>\n                                   <a class=\"btn btn-dark btn-small\" id=\"raw\" target=\"_blank\" title=\"Raw log\" href=\"<?=$log->getRawURL()->toString(); ?>\">\n                                       <i class=\"fa fa-arrow-up-right-from-square\"></i>\n                                       Raw\n                                   </a>\n                               </div>\n                           </div>\n                       </div>\n                   </div>\n                   <?php $information = $log->getAnalysis()->getInformation(); ?>\n                   <?php if(count($log->getVisibleMetadata()) > 0 || count($information) > 0): ?>\n                       <div class=\"log-info-rows\">\n                           <?php if(count($log->getVisibleMetadata()) > 0): ?>\n                               <div class=\"log-info-row\">\n                                   <div class=\"info-row-items\">\n                                       <div class=\"info-row-header\">\n                                           <i class=\"fa-solid fa-tags\"></i>\n                                           <span>Metadata</span>\n                                       </div>\n                                       <?php foreach($log->getVisibleMetadata() as $metadata): ?>\n                                           <span class=\"info-item\">\n                                               <span class=\"info-label\"><?=htmlspecialchars($metadata->getDisplayLabel()); ?>:</span>\n                                               <span class=\"info-value\"><?=htmlspecialchars($metadata->getDisplayValue()); ?></span>\n                                           </span>\n                                       <?php endforeach; ?>\n                                   </div>\n                               </div>\n                           <?php endif; ?>\n                           <?php if(count($information) > 0): ?>\n                               <div class=\"log-info-row\">\n                                   <div class=\"info-row-items\">\n                                       <div class=\"info-row-header\">\n                                           <i class=\"fa-solid fa-cube\"></i>\n                                           <span>Detected</span>\n                                       </div>\n                                       <?php foreach($information as $info): ?>\n                                           <span class=\"info-item\">\n                                               <span class=\"info-label\"><?=htmlspecialchars($info->getLabel()); ?>:</span>\n                                               <span class=\"info-value\"><?=htmlspecialchars($info->getValue()); ?></span>\n                                           </span>\n                                       <?php endforeach; ?>\n                                   </div>\n                               </div>\n                           <?php endif; ?>\n                       </div>\n                   <?php endif; ?>\n                    <?php $problems = $log->getAnalysis()?->getProblems(); ?>\n                    <?php if(count($problems) > 0): ?>\n                        <div class=\"problems-panel-container\">\n                            <div class=\"problems-panel\">\n                                <div class=\"problems-header\">\n                                    <span class=\"problems-count\"><?=count($problems); ?></span>\n                                    <span class=\"problems-title\"><?=count($problems) === 1 ? 'Problem' : 'Problems'; ?> detected</span>\n                                </div>\n                                <div class=\"problems-list\">\n                                    <?php foreach($problems as $problem): ?>\n                                        <?php $number = $problem->getEntry()[0]->getNumber(); ?>\n                                        <div class=\"problem-item\">\n                                            <a href=\"/<?=htmlspecialchars($log->getId()->get()) . \"#L\" . $number; ?>\" class=\"problem-entry\" onclick=\"updateLineNumber('#L<?=$number; ?>');\">\n                                        <span class=\"problem-label\">\n                                            <i class=\"fa-solid fa-triangle-exclamation\"></i>\n                                            Problem\n                                        </span>\n                                                <span class=\"problem-text\"><?=htmlspecialchars($problem->getMessage()); ?></span>\n                                                <span class=\"problem-line\">Line <?=$number; ?></span>\n                                            </a>\n                                            <?php if(count($problem->getSolutions()) > 0): ?>\n                                                <div class=\"problem-solutions\">\n                                                    <span class=\"problem-solutions-label\"><?=count($problem->getSolutions()) === 1 ? 'Solution:' : 'Solutions:'; ?></span>\n                                                    <?php foreach($problem->getSolutions() as $solution): ?>\n                                                        <div class=\"problem-solution\">\n                                                            <i class=\"fa-solid fa-lightbulb\"></i>\n                                                            <span><?=preg_replace(\"/'([^']+)'/\", \"'<strong>$1</strong>'\", htmlspecialchars($solution->getMessage())); ?></span>\n                                                        </div>\n                                                    <?php endforeach; ?>\n                                                </div>\n                                            <?php endif; ?>\n                                        </div>\n                                    <?php endforeach; ?>\n                                </div>\n                            </div>\n                        </div>\n                    <?php endif; ?>\n                </div>\n            </main>\n            <div class=\"log-container\">\n                <div class=\"log\">\n                    <?php\n                    echo $log->getPrinter()->print();\n                    ?>\n                </div>\n            </div>\n            <div class=\"log-footer\">\n                <div class=\"log-bottom\">\n                    <div class=\"btn btn-small btn-dark\" id=\"up-button\" title=\"Scroll to top\">\n                        <i class=\"fa fa-arrow-circle-up\"></i>\n                    </div>\n                    <div class=\"actions\">\n                        <?php if ($log->hasValidTokenCookie()): ?>\n                        <div class=\"delete-wrapper popover-wrapper\">\n                            <button class=\"delete-trigger popover-trigger btn btn-small btn-danger\" title=\"Delete log\" popovertarget=\"delete-overlay\">\n                                <i class=\"fa-solid fa-trash\"></i>\n                                Delete\n                            </button>\n                            <div class=\"delete-overlay popover-content popover-danger\" id=\"delete-overlay\" popover>\n                                <span class=\"delete-message\">Delete this log permanently?</span>\n                                <div class=\"popover-error\">\n\n                                </div>\n                                <div class=\"delete-actions\">\n                                    <button class=\"btn btn-small btn-white\" popovertarget=\"delete-overlay\">Cancel</button>\n                                    <button class=\"btn btn-small btn-danger delete-log-button\">Delete</button>\n                                </div>\n                            </div>\n                        </div>\n                        <?php endif; ?>\n                        <div class=\"settings-dropdown popover-wrapper\">\n                            <button class=\"settings-trigger popover-trigger btn btn-small btn-dark\" title=\"Settings\" popovertarget=\"settings-overlay\">\n                                <i class=\"fas fa-cog\"></i>\n                                Settings\n                            </button>\n                            <div class=\"settings-overlay popover-content\" id=\"settings-overlay\" popover>\n                                <?php foreach(Setting::cases() as $setting): ?>\n                                    <label class=\"setting\" for=\"setting-<?=$setting->value; ?>\">\n                                        <span class=\"setting-label\"><?=$setting->getLabel(); ?></span>\n                                        <input type=\"checkbox\"\n                                               id=\"setting-<?=$setting->value; ?>\"\n                                               class=\"setting-checkbox\"\n                                               data-body-class=\"<?=$setting->getBodyClass() ?? \"\"; ?>\"\n                                               data-key=\"<?=$setting->value; ?>\"\n                                                <?=($settings->get($setting)) ? \" checked\" : \"\"; ?>/>\n                                    </label>\n                                <?php endforeach; ?>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n                <div class=\"log-details\">\n                <?php\n                    $source = $log->getSource();\n                    $created = $log->getCreated()?->toDateTime()->getTimestamp();\n                ?>\n                <?php if ($source || $created): ?>\n                    <div class=\"meta-data\">\n                        <?php if ($source): ?>\n                            <div class=\"source\" title=\"Source\">\n                                <i class=\"fa-solid fa-arrow-up-from-bracket\"></i>\n                                <?=htmlspecialchars($source); ?>\n                            </div>\n                        <?php endif; ?>\n                        <?php if ($created): ?>\n                            <div class=\"created-time\" title=\"Created\">\n                                <i class=\"fa-solid fa-clock\"></i>\n                                <span class=\"created\" data-time=\"<?=htmlspecialchars($created); ?>\">\n                                </span>\n                            </div>\n                        <?php endif; ?>\n                    </div>\n                <?php endif; ?>\n                    <div class=\"delete-notice\">\n                        This log will be saved for <?= htmlspecialchars(TimeInterval::getInstance()->format(Config::getInstance()->get(ConfigKey::STORAGE_TTL))); ?> from its last view.\n                    </div>\n                    <?php if ($abuseEmail = Config::getInstance()->get(ConfigKey::LEGAL_ABUSE)): ?>\n                        <a href=\"mailto:<?=htmlspecialchars($abuseEmail); ?>?subject=Report%20<?=htmlspecialchars(rawurlencode(Config::getInstance()->getName())); ?>/<?=htmlspecialchars($log->getId()->get()); ?>\" class=\"report-link\">\n                            <i class=\"fa-solid fa-flag\"></i>\n                            Report abuse\n                        </a>\n                    <?php endif; ?>\n                </div>\n            </div>\n        <?php include __DIR__ . '/parts/footer.php'; ?>\n        <div class=\"floating-scrollbar-container\">\n            <div class=\"floating-scrollbar\">\n                <div class=\"floating-scrollbar-content\">\n                </div>\n            </div>\n        </div>\n        <?= AssetLoader::getInstance()->getHTML(AssetType::JS, \"js/log.js\"); ?>\n    </body>\n</html>\n"
  },
  {
    "path": "web/frontend/parts/favicon.php",
    "content": "<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\">\n    <rect width=\"41\" height=\"5\" rx=\"2\"/>\n    <rect y=\"9.25\" width=\"33\" height=\"5\" rx=\"2\"/>\n    <rect y=\"18.5\" width=\"19\" height=\"5\" rx=\"2\"/>\n    <rect y=\"27.75\" width=\"33\" height=\"5\" rx=\"2\"/>\n    <rect y=\"37\" width=\"41\" height=\"5\" rx=\"2\"/>\n</svg>\n"
  },
  {
    "path": "web/frontend/parts/footer.php",
    "content": "<?php\nuse Aternos\\Mclogs\\Config\\Config;use Aternos\\Mclogs\\Config\\ConfigKey;use Aternos\\Mclogs\\Util\\URL;\n\n$imprintUrl = Config::getInstance()->get(ConfigKey::LEGAL_IMPRINT);\n$privacyUrl = Config::getInstance()->get(ConfigKey::LEGAL_PRIVACY);\n?>\n<footer>\n    <?php if($imprintUrl || $privacyUrl): ?>\n    <nav class=\"legal\">\n        <?php if ($imprintUrl): ?>\n            <a href=\"<?=htmlspecialchars($imprintUrl); ?>\" class=\"footer-link\" title=\"Imprint\" target=\"_blank\">Imprint</a>\n        <?php endif; ?>\n        <?php if ($imprintUrl && $privacyUrl): ?>\n            <span class=\"footer-separator\"> - </span>\n        <?php endif; ?>\n        <?php if ($privacyUrl): ?>\n            <a href=\"<?=htmlspecialchars($privacyUrl); ?>\" class=\"footer-link\" title=\"Privacy Policy\" target=\"_blank\">Privacy Policy</a>\n        <?php endif; ?>\n    </nav>\n    <?php endif; ?>\n    <nav class=\"footer-nav\">\n        <a href=\"https://github.com/aternosorg/mclogs\" title=\"mclo.gs on Github\" target=\"_blank\"><i class=\"fa-brands fa-github\"></i>GitHub</a>\n        <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>\n        <a href=\"<?=htmlspecialchars(URL::getApi()->toString()); ?>\" title=\"mclo.gs API\"><i class=\"fa-solid fa-code\"></i>API</a>\n    </nav>\n    <span class=\"footer-text\">developed by <a href=\"https://aternos.org\" target=\"_blank\" title=\"Aternos website\">Aternos</a>\n    </span>\n</footer>\n"
  },
  {
    "path": "web/frontend/parts/head.php",
    "content": "<?php\n\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetType;\nuse Aternos\\Mclogs\\Util\\URL;\n\n?>\n    <meta charset=\"utf-8\"/>\n\n    <base href=\"/\"/>\n    <?= AssetLoader::getInstance()->getHTML(AssetType::CSS, \"vendor/fontawesome/css/fontawesome.min.css\"); ?>\n    <?= AssetLoader::getInstance()->getHTML(AssetType::CSS, \"css/mclogs.css\"); ?>\n\n    <style>\n        :root {\n            --bg: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_BACKGROUND)); ?>;\n            --text: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_TEXT)); ?>;\n            --accent: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ACCENT)); ?>;\n            --error: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ERROR)); ?>;\n        }\n    </style>\n\n    <link rel=\"shortcut icon\" href=\"img/favicon.ico\" type=\"image/x-icon\" sizes=\"any\"/>\n    <link rel=\"shortcut icon\" href=\"<?= htmlspecialchars(URL::getBase()->withPath(\"/favicon.svg\")->toString()); ?>\" type=\"image/svg+xml\">\n\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\" />\n<?php if (Config::getInstance()->get(ConfigKey::FRONTEND_ANALYTICS)): ?>\n    <script>\n        let _paq = window._paq = window._paq || [];\n        _paq.push(['disableCookies']);\n        _paq.push(['trackPageView']);\n        _paq.push(['enableLinkTracking']);\n        (function () {\n            _paq.push(['setTrackerUrl', '/data']);\n            _paq.push(['setSiteId', '5']);\n            let d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];\n            g.async = true;\n            g.src = '/data.js';\n            s.parentNode.insertBefore(g, s);\n        })();\n    </script>\n<?php endif; ?>\n"
  },
  {
    "path": "web/frontend/parts/header.php",
    "content": "<header>\n    <a href=\"<?=htmlspecialchars(\\Aternos\\Mclogs\\Util\\URL::getBase()->toString()); ?>\" class=\"logo\">\n        <svg class=\"logo-icon\" width=\"41\" height=\"42\" viewBox=\"0 0 41 42\" fill=\"none\"\n             xmlns=\"http://www.w3.org/2000/svg\">\n            <rect width=\"41\" height=\"5\" rx=\"2\" fill=\"currentColor\"/>\n            <rect y=\"9.25\" width=\"33\" height=\"5\" rx=\"2\" fill=\"currentColor\"/>\n            <rect y=\"18.5\" width=\"19\" height=\"5\" rx=\"2\" fill=\"currentColor\"/>\n            <rect y=\"27.75\" width=\"33\" height=\"5\" rx=\"2\" fill=\"currentColor\"/>\n            <rect y=\"37\" width=\"41\" height=\"5\" rx=\"2\" fill=\"currentColor\"/>\n        </svg>\n        <span class=\"logo-text\"><?= htmlspecialchars(\\Aternos\\Mclogs\\Config\\Config::getInstance()->getName()); ?></span>\n    </a>\n    <div class=\"tagline\">\n        <h1 class=\"tagline-main\"><span class=\"title-verb\">Paste</span> your logs.</h1>\n        <div class=\"tagline-sub\">Built for Minecraft & Hytale</div>\n    </div>\n    <script>\n        const titles = [\"Paste\", \"Share\", \"Analyse\"];\n        let currentTitle = 0;\n        let speed = 30;\n        let pause = 3000;\n        const titleElement = document.querySelector('.title-verb');\n\n        setTimeout(nextTitle, pause);\n\n        function nextTitle() {\n            currentTitle++;\n            if (typeof (titles[currentTitle]) === \"undefined\") {\n                currentTitle = 0;\n            }\n\n            const title = titleElement.innerHTML;\n            for (let i = 0; i < title.length - 1; i++) {\n                setTimeout(function () {\n                    titleElement.innerHTML = titleElement.innerHTML.substring(0, titleElement.innerHTML.length - 1);\n                }, i * speed);\n            }\n\n            const newTitle = titles[currentTitle];\n            for (let i = 1; i <= newTitle.length; i++) {\n                setTimeout(function () {\n                    titleElement.innerHTML = newTitle.substring(0, titleElement.innerHTML.length + 1);\n                }, title.length * speed + i * speed);\n            }\n\n            setTimeout(nextTitle, title.length * speed + newTitle.length * speed + pause);\n        }\n    </script>\n</header>\n"
  },
  {
    "path": "web/frontend/start.php",
    "content": "<?php\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Filter\\Filter;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetLoader;\nuse Aternos\\Mclogs\\Frontend\\Assets\\AssetType;\n?><!DOCTYPE html>\n<html lang=\"en\">\n    <head>\n        <?php include __DIR__ . '/parts/head.php'; ?>\n        <title><?= htmlspecialchars(Config::getInstance()->getName()); ?> - Paste, share & analyse your logs</title>\n        <meta name=\"description\" content=\"Easily paste your Minecraft & Hytale logs to share and analyse them.\" />\n    </head>\n    <body data-name=\"<?=htmlspecialchars(Config::getInstance()->getName()); ?>\">\n    <?php include __DIR__ . '/parts/header.php'; ?>\n            <main>\n                <div class=\"paste-area\" id=\"dropzone\">\n                    <div class=\"paste-placeholder\">\n                        <i class=\"fa-solid fa-cloud-arrow-up\"></i>\n                        <p>Paste or drop your log here</p>\n                        <div class=\"paste-hints\">\n                            <button type=\"button\" class=\"btn btn-transparent\" title=\"Paste log\" id=\"paste-clipboard\"><i class=\"fa-solid fa-paste\"></i> Paste</button>\n                            <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>\n                            <span><i class=\"fa-solid fa-file-arrow-up\" title=\"Drop file\"></i> Drop</span>\n                        </div>\n                    </div>\n                    <textarea aria-label=\"Paste or drop your log here\" spellcheck=\"false\" data-enable-grammarly=\"false\" id=\"paste-text\"></textarea>\n                    <button type=\"button\" class=\"btn-save btn paste-save\" title=\"Save log\" disabled><i class=\"fa-solid fa-save\"></i> Save</button>\n                    <div class=\"paste-error\" id=\"paste-error\"></div>\n                </div>\n            </main>\n        <?php include __DIR__ . '/parts/footer.php'; ?>\n        <script>\n            const FILTERS = <?= json_encode(Filter::getAll()); ?>;\n        </script>\n        <?= AssetLoader::getInstance()->getHTML(AssetType::JS, \"js/start.js\"); ?>\n    </body>\n</html>\n"
  },
  {
    "path": "web/public/css/mclogs.css",
    "content": "/* plus-jakarta-sans-regular - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Plus Jakarta Sans';\n    font-style: normal;\n    font-weight: 400;\n    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2');\n}\n/* plus-jakarta-sans-500 - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Plus Jakarta Sans';\n    font-style: normal;\n    font-weight: 500;\n    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2');\n}\n/* plus-jakarta-sans-600 - latin */\n@font-face {\n    font-display: swap;\n    font-family: 'Plus Jakarta Sans';\n    font-style: normal;\n    font-weight: 600;\n    src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2') format('woff2');\n}\n/* jetbrains-mono-regular - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */\n@font-face {\n    font-display: swap;\n    font-family: 'JetBrains Mono';\n    font-style: normal;\n    font-weight: 400;\n    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2') format('woff2');\n}\n/* jetbrains-mono-italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */\n@font-face {\n    font-display: swap;\n    font-family: 'JetBrains Mono';\n    font-style: italic;\n    font-weight: 400;\n    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2') format('woff2');\n}\n/* jetbrains-mono-700 - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */\n@font-face {\n    font-display: swap;\n    font-family: 'JetBrains Mono';\n    font-style: normal;\n    font-weight: 700;\n    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2') format('woff2');\n}\n/* jetbrains-mono-700italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */\n@font-face {\n    font-display: swap;\n    font-family: 'JetBrains Mono';\n    font-style: italic;\n    font-weight: 700;\n    src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2') format('woff2');\n}\n\n:root {\n    --bg-surface: color-mix(in srgb, var(--bg) 92%, var(--text) 8%);\n    --bg-elevated: color-mix(in srgb, var(--bg) 95%, var(--text) 5%);\n    --bg-inset: var(--bg-surface);\n    --text-muted: color-mix(in srgb, var(--text) 55%, var(--bg) 45%);\n    --accent-hover: color-mix(in srgb, var(--accent) 78%, var(--bg) 22%);\n    --accent-bg: color-mix(in srgb, var(--accent) 12%, transparent);\n    --accent-border: var(--accent);\n    --error-bg: color-mix(in srgb, var(--error) 10%, transparent);\n    --error-border: color-mix(in srgb, var(--error) 40%, transparent);\n    --border: rgba(255, 255, 255, 0.08);\n    --surface: rgba(255, 255, 255, 0.04);\n    --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;\n    --font-mono: 'JetBrains Mono', 'Fira Code', monospace;\n    --max-width: 1400px;\n    --page-padding: clamp(1rem, 2.5vw, 1.25rem);\n    --max-width-content: min(100%, calc(var(--max-width)) - var(--page-padding) * 2);\n    --radius: 12px;\n    --scrollbar-height: 8px;\n    --browser: unset;\n    scroll-behavior: smooth;\n}\n\n@view-transition {\n    navigation: auto;\n}\n\n/* Global scrollbar styling */\n*::-webkit-scrollbar {\n    width: 8px;\n    height: var(--scrollbar-height);\n}\n\n*::-webkit-scrollbar-track {\n    background: transparent;\n}\n\n*::-webkit-scrollbar-thumb {\n    background-color: var(--accent);\n    border-radius: 4px;\n}\n\n*::-webkit-scrollbar-thumb:hover {\n    background-color: var(--accent-hover);\n}\n\n::selection {\n    background-color: var(--accent);\n    color: var(--text);\n}\n\n* {\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n    scrollbar-color: var(--accent) transparent;\n}\n\nhtml {\n    height: 100%;\n    text-size-adjust: 100%;\n    -webkit-text-size-adjust: 100%;\n}\n\nbody {\n    font-family: var(--font-sans), system-ui, sans-serif;\n    background-color: var(--bg);\n    color: var(--text);\n    line-height: 1.5;\n    min-height: 100%;\n    display: flex;\n    flex-direction: column;\n    position: relative;\n    font-weight: 400;\n}\n\n/* Log Settings */\nbody.setting-full-width {\n    --max-width: 100%;\n    --max-width-content: calc(100% - var(--page-padding) * 2);\n}\n\nbody.setting-overflow .log-container {\n    max-width: unset;\n    min-width: 100%;\n}\n\nbody.setting-no-wrap .log-inner {\n    white-space: pre;\n}\n\nbody.setting-no-wrap .log-inner .line-content {\n    word-break: normal;\n    overflow-wrap: normal;\n}\n\nbody.setting-no-wrap .log-inner .level {\n    white-space: pre;\n}\n\nbody.setting-no-wrap .log-inner .collapsed-lines-count {\n    justify-content: flex-start;\n}\n\na {\n    color: inherit;\n    text-decoration: none;\n    transition: color 0.15s ease;\n}\n\na:hover:not(.btn) {\n    color: var(--accent);\n}\n\nbody::before {\n    content: '';\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    background-image:\n        linear-gradient(color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px),\n        linear-gradient(90deg, color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px);\n    background-size: 40px 40px;\n    pointer-events: none;\n    z-index: 0;\n}\n\n/** Buttons **/\n\n.btn {\n    background-color: var(--accent);\n    color: var(--bg);\n    font-family: inherit;\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    font-weight: 600;\n    cursor: pointer;\n    display: flex;\n    align-items: center;\n    border: 2px solid transparent;\n    padding: clamp(0.6rem, 2vw, 0.7rem) clamp(1.2rem, 3vw, 1.5rem);\n    border-radius: 8px;\n    gap: .4rem;\n    line-height: 1;\n    transition: color .15s ease, background-color .15s ease, border-color .15s ease;\n}\n\n.btn:hover:not(:disabled) {\n    background-image: linear-gradient(#00000014,#00000014);\n}\n\n.btn:disabled,\n.btn.disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n}\n\n.btn-small {\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    padding: clamp(0.35rem, 1.5vw, 0.4rem) clamp(0.85rem, 2.5vw, 1rem);\n}\n\n.btn-transparent {\n    background-color: transparent;\n    color: var(--accent);\n    border: 0 none;\n}\n\n.btn-transparent:hover {\n    color: var(--accent);\n}\n\n.btn-danger {\n    background-color: var(--error);\n    color: var(--text);\n}\n\n#error-toggle {\n    cursor: pointer;\n    transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;\n}\n\n#error-toggle.toggled {\n    background-color: var(--error-bg);\n    color: var(--text);\n    border-color: var(--error);\n}\n\n#error-toggle.toggled:hover {\n    background-color: var(--error-bg);\n}\n\n.btn-white {\n    background-color: #fff;\n    color: var(--bg);\n}\n\n.btn-dark {\n    background-color: var(--surface);\n    color: var(--text);\n    border-color: var(--border);\n}\n\n/** Header **/\n\nheader {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    flex-wrap: wrap;\n    width: 100%;\n    max-width: var(--max-width);\n    margin: 0 auto;\n    padding: clamp(1rem, 3vw, 2rem) var(--page-padding);\n    position: relative;\n    z-index: 1;\n    transition: max-width .25s ease;\n}\n\n.logo {\n    view-transition-name: logo;\n    display: flex;\n    align-items: center;\n    gap: .9rem;\n    text-decoration: none;\n    transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);\n    transform-origin: center;\n}\n\n.logo:active {\n    transform: scale(.9);\n}\n\n.logo-icon {\n    height: clamp(1.5rem, 3vw, 2rem);\n    width: auto;\n    margin-top: 3px;\n    color: var(--accent);\n}\n\n.logo-text {\n    font-size: clamp(1.75rem, 3vw, 2rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-top: -3px;\n}\n\n.tagline {\n    display: flex;\n    flex-direction: column;\n    gap: 0.25rem;\n    text-align: right;\n}\n\n.tagline-main {\n    font-size: clamp(1rem, 3vw, 1.5rem);\n    color: var(--text);\n    font-weight: 400;\n}\n\n.tagline-sub {\n    font-size: clamp(0.75rem, 2vw, 1rem);\n    color: var(--text-muted);\n}\n\n.title-verb {\n    font-weight: 600;\n    color: var(--accent);\n}\n\n/** Footer **/\n\nfooter {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 1rem;\n    color: var(--text-muted);\n    font-size: clamp(0.75rem, 2vw, 0.9rem);\n    max-width: var(--max-width);\n    width: 100%;\n    margin: 0 auto;\n    padding: clamp(1rem, 3vw, 2rem) clamp(1rem, 2.5vw, 1.25rem);\n    position: relative;\n    z-index: 1;\n    transition: max-width .25s ease;\n}\n\n.legal {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.footer-nav {\n    display: flex;\n    gap: 1.5rem;\n}\n\n.footer-nav a {\n    color: var(--text-muted);\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.footer-nav a:hover {\n    color: var(--accent);\n}\n\n.footer-nav a i {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n}\n\n.footer-text a {\n    color: var(--text-muted);\n}\n\n.footer-text a:hover {\n    color: var(--accent);\n}\n\n/** Main  **/\n\nmain {\n    max-width: var(--max-width-content);\n    width: 100%;\n    margin: 0 auto;\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    background-color: var(--bg-surface);\n    border-radius: var(--radius);\n    position: relative;\n    overflow: hidden;\n    z-index: 1;\n    transition: max-width .25s ease;\n}\n\n.paste-area {\n    flex: 1;\n    width: 100%;\n    display: flex;\n    flex-direction: column;\n    border-radius: var(--radius);\n    position: relative;\n    transition: background-color 0.25s ease, border-color 0.25s ease;\n    border: 2px dashed transparent;\n}\n\n.paste-area::after {\n    content: '';\n    position: absolute;\n    bottom: 0;\n    left: 0;\n    right: 0;\n    height: 120px;\n    background: linear-gradient(to bottom,\n        transparent 0%,\n        color-mix(in srgb, var(--bg-surface) 40%, transparent) 40%,\n        color-mix(in srgb, var(--bg-surface) 80%, transparent) 70%,\n        var(--bg-surface) 100%);\n    pointer-events: none;\n    z-index: 5;\n    border-radius: 0 0 var(--radius) var(--radius);\n}\n\n.paste-area.dragover,\n.paste-area.window-dragover {\n    background-color: color-mix(in srgb, var(--bg-surface) 90%, var(--accent) 10%);\n    border-color: var(--accent);\n}\n\n.paste-area.dragover .paste-placeholder i.fa-cloud-arrow-up,\n.paste-area.window-dragover .paste-placeholder i.fa-cloud-arrow-up {\n    color: var(--accent);\n    transform: scale(1.1) translateY(-4px);\n}\n\n.paste-area.dragover .paste-placeholder p,\n.paste-area.window-dragover .paste-placeholder p {\n    color: var(--accent);\n}\n\n.paste-placeholder {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n    display: flex;\n    justify-content: center;\n    flex-direction: column;\n    align-items: center;\n    z-index: 2;\n    font-size: clamp(1rem, 3vw, 1.5rem);\n}\n\n.paste-placeholder i.fa-cloud-arrow-up {\n    font-size: clamp(2rem, 8vw, 3.5rem);\n    color: var(--text-muted);\n    margin-bottom: clamp(0.5rem, 2vw, 1.5rem);\n    transition: color 0.25s ease, transform 0.25s ease;\n}\n\n.paste-placeholder p {\n    color: var(--text);\n    margin-bottom: clamp(1.2rem, 2vw, 1.5rem);\n    transition: color 0.25s ease;\n    font-weight: 600;\n}\n\n.paste-hints {\n    display: flex;\n    gap: clamp(1rem, 3vw, 1.5rem);\n    justify-content: center;\n    color: var(--text-muted);\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n}\n\n.paste-hints span,\n.paste-hints button {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n}\n\n.paste-hints button {\n    background: none;\n    border: none;\n    padding: 0;\n    color: var(--text-muted);\n    font-weight: 400;\n}\n\n.paste-hints button.btn:hover {\n    background-image: none;\n}\n\n.paste-hints i {\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n}\n\n.paste-area .btn-save {\n    position: absolute;\n    bottom: 1.5rem;\n    left: 50%;\n    transform: translateX(-50%);\n    width: fit-content;\n    z-index: 10;\n    font-size: clamp(1rem, 2.5vw, 1.1rem);\n    padding: 0.85rem 2rem;\n}\n\n.paste-area .btn-save:not(:disabled) {\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n    animation: btn-save-pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes btn-save-pulse {\n    0% {\n        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 80%, transparent);\n    }\n    70% {\n        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 12px color-mix(in srgb, var(--accent) 0%, transparent);\n    }\n    100% {\n        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);\n    }\n}\n\n.paste-area textarea {\n    view-transition-name: log;\n    flex: 1;\n    width: 100%;\n    background: transparent;\n    border: none;\n    outline: none;\n    resize: none;\n    padding: clamp(.5rem, 3vw, 1.2rem);\n    font-family: var(--font-mono), monospace;\n    font-size: clamp(0.75rem, 2vw, 0.9rem);\n    color: var(--text);\n    position: relative;\n}\n\n.paste-error {\n    display: none;\n    position: absolute;\n    top: clamp(1rem, 2.5vw, 1.5rem);\n    right: clamp(1rem, 2.5vw, 1.5rem);\n    color: var(--error);\n    font-weight: 600;\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    padding: clamp(0.7rem, 2vw, 0.8rem) clamp(1rem, 2.5vw, 1.25rem);\n    background-color: var(--error-bg);\n    border: 1px solid var(--error-border);\n    border-radius: 8px;\n    z-index: 1000;\n    animation: error-slide-in 0.3s ease-out;\n}\n\n.paste-error.show {\n    display: block;\n}\n\n@keyframes error-slide-in {\n    from {\n        transform: translateX(100%);\n        opacity: 0;\n    }\n    to {\n        transform: translateX(0);\n        opacity: 1;\n    }\n}\n\n/** Log Page Layout **/\n\n.log-body main {\n    flex: 0 0 auto;\n    border-radius: var(--radius) var(--radius) 0 0;\n}\n\n.log-container {\n    max-width: var(--max-width-content);\n    min-width: var(--max-width-content);\n    margin: 0 auto;\n    background-color: var(--bg-surface);\n    position: relative;\n    z-index: 1;\n    transition: max-width .25s ease, min-width .25s ease;\n}\n\n.log-footer {\n    max-width: var(--max-width-content);\n    width: 100%;\n    margin: 0 auto;\n    padding: 0 var(--page-padding);\n    background-color: var(--bg-surface);\n    border-radius: 0 0 var(--radius) var(--radius);\n    position: relative;\n    z-index: 1;\n    transition: max-width .25s ease;\n}\n\n/** Log Header **/\n\n.log-header {\n    padding: clamp(1rem, 3vw, 1.5rem) var(--page-padding);\n    border-bottom: 1px solid var(--border);\n}\n\n.log-header-inner {\n    display: flex;\n    justify-content: space-between;\n    align-items: flex-start;\n    flex-wrap: wrap;\n    gap: 1rem;\n}\n\n.log-header .left {\n    flex: 1 1 300px;\n    min-width: 0;\n}\n\n.log-header .right {\n    flex-shrink: 0;\n}\n\n.log-header .log-title h1 {\n    font-size: clamp(1.1rem, 3vw, 1.25rem);\n    font-weight: 600;\n    color: var(--text);\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    line-height: 1.3;\n    flex-wrap: wrap;\n}\n\n.log-header .log-title h1 i {\n    color: var(--accent);\n}\n\n.log-header .log-title {\n    display: flex;\n    align-items: center;\n    gap: 0.75rem;\n    flex-wrap: wrap;\n}\n\n.log-header .log-title-actions {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.log-header .log-url-btn {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.4rem;\n    padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.4rem, 1.5vw, 0.5rem);\n    background-color: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 6px;\n    font-size: clamp(0.7rem, 1.8vw, 0.75rem);\n    color: var(--text-muted);\n    font-family: var(--font-mono), monospace;\n    line-height: 1;\n    transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;\n    vertical-align: middle;\n    cursor: pointer;\n}\n\n.log-header .log-url-btn:hover {\n    border-color: var(--accent-border);\n    background-color: var(--accent-bg);\n    color: var(--text);\n}\n\n.log-header .log-url-btn i {\n    font-size: 0.85em;\n    opacity: 0.5;\n    color: var(--accent);\n}\n\n.log-info-rows {\n    display: flex;\n    flex-direction: column;\n    gap: 0.5rem;\n    margin-top: 1rem;\n}\n\n.log-info-row {\n    padding: clamp(0.4rem, 1.5vw, 0.5rem) clamp(0.6rem, 2vw, 0.75rem);\n    background-color: var(--surface);\n    border-radius: 6px;\n}\n\n.info-row-header {\n    display: flex;\n    align-items: center;\n    gap: 0.4rem;\n    font-size: clamp(0.7rem, 1.8vw, 0.75rem);\n    font-weight: 600;\n    color: var(--text-muted);\n    letter-spacing: 0.03em;\n    padding-right: clamp(0.6rem, 2vw, 0.75rem);\n    border-right: 1px solid var(--border);\n}\n\n.info-row-header i {\n    font-size: 0.7rem;\n    opacity: 0.8;\n}\n\n.info-row-items {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: 0.75rem;\n}\n\n.info-item {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.4rem;\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    color: var(--text-muted);\n}\n\n.info-label {\n    font-weight: 500;\n}\n\n.info-value {\n    color: var(--text);\n    font-weight: 500;\n    font-family: var(--font-mono), monospace;\n}\n\n.log-header .details {\n    display: flex;\n    align-items: center;\n}\n\n.log-header .log-info-actions {\n    display: flex;\n    gap: 0.5rem;\n    flex-wrap: wrap;\n    align-items: center;\n}\n\n/** Problems Panel **/\n\n.problems-panel-container {\n    border-top: 1px solid var(--border);\n    padding-top: clamp(0.75rem, 2vw, 1rem);\n    margin-top: clamp(0.75rem, 2vw, 1rem);\n}\n\n.problems-panel {\n    overflow: hidden;\n    border: 1px solid var(--border);\n    background-color: var(--surface);\n    border-radius: 8px;\n}\n\n.problems-header {\n    display: flex;\n    align-items: center;\n    gap: clamp(0.5rem, 1.5vw, 0.6rem);\n    padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem);\n    background-color: var(--surface);\n    border-bottom: 1px solid var(--border);\n}\n\n.problems-count {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    min-width: clamp(1.25rem, 2.5vw, 1.4rem);\n    height: clamp(1.25rem, 2.5vw, 1.4rem);\n    background-color: var(--accent);\n    color: var(--bg);\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    font-weight: 600;\n    border-radius: 4px;\n}\n\n.problems-title {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n    font-weight: 600;\n    color: var(--text);\n}\n\n.problems-list {\n    display: flex;\n    flex-direction: column;\n}\n\n.problem-item {\n    display: flex;\n    flex-direction: column;\n    gap: clamp(0.4rem, 1vw, 0.5rem);\n    padding: clamp(0.75rem, 2vw, 1rem) clamp(0.85rem, 2.5vw, 1rem);\n    border-bottom: 1px solid var(--border);\n}\n\n.problem-item:last-child {\n    border-bottom: none;\n}\n\n.problem-entry {\n    display: flex;\n    border-radius: 5px;\n    overflow: hidden;\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    background: var(--error-bg);\n    border: 1px solid var(--error-border);\n    text-decoration: none;\n    transition: border-color 0.15s ease;\n}\n\n.problem-entry:hover {\n    border-color: var(--error);\n}\n\n.problem-label {\n    display: flex;\n    align-items: center;\n    gap: 0.4rem;\n    padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem);\n    font-weight: 600;\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    white-space: nowrap;\n    background-color: var(--error);\n    color: #fff;\n}\n\n.problem-text {\n    display: flex;\n    align-items: center;\n    padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem);\n    color: var(--text);\n    font-weight: 500;\n    flex: 1;\n    word-break: break-word;\n}\n\n.problem-line {\n    display: inline-flex;\n    align-items: center;\n    margin: clamp(0.25rem, 0.8vw, 0.35rem) clamp(0.55rem, 1.5vw, 0.65rem);\n    padding: 0.2em 0.5em;\n    font-family: var(--font-mono), monospace;\n    font-size: clamp(0.7rem, 1.6vw, 0.75rem);\n    font-weight: 500;\n    color: var(--text-muted);\n    background-color: var(--surface);\n    border: 1px solid var(--border);\n    border-radius: 4px;\n    white-space: nowrap;\n}\n\n.problem-solutions {\n    display: flex;\n    flex-direction: column;\n    gap: clamp(0.25rem, 0.5vw, 0.3rem);\n    padding: clamp(0.4rem, 1vw, 0.5rem) clamp(0.55rem, 1.5vw, 0.65rem);\n    background-color: var(--surface);\n    border-radius: 5px;\n}\n\n.problem-solutions-label {\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    font-weight: 600;\n    color: var(--text-muted);\n}\n\n.problem-solution {\n    display: flex;\n    align-items: baseline;\n    gap: clamp(0.4rem, 1vw, 0.5rem);\n    font-size: clamp(0.8rem, 1.8vw, 0.85rem);\n}\n\n.problem-solution i {\n    color: var(--accent);\n    font-size: 0.85em;\n}\n\n.problem-solution span {\n    color: var(--text);\n}\n\n/** Log Viewer **/\n\n.log {\n    view-transition-name: log;\n    padding: 0;\n    border-bottom: 1px solid var(--border);\n    background-color: var(--bg-elevated);\n    position: relative;\n    flex: 1;\n}\n\n.setting-floating-scrollbar .floating-scrollbar-container {\n    display: flex;\n}\n\n.floating-scrollbar-container {\n    --floating-scrollbar-width: 0;\n    --floating-scrollbar-content-width: 0;\n\n    position: fixed;\n    display: none;\n    justify-content: center;\n    bottom: 0;\n    width: 100%;\n    z-index: 10;\n}\n\n.floating-scrollbar {\n    overflow-x: scroll;\n    width: var(--floating-scrollbar-width);\n}\n\n.floating-scrollbar-content {\n    width: var(--floating-scrollbar-content-width);\n    height: var(--scrollbar-height);\n}\n\n.log-inner {\n    overflow-y: hidden;\n    font-family: var(--font-mono), monospace;\n    font-size: clamp(0.75rem, 2vw, 0.9rem);\n    line-height: 1.6;\n    overflow-x: auto;\n    position: relative;\n    padding: 0.5rem 0 0;\n    display: grid;\n    grid-template-columns: auto 1fr;\n    contain: layout style paint;\n    will-change: scroll-position;\n}\n\n.log-inner .entry {\n    display: contents;\n    width: 100%;\n}\n\n.log-inner .entry.entry-error .line-content,\n.log-inner .entry.entry-error .line-number-container{\n    background-color: var(--error-bg);\n}\n\n.log-inner .line-number-container {\n    min-width: 2.75rem;\n    padding: 0 0.4rem;\n    border-right: 1px solid var(--border);\n    text-align: right;\n    user-select: none;\n}\n\n\n.log-inner .line-number {\n    padding: clamp(0.08rem, 1vw, 0.1rem) clamp(0.2rem, 1.5vw, 0.25rem);\n    color: var(--text-muted);\n    font-weight: 500;\n    font-size: clamp(0.65rem, 1.8vw, 0.8rem);\n    border-radius: 4px;\n}\n\n.log-inner .entry.line-active .line-number {\n    background-color: var(--accent);\n    color: var(--bg);\n    font-weight: 600;\n}\n\n\n.log-inner .entry.line-active .line-number-container,\n.log-inner .entry.line-active .line-content {\n    background-color: color-mix(in srgb, var(--accent) 15%, var(--bg) 85%);\n}\n\n.log-inner .entry.entry-error.line-active .line-number {\n    background-color: var(--error);\n    color: #fff;\n}\n\n.log-inner .entry.entry-error.line-active .line-number-container,\n.log-inner .entry.entry-error.line-active .line-content {\n    background-color: color-mix(in srgb, var(--error) 25%, var(--bg) 75%);\n}\n\n.log-inner .line-content {\n    padding-left: clamp(0.4rem, 1vw, 0.9rem);\n    padding-right: clamp(0.4rem, 2vw, 0.6rem);\n    word-break: break-word;\n    overflow-wrap: anywhere;\n    color: var(--text);\n}\n\n/* Firefox fallback: use table layout instead of grid */\n@supports (-moz-appearance: none) {\n    :root {\n        --browser: 'firefox';\n    }\n    .log-inner {\n        display: table;\n        table-layout: fixed;\n        width: 100%;\n    }\n\n    .log-inner .entry,\n    .log-inner .collapsed-lines {\n        display: table-row;\n    }\n\n    .log-inner .line-number-container,\n    .log-inner .collapsed-lines > div:first-child {\n        display: table-cell;\n        width: 3.6rem;\n    }\n\n    @media (max-width: 600px) {\n        .log-inner .line-number-container {\n            width: 2.7rem;\n        }\n    }\n\n    .log-inner .line-content,\n    .log-inner .collapsed-lines-count {\n        display: table-cell;\n    }\n\n    .log-inner .collapsed-lines-count {\n        text-align: center;\n        vertical-align: middle;\n    }\n\n    body.setting-no-wrap .log {\n        overflow-x: auto;\n    }\n\n    body.setting-no-wrap .log-inner {\n        table-layout: auto;\n    }\n}\n\n.collapsed-lines {\n    display: contents;\n    cursor: pointer;\n}\n\n.collapsed-lines > div:first-child {\n    background-color: var(--surface);\n    border-right: 1px solid var(--border);\n}\n\n.collapsed-lines-count {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 0.75rem;\n    padding: 0.6rem 1.25rem;\n    background-color: var(--surface);\n    color: var(--text);\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    font-family: var(--font-mono), monospace;\n    font-weight: 500;\n    transition: background-color 0.15s ease, color 0.15s ease;\n}\n\n.collapsed-lines:hover .collapsed-lines-count {\n    background-color: var(--accent-bg);\n    color: var(--accent);\n}\n\n.collapsed-lines-count i {\n    font-size: 0.75rem;\n    color: var(--text-muted);\n    transition: color 0.15s ease;\n}\n\n.collapsed-lines:hover .collapsed-lines-count i {\n    color: var(--accent);\n}\n\n.log-inner .level {\n    display: block;\n    white-space: pre-wrap;\n    tab-size: 4;\n    width: 100%;\n}\n\n.log-inner .level-prefix {\n    font-weight: 500;\n    opacity: 0.9;\n}\n\n/** Log Level Styles **/\n\n.level {\n    white-space: pre-wrap;\n    tab-size: 4;\n    word-break: normal;\n}\n\n.level-prefix {\n    font-weight: bold;\n}\n\n.level-info {\n    color: var(--text);\n}\n\n.level-title {\n    font-weight: bold;\n    color: var(--bg);\n    background-color: var(--accent);\n    padding: 0 8px;\n    border-radius: 2px;\n}\n\n.level-info .level-prefix,\n.level-notice .level-prefix,\n.level-debug .level-prefix {\n    color: var(--accent);\n}\n\n.level-warning {\n    color: #FF6625;\n}\n\n.level-error,\n.level-critical,\n.level-emergency,\n.level-stacktrace {\n    color: var(--error);\n}\n\n.level-comment {\n    color: #A4A4A4;\n}\n\n/** Minecraft Format Colors **/\n\n.format-black {\n    color: #000;\n}\n\n.format-darkblue {\n    color: #0000AA;\n}\n\n.format-darkgreen {\n    color: #00AA00;\n}\n\n.format-darkaqua {\n    color: #00AAAA;\n}\n\n.format-darkred {\n    color: #AA0000;\n}\n\n.format-darkpurple {\n    color: #AA00AA;\n}\n\n.format-gold {\n    color: #FFAA00;\n}\n\n.format-gray {\n    color: #AAAAAA;\n}\n\n.format-darkgray {\n    color: #555555;\n}\n\n.format-blue {\n    color: #5555FF;\n}\n\n.format-green {\n    color: #55FF55;\n}\n\n.format-aqua {\n    color: #55FFFF;\n}\n\n.format-red {\n    color: #FF5555;\n}\n\n.format-lightpurple {\n    color: #FF55FF;\n}\n\n.format-yellow {\n    color: #FFFF55;\n}\n\n.format-white {\n    color: #FFFFFF;\n}\n\n.format-reset {\n    color: #FFFFFF;\n    font-weight: normal;\n    text-decoration: none;\n    font-style: normal;\n    display: inline-block;\n}\n\n.format-bold {\n    font-weight: bold;\n}\n\n.format-underline {\n    text-decoration: underline;\n}\n\n.format-italic {\n    font-style: italic;\n}\n\n.format-strike {\n    text-decoration: line-through;\n}\n\n/** Log Content Styles **/\n\n.multiline {\n    padding-left: 64px;\n}\n\n.highlight-error {\n    background: var(--error);\n    color: #fff;\n    padding: 0 3px;\n    border-radius: 2px;\n    font-weight: bold;\n    display: inline-block;\n}\n\n.highlight-warning {\n    background: #FF6625;\n    color: var(--text);\n    padding: 0 3px;\n    border-radius: 2px;\n    font-weight: bold;\n    display: inline-block;\n}\n\n.entry {\n    overflow-wrap: anywhere;\n}\n\n@media (max-width: 800px) {\n    .multiline {\n        padding-left: 0;\n    }\n\n    .problem-line {\n        display: none;\n    }\n}\n\n/** Log bottom **/\n\n.log-bottom {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: clamp(0.75rem, 2vw, 1rem) 0;\n    border-bottom: 1px solid var(--border);\n}\n\n.log-bottom .actions {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 10px;\n}\n\n/** Generic Popover **/\n\n.popover-wrapper {\n    position: relative;\n}\n\n.popover-trigger {\n    cursor: pointer;\n}\n\n.popover-trigger i {\n    transition: transform 0.2s ease;\n}\n\n.popover-content {\n    position: fixed;\n    inset: unset;\n    margin-bottom: 0.5rem;\n    background-color: var(--bg-surface);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: 0.5rem;\n    min-width: 200px;\n    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);\n    overflow: hidden;\n}\n\n.popover-content:popover-open {\n    display: flex;\n    flex-direction: column;\n    gap: 0.25rem;\n}\n\n.popover-content::after {\n    content: '';\n    position: absolute;\n    bottom: -6px;\n    right: 1rem;\n    width: 10px;\n    height: 10px;\n    background-color: var(--bg-surface);\n    border-right: 1px solid var(--border);\n    border-bottom: 1px solid var(--border);\n    transform: rotate(45deg);\n}\n\n.popover-content::backdrop {\n    background: transparent;\n}\n\n/* Popover danger variant */\n.popover-content.popover-danger {\n    background-color: var(--bg-surface);\n    border-color: var(--error);\n    text-align: center;\n}\n\n.popover-content.popover-danger::after {\n    background-color: var(--bg-surface);\n    border-color: var(--error);\n}\n\n.popover-error {\n    display: none;\n    font-weight: 600;\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    padding: clamp(0.2rem, 2vw, 0.2rem);\n    color: var(--text);\n    background-color: var(--error-bg);\n    border: 1px solid var(--error-border);\n    border-radius: 8px;\n    margin-bottom: 0.5rem;\n}\n\n/** Settings Popover **/\n\n.settings-trigger {\n    anchor-name: --settings-trigger;\n}\n\n.settings-overlay {\n    position-anchor: --settings-trigger;\n    bottom: anchor(top);\n    right: anchor(right);\n}\n\n/** Delete Popover **/\n\n.delete-trigger {\n    anchor-name: --delete-trigger;\n}\n\n.delete-trigger:hover {\n    opacity: 1;\n}\n\n.delete-overlay {\n    position-anchor: --delete-trigger;\n    bottom: anchor(top);\n    right: anchor(right);\n    min-width: 250px;\n    padding: 1rem;\n    gap: 0.75rem;\n}\n\n.delete-message {\n    font-size: 0.9rem;\n    color: var(--text);\n    font-weight: 500;\n    margin-bottom: 10px;\n}\n\n.delete-actions {\n    display: flex;\n    gap: 0.5rem;\n}\n\n.delete-actions .btn {\n    flex: 1;\n    justify-content: center;\n}\n\n.setting {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: 1rem;\n    padding: 0.5rem 0.75rem;\n    border-radius: 6px;\n    cursor: pointer;\n    transition: background-color 0.1s ease;\n}\n\n.setting:hover {\n    background-color: var(--surface);\n}\n\n.setting-label {\n    font-size: 0.9rem;\n    color: var(--text);\n}\n\n.setting-checkbox {\n    appearance: none;\n    width: 2.5rem;\n    height: 1.4rem;\n    background-color: var(--surface);\n    border-radius: 1rem;\n    position: relative;\n    cursor: pointer;\n    transition: background-color 0.15s ease;\n    flex-shrink: 0;\n}\n\n.setting-checkbox::before {\n    content: '';\n    position: absolute;\n    top: 0.2rem;\n    left: 0.2rem;\n    width: 1rem;\n    height: 1rem;\n    background-color: var(--text-muted);\n    border-radius: 50%;\n    transition: left 0.15s ease, background-color 0.15s ease;\n}\n\n.setting-checkbox:checked {\n    background-color: var(--accent);\n}\n\n.setting-checkbox:checked::before {\n    left: 1.3rem;\n    background-color: var(--bg);\n}\n\n.log-details {\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    align-items: center;\n    gap: clamp(0.75rem, 2vw, 1.25rem);\n    padding: clamp(0.75rem, 2vw, 1rem) 0;\n    border-top: 1px solid var(--border);\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n    color: var(--text-muted);\n}\n\n.log-details:has(:nth-child(3)) {\n    grid-template-columns: 1fr 1fr 1fr;\n}\n\n.log-details .meta-data {\n    display: flex;\n    align-items: center;\n    gap: 0.7rem;\n    flex-wrap: wrap;\n}\n\n.log-details i {\n    margin-right: 0.25rem;\n}\n\n.log-details *:nth-child(2) {\n    text-align: center;\n}\n\n.log-details *:last-child {\n    text-align: right;\n}\n\n@media (max-width: 640px) {\n    .log-details {\n        grid-template-columns: 1fr;\n        gap: 0.5rem;\n        justify-content: center;\n    }\n\n    .log-details:has(:nth-child(3)) {\n        grid-template-columns: 1fr;\n    }\n\n    .log-details .meta-data {\n        justify-content: center;\n    }\n\n    .log-details *:nth-child(2),\n    .log-details *:last-child {\n        text-align: center;\n    }\n}\n\n/** Error Page **/\n\n.error-page {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    text-align: center;\n    padding: clamp(2rem, 5vw, 3rem) clamp(1rem, 3vw, 1.5rem);\n}\n\n.error-code {\n    font-size: clamp(4rem, 15vw, 8rem);\n    font-weight: 600;\n    color: var(--text);\n    line-height: 1;\n    opacity: 0.15;\n}\n\n.error-message {\n    font-size: clamp(1.25rem, 4vw, 1.8rem);\n    font-weight: 700;\n    color: var(--text);\n    margin-top: -0.5rem;\n}\n\n.error-description {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n    color: var(--text-muted);\n    margin-top: 0.75rem;\n    margin-bottom: clamp(1.5rem, 4vw, 2rem);\n}\n\n/** API Documentation **/\n\n.api-docs-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: clamp(1rem, 3vw, 2rem);\n    padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem);\n    border-bottom: 1px solid var(--border);\n    background-color: var(--bg-elevated);\n}\n\n.api-docs-header-content {\n    flex: 1;\n}\n\n.api-docs-header h1 {\n    font-size: clamp(1.5rem, 4vw, 2rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-bottom: 0.75rem;\n}\n\n.api-docs-header p {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n    color: var(--text-muted);\n    line-height: 1.6;\n}\n\n.api-docs-header p strong {\n    color: var(--text);\n    font-weight: 600;\n}\n\n.api-docs-toc {\n    padding: clamp(1rem, 2.5vw, 1.25rem) clamp(1rem, 3vw, 1.5rem);\n    margin-bottom: 0;\n    background-color: var(--bg-elevated);\n    border-bottom: 1px solid var(--border);\n    display: flex;\n    align-items: center;\n    gap: clamp(0.75rem, 2vw, 1rem);\n    flex-wrap: wrap;\n}\n\n.api-docs-toc h3 {\n    font-size: clamp(0.85rem, 2vw, 0.95rem);\n    font-weight: 500;\n    color: var(--text-muted);\n    margin: 0;\n    white-space: nowrap;\n    opacity: 0.6;\n    pointer-events: none;\n    user-select: none;\n}\n\n.api-docs-toc h3::after {\n    content: ':';\n    margin-left: 0.25rem;\n    opacity: 0.4;\n}\n\n.api-docs-toc-nav {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n    align-items: center;\n    justify-content: flex-start;\n}\n\n.api-docs-toc-nav a {\n    display: inline-flex;\n    align-items: center;\n    padding: 0.4rem 0.85rem;\n    color: var(--text-muted);\n    font-size: clamp(0.8rem, 2vw, 0.85rem);\n    border-radius: 6px;\n    transition: background-color 0.15s ease, color 0.15s ease;\n    text-decoration: none;\n    font-weight: 500;\n    white-space: nowrap;\n    cursor: pointer;\n}\n\n.api-docs-toc-nav a:hover {\n    background-color: rgba(255, 255, 255, 0.04);\n    color: var(--text);\n}\n\n.api-docs-toc-nav a:active {\n    background-color: rgba(255, 255, 255, 0.06);\n}\n\n.api-docs-section {\n    padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem);\n    border-bottom: 1px solid var(--border);\n    scroll-margin-top: 1rem;\n}\n\n.api-docs-section:last-of-type {\n    border-bottom: none;\n}\n\n.api-docs-section h2 {\n    font-size: clamp(1.25rem, 3vw, 1.5rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-top: 0;\n    margin-bottom: 1rem;\n}\n\n.api-docs-section p {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n    color: var(--text);\n    line-height: 1.6;\n    margin-top: 0;\n    margin-bottom: 1.5rem;\n}\n\n.api-docs-section p + p {\n    margin-top: -0.75rem;\n}\n\n.api-docs-section p + .api-endpoint,\n.api-docs-section p + .api-table,\n.api-docs-section p + h3,\n.api-docs-section p + h4 {\n    margin-top: 0;\n}\n\n.api-docs-section h3 {\n    font-size: clamp(1rem, 2.5vw, 1.1rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-top: 2rem;\n    margin-bottom: 1rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.api-docs-section h2 + h3,\n.api-docs-section .api-endpoint + h3,\n.api-docs-section .api-table + h3,\n.api-docs-section .api-note + h3 {\n    margin-top: 1.5rem;\n}\n\n.api-docs-section h4 {\n    font-size: clamp(0.95rem, 2vw, 1rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-top: 1.5rem;\n    margin-bottom: 0.75rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n.api-docs-section h3 + h4 {\n    margin-top: 1rem;\n}\n\n.api-endpoint {\n    display: flex;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: clamp(0.5rem, 1.5vw, 0.75rem);\n    padding: clamp(0.75rem, 2vw, 1rem) clamp(1rem, 2.5vw, 1.25rem);\n    background-color: var(--bg-inset);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    margin-top: 0;\n    margin-bottom: 1.5rem;\n    font-family: var(--font-mono), monospace;\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n}\n\n.api-endpoint + .api-note,\n.api-endpoint + .api-table,\n.api-endpoint + h3,\n.api-endpoint + h4 {\n    margin-top: 0;\n}\n\n.api-method {\n    display: inline-flex;\n    align-items: center;\n    padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.6rem, 1.5vw, 0.75rem);\n    background-color: var(--accent);\n    color: var(--bg);\n    font-weight: 600;\n    border-radius: 4px;\n    font-size: clamp(0.75rem, 1.8vw, 0.8rem);\n    text-transform: uppercase;\n    letter-spacing: 0.05em;\n}\n\n.api-url {\n    color: var(--text);\n    font-weight: 500;\n    word-break: break-all;\n}\n\n.content-type {\n    display: inline-flex;\n    align-items: center;\n    padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.6rem, 1.5vw, 0.75rem);\n    background-color: var(--surface);\n    color: var(--text-muted);\n    border: 1px solid var(--border);\n    border-radius: 4px;\n    font-size: clamp(0.7rem, 1.8vw, 0.75rem);\n    font-weight: 500;\n    font-family: var(--font-mono), monospace;\n}\n\n.api-table {\n    width: 100%;\n    border-collapse: collapse;\n    margin-top: 0;\n    margin-bottom: 1.5rem;\n    background-color: var(--bg-inset);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    overflow: hidden;\n}\n\n.api-table + .api-note,\n.api-table + h3,\n.api-table + h4,\n.api-table + .api-code {\n    margin-top: 0;\n}\n\n.api-table th {\n    background-color: var(--surface);\n    padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem);\n    text-align: left;\n    font-weight: 600;\n    font-size: clamp(0.8rem, 2vw, 0.85rem);\n    color: var(--text);\n    border-bottom: 1px solid var(--border);\n}\n\n.api-table td {\n    padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem);\n    border-bottom: 1px solid var(--border);\n    font-size: clamp(0.85rem, 2vw, 0.9rem);\n}\n\n.api-table tr:last-child td {\n    border-bottom: none;\n}\n\n.api-table tr:hover {\n    background-color: var(--surface);\n}\n\n.api-field {\n    white-space: nowrap;\n    font-family: var(--font-mono), monospace;\n    color: var(--accent);\n    font-weight: 500;\n}\n\n.api-type {\n    font-family: var(--font-mono), monospace;\n    color: var(--text-muted);\n    font-weight: 500;\n}\n\n.api-description {\n    color: var(--text);\n    line-height: 1.5;\n}\n\n.api-required {\n    text-align: center;\n    font-size: 1rem;\n}\n\n.api-required i {\n    color: var(--text-muted);\n    opacity: 0.5;\n}\n\n.api-required.required i {\n    color: var(--accent);\n    opacity: 1;\n}\n\n.api-code {\n    background-color: var(--bg-elevated);\n    border: 1px solid var(--border);\n    border-radius: 8px;\n    padding: clamp(1rem, 2.5vw, 1.25rem);\n    overflow-x: auto;\n    font-family: var(--font-mono), monospace;\n    font-size: clamp(0.8rem, 2vw, 0.85rem);\n    line-height: 1.6;\n    color: var(--text);\n    margin-top: 0;\n    margin-bottom: 1.5rem;\n    white-space: pre;\n    tab-size: 2;\n    font-variant-ligatures: none;\n}\n\n.api-code + h3,\n.api-code + h4,\n.api-code + .api-note {\n    margin-top: 1.5rem;\n}\n\n.api-note {\n    margin-top: 0;\n    margin-bottom: 1.5rem;\n    padding: clamp(0.75rem, 2vw, 0.85rem) clamp(0.85rem, 2.5vw, 1rem);\n    border-radius: 8px;\n    border: 1px solid var(--accent-border);\n    background-color: var(--accent-bg);\n    font-size: clamp(0.85rem, 1.8vw, 0.9rem);\n    color: var(--text);\n    line-height: 1.6;\n}\n\n.api-note .content-type {\n    margin: 0 10px;\n    white-space: normal;\n    word-break: break-word;\n    display: inline;\n    vertical-align: baseline;\n}\n\n.api-docs-notes {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    gap: clamp(1rem, 3vw, 2rem);\n    padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem);\n    background-color: var(--bg-elevated);\n    border: 1px solid var(--border);\n}\n\n.api-docs-notes-content {\n    flex: 1;\n}\n\n.api-docs-notes-content h2 {\n    font-size: clamp(1.25rem, 3vw, 1.5rem);\n    font-weight: 600;\n    color: var(--text);\n    margin-bottom: 0.75rem;\n}\n\n.api-docs-notes-content p {\n    font-size: clamp(0.9rem, 2vw, 1rem);\n    color: var(--text-muted);\n    line-height: 1.6;\n    margin-bottom: 1rem;\n}\n\n.api-docs-notes-actions {\n    display: flex;\n    gap: 0.75rem;\n    flex-wrap: wrap;\n}\n\n@media (max-width: 1024px) {\n    body.setting-full-width {\n        --max-width-content: min(100%, calc(var(--max-width)));\n    }\n\n    main {\n        padding: 0;\n        border-radius: 0;\n    }\n\n    .log-body main {\n        border-radius: 0;\n    }\n\n    .log-container,\n    .log-footer {\n        border-radius: 0;\n    }\n}\n\n@media (max-width: 640px) {\n    .log-inner .line-number-container {\n        min-width: unset;\n        padding: 0;\n    }\n\n    footer {\n        justify-content: center;\n    }\n\n    .legal,\n    .footer-text {\n        order: 2;\n    }\n\n    .footer-nav {\n        width: 100%;\n        order: 1;\n        justify-content: center;\n    }\n\n    .problem-entry {\n        align-items: stretch;\n    }\n\n    .problem-line {\n        display: none;\n    }\n\n    .api-docs-header {\n        flex-direction: column;\n        align-items: flex-start;\n        gap: 1.5rem;\n    }\n\n    .api-docs-section {\n        padding: 1.5rem 1rem;\n    }\n\n    .api-docs-notes {\n        flex-direction: column;\n        align-items: flex-start;\n        gap: 1.5rem;\n    }\n\n    .api-endpoint {\n        flex-direction: column;\n        align-items: flex-start;\n    }\n\n    .api-docs-toc {\n        padding: 1rem;\n    }\n\n    .api-docs-toc-nav {\n        gap: 0.25rem;\n    }\n\n    .api-docs-toc-nav a {\n        padding: 0.35rem 0.7rem;\n    }\n}\n"
  },
  {
    "path": "web/public/js/log.js",
    "content": "/* line numbers */\nupdateLineNumber(location.hash);\n\nfor (let line of document.querySelectorAll('.line-number')) {\n    line.addEventListener(\"click\", () =>\n        updateLineNumber(line.attributes.getNamedItem(\"id\").value));\n}\n\nfunction updateLineNumber(id) {\n    if (id && id.startsWith('#')) {\n        id = id.substring(1);\n    }\n\n    if (!id) {\n        return;\n    }\n\n    let element = document.getElementById(id);\n    if (element.classList.contains(\"line-number\")) {\n        for (const line of document.querySelectorAll(\".line-active\")) {\n            line.classList.remove(\"line-active\");\n        }\n        element.closest('.entry').classList.add('line-active');\n    }\n}\n\n/* Scroll to top/bottom buttons */\nconst downButton = document.getElementById(\"down-button\");\nif (downButton) {\n    downButton.addEventListener(\"click\", () => scrollToHeight(document.body.scrollHeight));\n}\n\nconst upButton = document.getElementById(\"up-button\");\nif (upButton) {\n    upButton.addEventListener(\"click\", () => scrollToHeight(0));\n}\n\n/**\n * Scroll to a specific height\n * Disable smooth scrolling for large pages\n * @param {number} top height to scroll to\n * @param {number} [smoothScrollLimit] only use smooth scrolling if the distance is less than this value\n */\nfunction scrollToHeight(top, smoothScrollLimit = 10000) {\n    const distance = Math.abs(document.documentElement.scrollTop - top);\n    const behavior = (distance < smoothScrollLimit) ? \"smooth\" : \"instant\";\n    window.scrollTo({left: 0, top, behavior});\n}\n\n/* error collapse toggle */\nconst toggleErrorsButton = document.getElementById(\"error-toggle\");\nif (toggleErrorsButton) {\n    toggleErrorsButton.addEventListener(\"click\", toggleErrors);\n}\n\nfunction toggleErrors() {\n    if (toggleErrorsButton.classList.contains(\"toggled\")) {\n        toggleErrorsButton.classList.remove(\"toggled\");\n        uncollapseAllErrors();\n    } else {\n        toggleErrorsButton.classList.add(\"toggled\");\n        collapseAllErrors();\n    }\n}\n\nfunction collapseAllErrors() {\n    let firstNoErrorLine = false;\n    let lines = document.querySelectorAll('.log-inner > .entry');\n    let totalLines = lines.length;\n    for (const [i, line] of lines.entries()) {\n        let lineNumber = line.querySelector(\".line-number\").innerHTML;\n        if (line.classList.contains(\"entry-no-error\")) {\n            line.style.display = \"none\";\n\n            if (firstNoErrorLine === false) {\n                firstNoErrorLine = lineNumber;\n            }\n\n            if (i + 1 === totalLines && firstNoErrorLine) {\n                line.insertAdjacentElement(\"afterend\", generateCollapsedLines(firstNoErrorLine, lineNumber));\n            }\n        } else {\n            if (firstNoErrorLine) {\n                line.insertAdjacentElement(\"beforebegin\", generateCollapsedLines(firstNoErrorLine, lineNumber - 1));\n                firstNoErrorLine = false;\n            }\n        }\n    }\n}\n\nfunction uncollapseAllErrors() {\n    document.querySelectorAll('.entry-no-error').forEach(line => line.style.removeProperty(\"display\"));\n    document.querySelectorAll('.collapsed-lines').forEach(collapsed => collapsed.remove());\n}\n\nfunction handleCollapsedClick(e) {\n    let collapsed = e.currentTarget;\n    let positionElement = document.getElementById(`L${parseInt(collapsed.dataset.end) + 1}`);\n    let position;\n    if (positionElement) {\n        position = positionElement.getBoundingClientRect().top - window.scrollY;\n    }\n    for (let i = parseInt(collapsed.dataset.start); i <= parseInt(collapsed.dataset.end); i++) {\n        document.getElementById(`L${i}`).parentElement.parentElement.style.removeProperty(\"display\");\n    }\n    if (positionElement) {\n        window.scrollTo({\n            left: 0,\n            top: positionElement.getBoundingClientRect().top - position - collapsed.offsetHeight,\n            behavior: \"instant\"\n        });\n    }\n    collapsed.remove();\n}\n\nfunction generateCollapsedLines(start, end) {\n    let count = end - start + 1;\n    let string = count === 1 ? \"line\" : \"lines\";\n\n    let collapsedRow = document.createElement(\"div\");\n    collapsedRow.classList.add(\"collapsed-lines\");\n    collapsedRow.dataset.start = start;\n    collapsedRow.dataset.end = end;\n    collapsedRow.appendChild(document.createElement(\"div\"));\n    collapsedRow.addEventListener(\"click\", handleCollapsedClick);\n\n    let collapsedLinesCount = document.createElement(\"div\");\n    collapsedLinesCount.classList.add(\"collapsed-lines-count\");\n    let icon = document.createElement(\"i\");\n    icon.classList.add(\"fa-solid\", \"fa-angle-up\");\n    collapsedLinesCount.appendChild(icon);\n    collapsedLinesCount.append(` ${count} ${string} `);\n    collapsedLinesCount.append(icon.cloneNode());\n    collapsedRow.appendChild(collapsedLinesCount);\n\n    return collapsedRow;\n}\n\n/* convert timestamps */\nlet timeElements = document.querySelectorAll('[data-time]');\nfor (const element of timeElements) {\n    const timestamp = parseInt(element.dataset.time);\n    if (isNaN(timestamp)) {\n        continue;\n    }\n    const date = new Date(timestamp * 1000);\n    element.innerHTML = date.toLocaleString();\n}\n\n/* settings */\nconst settingCheckboxes = document.querySelectorAll(\".setting-checkbox\");\nsettingCheckboxes.forEach(checkbox => checkbox.addEventListener(\"change\", handleSettingChange));\n\nlet settingsChannel = null;\nif (typeof BroadcastChannel !== \"undefined\") {\n    settingsChannel = new BroadcastChannel(\"mc-logs-settings\");\n    settingsChannel.onmessage = (e) => {\n        if (e.data.type === \"settings-updated\") {\n            for (const checkbox of settingCheckboxes) {\n                checkbox.checked = !!e.data.settings[checkbox.dataset.key];\n                applySetting(checkbox);\n            }\n        }\n    };\n}\n\nfunction handleSettingChange(e) {\n    let checkbox = e.target;\n    applySetting(checkbox);\n    saveSettings();\n    if (settingsChannel) {\n        settingsChannel.postMessage({\n            type: \"settings-updated\",\n            settings: getCurrentSettings()\n        });\n    }\n}\n\nfunction applySetting(checkbox) {\n    let bodyClass = checkbox.dataset.bodyClass;\n    if (checkbox.checked) {\n        document.body.classList.add(bodyClass);\n    } else {\n        document.body.classList.remove(bodyClass);\n    }\n    switch (checkbox.dataset.key) {\n        case \"floatingScrollbar\":\n            initFloatingScrollbar();\n            break;\n    }\n}\n\nfunction getCurrentSettings() {\n    const data = {};\n    for (const checkbox of settingCheckboxes) {\n        data[checkbox.dataset.key] = checkbox.checked;\n    }\n    return data;\n}\n\nfunction saveSettings() {\n    const data = {};\n    for (const checkbox of settingCheckboxes) {\n        data[checkbox.dataset.key] = checkbox.checked;\n    }\n    document.cookie = \"MCLOGS_SETTINGS=\" + encodeURIComponent(JSON.stringify(data)) + \";path=/;expires=\" + new Date(new Date().getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toUTCString();\n}\n\n/* copy to clipboard */\nconst copyButtons = document.querySelectorAll(\"[data-clipboard]\");\ncopyButtons.forEach(button => button.addEventListener(\"click\", handleCopyButtonClick));\nconst doneClassName = \"fa-solid fa-check\";\n\nasync function handleCopyButtonClick(e) {\n    const button = e.currentTarget;\n    const data = button.dataset.clipboard;\n    await navigator.clipboard.writeText(data);\n\n    const iconElement = button.querySelector(\"i\");\n    if (!iconElement) {\n        return;\n    }\n    const originalClassName = iconElement.className;\n    if (originalClassName === doneClassName) {\n        return;\n    }\n    iconElement.className = doneClassName;\n    setTimeout(() => {\n        iconElement.className = originalClassName;\n    }, 2000);\n}\n\n/* delete button */\nconst deleteButton = document.querySelector(\".delete-log-button\");\nconst deleteErrorElement = document.querySelector(\".delete-overlay .popover-error\");\nif (deleteButton) {\n    deleteButton.addEventListener(\"click\", handleDeleteButtonClick);\n}\n\nasync function handleDeleteButtonClick() {\n    deleteErrorElement.style.display = \"none\";\n    const response = await fetch(window.location.href, {\n        method: \"DELETE\",\n        credentials: \"include\"\n    });\n    if (!response.ok) {\n        deleteErrorElement.style.display = \"block\";\n        deleteErrorElement.textContent = `${response.status} (${response.statusText})`;\n        return;\n    }\n    window.location.href = \"/\";\n}\n\n/* floating scroll bar */\nconst browser = getComputedStyle(document.body)\n    .getPropertyValue(\"--browser\")\n    .replaceAll(/['\"]/g, '')\n    .trim()\n    .toLowerCase();\nconst floatingScrollbar = document.querySelector(\".floating-scrollbar\");\nlet logContainer = null;\nif (browser === \"firefox\") {\n    logContainer = document.querySelector(\".log\");\n} else {\n    logContainer = document.querySelector(\".log-inner\");\n}\n\nif (floatingScrollbar && logContainer) {\n    updateFloatingScrollbarWidths();\n\n    floatingScrollbar.addEventListener(\"scroll\", () => {\n        syncScroll(floatingScrollbar, logContainer);\n    });\n\n    logContainer.addEventListener(\"scroll\", () => {\n        syncScroll(logContainer, floatingScrollbar);\n    });\n\n    const observer = new ResizeObserver(() => {\n        updateFloatingScrollbarWidths();\n    });\n    observer.observe(logContainer);\n}\n\nfunction syncScroll(source, target) {\n    if (Math.abs(source.scrollLeft - target.scrollLeft) > 1) {\n        target.scrollLeft = source.scrollLeft;\n    }\n}\n\nfunction initFloatingScrollbar() {\n    if (!floatingScrollbar || !logContainer) {\n        return;\n    }\n    updateFloatingScrollbarWidths();\n    syncScroll(logContainer, floatingScrollbar);\n}\n\nfunction updateFloatingScrollbarWidths() {\n    floatingScrollbar.style.setProperty(\n        \"--floating-scrollbar-width\",\n        `${logContainer.clientWidth}px`\n    );\n\n    floatingScrollbar.style.setProperty(\n        \"--floating-scrollbar-content-width\",\n        `${logContainer.scrollWidth}px`\n    );\n}\n"
  },
  {
    "path": "web/public/js/start.js",
    "content": "/* Paste area */\nconst source = document.body.dataset.name || location.host;\nconst pasteArea = document.getElementById('paste-text');\nconst pastePlaceholder = document.querySelector('.paste-placeholder');\nconst pasteSaveButtons = document.querySelectorAll('.paste-save');\nconst fileSelectButton = document.getElementById('paste-select-file');\nconst pasteClipboardButton = document.getElementById('paste-clipboard');\nconst pasteError = document.getElementById('paste-error');\n\npasteArea.focus();\npasteArea.addEventListener('input', reevaluateContentStatus);\npasteArea.addEventListener('paste', handlePasteEvent);\npasteSaveButtons.forEach(button => button.addEventListener('click', sendLog));\nfileSelectButton.addEventListener('click', selectLogFile);\npasteClipboardButton.addEventListener('click', pasteFromClipboard);\n\nreevaluateContentStatus();\n\ndocument.addEventListener('keydown', event => {\n    if (event.key.toLowerCase() === 's' && (event.ctrlKey || event.metaKey)) {\n        void sendLog();\n        event.preventDefault();\n        return false;\n    }\n\n    return true;\n});\n\n/**\n * Save the log to the API\n * @returns {Promise<void>}\n */\nasync function sendLog() {\n    if (pasteArea.value === \"\") {\n        return;\n    }\n\n    clearError();\n    pasteSaveButtons.forEach(button => button.classList.add(\"btn-working\"));\n\n    try {\n        let log = pasteArea.value;\n        log = applyFilters(log);\n\n        const bodyData = {\n            \"content\": log,\n            \"source\": source,\n            \"metadata\": Array.isArray(self.METADATA) ? self.METADATA : []\n        };\n\n        let headers = {\n            \"Content-Type\": \"application/json\"\n        }\n\n        let body = JSON.stringify(bodyData);\n        if (isGzSupported()) {\n            headers[\"Content-Encoding\"] = \"gzip\";\n            body = await packGz(body);\n        }\n\n        const response = await fetch(`/new`, {\n            method: \"POST\",\n            credentials: \"include\",\n            headers: {\n                \"Content-Type\": \"application/json\",\n                \"Content-Encoding\": \"gzip\"\n            },\n            body\n        });\n\n        if (!response.ok) {\n            showError(`${response.status} (${response.statusText})`);\n            return;\n        }\n\n        let data = null;\n        try {\n            data = await response.json();\n        } catch (e) {\n            console.error(\"Failed to parse JSON returned by API\", e);\n            showError(\"API returned invalid JSON\");\n            return;\n        }\n\n        if (typeof data === 'object' && !data.success && data.error) {\n            console.error(new Error(\"API returned an error\"), data.error);\n            showError(data.error);\n            return;\n        }\n\n        if (typeof data !== 'object' || !data.success || !data.id) {\n            console.error(new Error(\"API returned an invalid response\"), data);\n            showError(\"API returned an invalid response\");\n            return;\n        }\n\n        location.href = data.url;\n    } catch (e) {\n        showError(\"Network error\");\n    }\n}\n\n/* filters */\nfunction applyFilters(text) {\n    if (typeof FILTERS === \"undefined\" || !Array.isArray(FILTERS)) {\n        return text;\n    }\n    for (let filter of FILTERS) {\n        text = applyFilter(text, filter);\n    }\n    return text;\n}\n\nfunction applyFilter(text, filter) {\n    switch (filter.type) {\n        case 'trim':\n            return text.trim();\n        case 'limit-bytes':\n            return text.substring(0, filter.data.limit);\n        case 'limit-lines':\n            return text.split('\\n').slice(0, filter.data.limit).join('\\n');\n        case 'regex':\n            try {\n                for (const pattern of filter.data.patterns) {\n                    const regex = new RegExp(pattern.pattern, 'g' + pattern.modifiers.join());\n                    text = text.replace(regex, (match) => {\n                        for (const exemption of filter.data.exemptions) {\n                            if (new RegExp(exemption.pattern, exemption.modifiers.join()).test(match)) {\n                                return match;\n                            }\n                        }\n                        return pattern.replacement;\n                    });\n                }\n            } catch (e) {\n                console.error('Error applying regex filter', e);\n            }\n            return text;\n        default:\n            console.error('Unknown filter type', filter.type);\n            return text;\n    }\n}\n\nasync function pasteFromClipboard() {\n    try {\n        let content = await navigator.clipboard.readText();\n        if (!content || content.trim().length === 0) {\n            showError(\"Clipboard is empty.\");\n            return;\n        }\n        pasteArea.value = content;\n        reevaluateContentStatus();\n    } catch (err) {\n        showError(\"Clipboard is empty or not accessible.\");\n    }\n}\n\nfunction reevaluateContentStatus() {\n    clearError();\n    if (pasteArea.value.length > 0) {\n        pastePlaceholder.style.display = 'none';\n        pasteSaveButtons.forEach(button => button.removeAttribute(\"disabled\"));\n    } else {\n        pastePlaceholder.style.display = 'flex';\n        pasteSaveButtons.forEach(button => button.setAttribute(\"disabled\", \"disabled\"));\n    }\n}\n\nfunction showError(message) {\n    pasteSaveButtons.forEach(button => button.classList.remove(\"btn-working\"));\n    pasteError.innerText = message;\n    pasteError.style.display = 'block';\n}\n\nfunction clearError() {\n    pasteSaveButtons.forEach(button => button.classList.remove(\"btn-working\"));\n    pasteError.innerText = '';\n    pasteError.style.display = 'none';\n}\n\n/* File handling */\nasync function handlePasteEvent(e) {\n    if (e.clipboardData?.files?.length > 0) {\n        e.preventDefault();\n        await loadFileContents(e.clipboardData.files[0]);\n    }\n}\n\n/**\n * @param {Blob} file\n * @return {Promise<Uint8Array>}\n */\nfunction readFile(file) {\n    return new Promise((resolve, reject) => {\n        let reader = new FileReader();\n        // noinspection JSCheckFunctionSignatures\n        reader.onload = () => resolve(new Uint8Array(reader.result));\n        reader.onerror = e => reject(e);\n        reader.readAsArrayBuffer(file);\n    });\n}\n\nasync function loadFileContents(file) {\n    if (file.size > 1024 * 1024 * 100) {\n        showError(`File is too large.`);\n        return;\n    }\n    let content = await readFile(file);\n    if (file.name.endsWith('.gz')) {\n        if (!isGzSupported()) {\n            showError(`Gzip files are not supported in this browser.`);\n            return;\n        }\n        content = await unpackGz(content);\n    }\n\n    if (content.includes(0)) {\n        showError(`This file is not supported.`);\n        return;\n    }\n\n    pasteArea.value = new TextDecoder().decode(content);\n    reevaluateContentStatus();\n}\n\nfunction selectLogFile() {\n    let input = document.createElement('input');\n    input.type = 'file';\n    input.style.display = 'none';\n    document.body.appendChild(input);\n    input.onchange = async () => {\n        if (input.files.length) {\n            await loadFileContents(input.files[0]);\n        }\n    }\n    input.click();\n    document.body.removeChild(input);\n}\n\n/* Gzip compression */\nfunction isGzSupported() {\n    return (typeof CompressionStream !== 'undefined') && (typeof DecompressionStream !== 'undefined');\n}\n\n/**\n * @param {string} raw\n * @returns {Promise<Uint8Array>}\n */\nasync function packGz(raw) {\n    let data = new TextEncoder().encode(raw);\n    let inputStream = new ReadableStream({\n        start: (controller) => {\n            controller.enqueue(data);\n            controller.close();\n        }\n    });\n    const cs = new CompressionStream('gzip');\n    const compressedStream = inputStream.pipeThrough(cs);\n    return new Uint8Array(await new Response(compressedStream).arrayBuffer());\n}\n\n/**\n * @param {Uint8Array} data\n * @return {Promise<Uint8Array>}\n */\nasync function unpackGz(data) {\n    let inputStream = new ReadableStream({\n        start: (controller) => {\n            controller.enqueue(data);\n            controller.close();\n        }\n    });\n    const ds = new DecompressionStream('gzip');\n    const decompressedStream = inputStream.pipeThrough(ds);\n    return new Uint8Array(await new Response(decompressedStream).arrayBuffer());\n}\n\nfunction isDragEventValid(e) {\n    if (!e.dataTransfer) {\n        return false;\n    }\n    let types = Array.from(e.dataTransfer.types);\n    if (types.includes('text/uri-list')) {\n        return false;\n    }\n    return types.includes('Files') || types.includes('text/plain');\n}\n\n/* Drag and drop */\nconst dropZone = document.getElementById('dropzone');\nlet windowDragCount = 0;\nlet dropZoneDragCount = 0;\n\nwindow.addEventListener('dragover', e => e.preventDefault());\nwindow.addEventListener('dragenter', e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateWindowDragCount(1);\n    }\n});\nwindow.addEventListener('dragleave', e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateWindowDragCount(-1);\n    }\n});\nwindow.addEventListener('drop', e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateWindowDragCount(-1);\n    }\n});\n\ndropZone.addEventListener('dragenter', e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateDropZoneDragCount(1);\n    }\n});\ndropZone.addEventListener('dragleave', e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateDropZoneDragCount(-1);\n    }\n});\ndropZone.addEventListener('drop', async e => {\n    e.preventDefault();\n    if (isDragEventValid(e)) {\n        updateDropZoneDragCount(-1);\n    }\n    await handleDropEvent(e);\n});\n\nfunction updateWindowDragCount(amount) {\n    windowDragCount = Math.max(0, windowDragCount + amount);\n    if (windowDragCount > 0) {\n        dropZone.classList.add('window-dragover');\n    } else {\n        dropZone.classList.remove('window-dragover');\n    }\n}\n\nfunction updateDropZoneDragCount(amount) {\n    dropZoneDragCount = Math.max(0, dropZoneDragCount + amount);\n    if (dropZoneDragCount > 0) {\n        dropZone.classList.add('dragover');\n    } else {\n        dropZone.classList.remove('dragover');\n    }\n}\n\nasync function handleDropEvent(e) {\n    console.log(e.dataTransfer?.types);\n    let files = e.dataTransfer.files;\n    if (files.length !== 1) {\n        if (Array.from(e.dataTransfer.types).includes('text/plain')) {\n            pasteArea.value = e.dataTransfer.getData('text/plain');\n            reevaluateContentStatus();\n        }\n        return;\n    }\n\n    await loadFileContents(files[0]);\n}\n"
  },
  {
    "path": "worker.php",
    "content": "<?php\n\nuse Aternos\\Mclogs\\Api\\ApiRouter;\nuse Aternos\\Mclogs\\Config\\Config;\nuse Aternos\\Mclogs\\Config\\ConfigKey;\nuse Aternos\\Mclogs\\Frontend\\FrontendRouter;\nuse Aternos\\Mclogs\\Storage\\MongoDBClient;\nuse Aternos\\Mclogs\\Util\\URL;\n\nrequire_once __DIR__ . '/vendor/autoload.php';\n\ntry {\n    MongoDBClient::getInstance()->ensureIndexes();\n} catch (Exception $e) {\n    error_log(\"Failed to ensure MongoDB indexes: \" . $e->getMessage());\n}\n\n$requestCount = 0;\n$maxRequests = Config::getInstance()->get(ConfigKey::WORKER_REQUESTS);\n\ndo {\n    $running = \\frankenphp_handle_request(function () {\n\n        MongoDBClient::getInstance()->reset();\n        URL::clear();\n\n        if (URL::isApi()) {\n            ApiRouter::getInstance()->run();\n        } else {\n            FrontendRouter::getInstance()->run();\n        }\n    });\n\n    gc_collect_cycles();\n\n    $requestCount++;\n} while ($running && $requestCount < $maxRequests);"
  }
]