diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c516c12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +vendor/ +.git/ +.github/ +Dockerfile \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9468738 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,66 @@ +name: Publish Docker Image + +on: + push: + branches: + - 'main' + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Branch Name (e.g. 'two', 'main') + type=ref,event=branch + # Full Version (e.g. '1.2.3') + type=semver,pattern={{version}} + # Major Version (e.g. '1') + type=semver,pattern={{major}} + # Major.Minor (e.g. '1.2') + type=semver,pattern={{major}}.{{minor}} + # Latest (Only on release tags) + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: | + type=gha + type=gha,scope=refs/heads/main + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa6d5e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.log +*.cache +.idea +/vendor/ +config.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f95e63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM dunglas/frankenphp:1-php8.5 + +# System Setup +RUN install-php-extensions mongodb zip + +ARG USER=mclogs +RUN useradd ${USER} && \ + setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp + +COPY --from=composer/composer:2-bin /composer /usr/bin/composer + +WORKDIR /app + +# Dependencies (Cached) +COPY composer.json composer.lock ./ + +RUN --mount=type=cache,target=/tmp/cache/composer \ + COMPOSER_CACHE_DIR=/tmp/cache/composer \ + composer install --no-dev --no-interaction --no-scripts --no-autoloader --prefer-dist --ignore-platform-req=ext-frankenphp + +# Application Setup +COPY docker/Caddyfile /etc/frankenphp/Caddyfile +COPY docker/mclogs.ini /usr/local/etc/php/conf.d/mclogs.ini + +COPY . . + +RUN composer dump-autoload --optimize --no-dev --classmap-authoritative +RUN php build.php + +# Permissions & Runtime +RUN chown -R ${USER}:${USER} /config/caddy /data/caddy /app + +USER ${USER} + +EXPOSE 80 +EXPOSE 443 +EXPOSE 443/udp + +VOLUME ["/data"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c0a550c --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018-2024 Aternos GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.php b/build.php new file mode 100644 index 0000000..425052e --- /dev/null +++ b/build.php @@ -0,0 +1,5 @@ +writeCache(); \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f01c4a4 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "aternos/mclogs", + "description": "Paste, share and analyse Minecraft logs", + "authors": [ + { + "name": "Matthias Neid", + "email": "matthias@aternos.org" + } + ], + "require": { + "php": ">=8.5", + "ext-frankenphp": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-mongodb": "*", + "ext-uri": "*", + "ext-zlib": "*", + "aternos/codex-hytale": "^2.0", + "aternos/codex-minecraft": "^5.0.1", + "aternos/sherlock": "^1.0.2", + "mongodb/mongodb": "2.1.2" + }, + "autoload": { + "psr-4": { + "Aternos\\Mclogs\\": "src/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..a4df1b5 --- /dev/null +++ b/composer.lock @@ -0,0 +1,413 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "a570f76a4698742115ca4d1d4113a836", + "packages": [ + { + "name": "aternos/codex", + "version": "v4.1.0", + "source": { + "type": "git", + "url": "https://github.com/aternosorg/codex.git", + "reference": "2d0b1930464d9c5129e90c5e69314b1da22c7c4a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aternosorg/codex/zipball/2d0b1930464d9c5129e90c5e69314b1da22c7c4a", + "reference": "2d0b1930464d9c5129e90c5e69314b1da22c7c4a", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Aternos\\Codex\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias Neid", + "email": "matthias@aternos.org" + } + ], + "description": "PHP library to read, parse, print and analyse log files.", + "support": { + "issues": "https://github.com/aternosorg/codex/issues", + "source": "https://github.com/aternosorg/codex/tree/v4.1.0" + }, + "time": "2026-01-21T14:12:19+00:00" + }, + { + "name": "aternos/codex-hytale", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/aternosorg/codex-hytale.git", + "reference": "9b48e2d0fa4b82a3f10c8833a766b7e76e233271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aternosorg/codex-hytale/zipball/9b48e2d0fa4b82a3f10c8833a766b7e76e233271", + "reference": "9b48e2d0fa4b82a3f10c8833a766b7e76e233271", + "shasum": "" + }, + "require": { + "aternos/codex": "^v4.1.0", + "ext-json": "*", + "php": ">=8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Aternos\\Codex\\Hytale\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias", + "email": "matthias@aternos.org" + } + ], + "description": "PHP library to read, parse, print and analyse Hytale log files.", + "support": { + "issues": "https://github.com/aternosorg/codex-hytale/issues", + "source": "https://github.com/aternosorg/codex-hytale/tree/v2.0.0" + }, + "time": "2026-01-23T12:25:09+00:00" + }, + { + "name": "aternos/codex-minecraft", + "version": "v5.1.0", + "source": { + "type": "git", + "url": "https://github.com/aternosorg/codex-minecraft.git", + "reference": "f921d0277449af04a72c3dbb784e37b5fa4934b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aternosorg/codex-minecraft/zipball/f921d0277449af04a72c3dbb784e37b5fa4934b1", + "reference": "f921d0277449af04a72c3dbb784e37b5fa4934b1", + "shasum": "" + }, + "require": { + "aternos/codex": "^v4.1.0", + "ext-json": "*", + "php": ">=8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Aternos\\Codex\\Minecraft\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matthias", + "email": "matthias@aternos.org" + } + ], + "description": "PHP library to read, parse, print and analyse Minecraft log files.", + "support": { + "issues": "https://github.com/aternosorg/codex-minecraft/issues", + "source": "https://github.com/aternosorg/codex-minecraft/tree/v5.1.0" + }, + "time": "2026-03-30T18:21:47+00:00" + }, + { + "name": "aternos/sherlock", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/aternosorg/sherlock.git", + "reference": "2bfb6427790b24df860f20905b76f09978a7df3a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aternosorg/sherlock/zipball/2bfb6427790b24df860f20905b76f09978a7df3a", + "reference": "2bfb6427790b24df860f20905b76f09978a7df3a", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-simplexml": "*", + "ext-zlib": "*", + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Aternos\\Sherlock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Julian Vennen", + "email": "julian@aternos.org" + } + ], + "description": "PHP library to apply minecraft mappings to log files", + "support": { + "issues": "https://github.com/aternosorg/sherlock/issues", + "source": "https://github.com/aternosorg/sherlock/tree/v1.1.3" + }, + "time": "2026-02-09T10:37:21+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.1", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.32" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.1.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "6.5.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" + }, + "time": "2025-10-06T12:12:40+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.5", + "ext-frankenphp": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-mongodb": "*", + "ext-uri": "*", + "ext-zlib": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/dev/compose.yaml b/dev/compose.yaml new file mode 100644 index 0000000..c20e830 --- /dev/null +++ b/dev/compose.yaml @@ -0,0 +1,32 @@ +name: "mclogs" +services: + web: + build: + context: .. + dockerfile: ./Dockerfile + environment: + - MCLOGS_WORKER_REQUESTS=1 + - FRANKENPHP_WORKERS=4 + ports: + - "80:80" + volumes: + - ../:/app + - ./dev.ini:/usr/local/etc/php/conf.d/dev.ini + user: root + depends_on: + mongo: + condition: service_healthy + + mongo: + image: mongo + volumes: + - mongo:/data/db + healthcheck: + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + mongo: diff --git a/dev/dev.ini b/dev/dev.ini new file mode 100644 index 0000000..5770ec2 --- /dev/null +++ b/dev/dev.ini @@ -0,0 +1,7 @@ +opcache.enable=1 +opcache.enable_cli=1 + +opcache.validate_timestamps=1 +opcache.revalidate_freq=0 + +opcache.jit=off \ No newline at end of file diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..680d0b9 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,28 @@ +{ + servers { + trusted_proxies static {$TRUSTED_PROXIES:private_ranges} + trusted_proxies_strict + } + + frankenphp { + worker /app/worker.php { + num {$FRANKENPHP_WORKERS:16} + } + } +} + +{$SERVER_NAME::80} { + root * /app/web/public + encode zstd br gzip + + @static file + handle @static { + file_server + } + + handle { + root * /app + rewrite * /worker.php + php_server + } +} \ No newline at end of file diff --git a/docker/compose.production.yaml b/docker/compose.production.yaml new file mode 100644 index 0000000..5f15434 --- /dev/null +++ b/docker/compose.production.yaml @@ -0,0 +1,46 @@ +services: + web: + image: ghcr.io/aternosorg/mclogs:2 + restart: always + ports: + # Expose HTTP (80) and HTTPS (443) + # Port 443/udp is required for HTTP/3 (QUIC) + - "80:80" + - "443:443" + - "443:443/udp" + environment: + # Set this to your domain (e.g., mclogs.example.com) to enable Auto-SSL. + # If running behind a proxy (Cloudflare/Nginx), set to ":80" to disable Auto-SSL. + SERVER_NAME: :80 + + MCLOGS_MONGODB_URL: mongodb://mongo:27017 + MCLOGS_MONGODB_DATABASE: mclogs + + # Optional MCLOGS configuration + # See README.md for full list of available options + # MCLOGS_FRONTEND_NAME: "mclogs" + + volumes: + # For caddy cache (SSL certificates) + - web-data:/data + + depends_on: + mongo: + condition: service_healthy + + mongo: + image: mongo + restart: always + volumes: + - mongo-data:/data/db + + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + +volumes: + web-data: + mongo-data: \ No newline at end of file diff --git a/docker/mclogs.ini b/docker/mclogs.ini new file mode 100644 index 0000000..5296784 --- /dev/null +++ b/docker/mclogs.ini @@ -0,0 +1,7 @@ +post_max_size = 50M + +error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /dev/stderr \ No newline at end of file diff --git a/example.config.json b/example.config.json new file mode 100644 index 0000000..0d8add0 --- /dev/null +++ b/example.config.json @@ -0,0 +1,36 @@ +{ + "storage": { + "ttl": 7776000, + "limit": { + "bytes": 10485760, + "lines": 25000 + } + }, + "mongodb": { + "url": "mongodb://127.0.0.1:27017", + "database": "mclogs" + }, + "id": { + "length": 7 + }, + "legal": { + "abuse": "abuse@aternos.org", + "imprint": "https://aternos.gmbh/imprint/", + "privacy": "https://aternos.gmbh/en/mclogs/privacy" + }, + "frontend": { + "name": "mclo.gs", + "assets": { + "integrity": true + }, + "color": { + "background": "#1a1a1a", + "text": "#e8e8e8", + "accent": "#5cb85c", + "error": "#f62451" + } + }, + "worker": { + "requests": 500 + } +} \ No newline at end of file diff --git a/src/Api/Action/AnalyseLogAction.php b/src/Api/Action/AnalyseLogAction.php new file mode 100644 index 0000000..f5a22bc --- /dev/null +++ b/src/Api/Action/AnalyseLogAction.php @@ -0,0 +1,26 @@ +getContent(); + + if ($data instanceof ApiError) { + return $data; + } + + $content = $data['content']; + $log = new Log()->setContent($content); + + return new CodexLogResponse($log->getCodexLog()); + } +} diff --git a/src/Api/Action/ApiAction.php b/src/Api/Action/ApiAction.php new file mode 100644 index 0000000..9da24fb --- /dev/null +++ b/src/Api/Action/ApiAction.php @@ -0,0 +1,37 @@ +getAllowedOrigin()); + header('Access-Control-Allow-Headers: *'); + if ($this->shouldAllowCredentials()) { + header('Access-Control-Allow-Credentials: true'); + } + header("Accept-Encoding: " . implode(",", ContentParser::getSupportedEncodings())); + + $response = $this->runApi(); + $response->output(); + + return true; + } +} \ No newline at end of file diff --git a/src/Api/Action/BulkDeleteLogsAction.php b/src/Api/Action/BulkDeleteLogsAction.php new file mode 100644 index 0000000..725c41c --- /dev/null +++ b/src/Api/Action/BulkDeleteLogsAction.php @@ -0,0 +1,77 @@ +getContent(); + if ($data instanceof ApiError) { + return $data; + } + + if (count($data) === 0) { + return new ApiError(400, "No logs provided."); + } + if (count($data) > static::MAX_IDS) { + return new ApiError(400, "Too many logs. Maximum is " . static::MAX_IDS . "."); + } + + $ids = []; + foreach ($data as $log) { + if (!is_array($log)) { + return new ApiError(400, "Each entry must be an object with 'id' and 'token' fields."); + } + if (!isset($log["id"]) || !is_string($log["id"]) || + !preg_match("/^" . Id::PATTERN . "$/", $log["id"])) { + return new ApiError(400, "Each log must have a valid 'id' field."); + } + if (!isset($log["token"]) || !is_string($log["token"])) { + return new ApiError(400, "Each log must have a valid 'token' field."); + } + $ids[] = $log["id"]; + } + + $logs = Log::findAll($ids, false); + + $deleteIds = []; + $response = new MultiResponse(); + foreach ($data as $log) { + $id = $log["id"]; + $token = $log["token"]; + + $log = $logs[$id] ?? null; + if (!$log) { + $response->addResponse($id, new ApiError(404, "Log not found.")); + continue; + } + + $logToken = $log->getToken(); + if (!$logToken || !$logToken->matches($token)) { + $response->addResponse($id, new ApiError(403, "Invalid token.")); + continue; + } + + $deleteIds[] = $id; + $response->addResponse($id, new ApiResponse()); + } + + MongoDBClient::getInstance()->deleteLogs($deleteIds); + + return $response; + } +} diff --git a/src/Api/Action/CreateLogAction.php b/src/Api/Action/CreateLogAction.php new file mode 100644 index 0000000..6200f33 --- /dev/null +++ b/src/Api/Action/CreateLogAction.php @@ -0,0 +1,43 @@ +getContent(); + + if ($data instanceof ApiError) { + return $data; + } + + $content = $data['content']; + $metadata = []; + if (isset($data['metadata']) && is_array($data['metadata'])) { + $metadata = MetadataEntry::allFromArray($data['metadata']); + } + $source = null; + if (isset($data['source']) && is_string($data['source'])) { + $source = $data['source']; + } + + $log = Log::create($content, $metadata, $source); + + if ($this->includeCookie) { + $log->setTokenCookie(); + } + + return new LogResponse($log, $this->includeToken); + } +} diff --git a/src/Api/Action/DeleteLogAction.php b/src/Api/Action/DeleteLogAction.php new file mode 100644 index 0000000..dc0a6ad --- /dev/null +++ b/src/Api/Action/DeleteLogAction.php @@ -0,0 +1,60 @@ +getRequestToken(); + + if (!$requestToken) { + return new ApiError(400, "Missing token."); + } + + $id = new Id(URL::getLastPathPart()); + $log = Log::find($id); + + if (!$log) { + return new ApiError(404, "Log not found."); + } + + $token = $log->getToken(); + if (!$token || !$token->matches($requestToken)) { + return new ApiError(403, "Invalid token."); + } + + $deleted = $log->delete(); + if (!$deleted) { + return new ApiError(500, "Failed to delete log."); + } + + $this->handleDeletedLog($log); + + return new ApiResponse(); + } + + protected function handleDeletedLog(Log $log): void + { + + } +} diff --git a/src/Api/Action/EmptyAction.php b/src/Api/Action/EmptyAction.php new file mode 100644 index 0000000..412689f --- /dev/null +++ b/src/Api/Action/EmptyAction.php @@ -0,0 +1,13 @@ +getCodexLog(); + $codexLog->setIncludeEntries(false); + + return new CodexLogResponse($codexLog); + } +} \ No newline at end of file diff --git a/src/Api/Action/RateLimitErrorAction.php b/src/Api/Action/RateLimitErrorAction.php new file mode 100644 index 0000000..1adac3c --- /dev/null +++ b/src/Api/Action/RateLimitErrorAction.php @@ -0,0 +1,17 @@ +register(Method::GET, "#^/$#", new Frontend\Action\ApiDocsAction()) + ->register(Method::OPTIONS, "#^/.*$#", new Action\EmptyAction()) + ->register(Method::POST, "#^/1/log/?$#", new Action\CreateLogAction()) + ->register(Method::GET, "#^/1/log/" . Id::PATTERN . "$#", new Action\LogInfoAction()) + ->register(Method::DELETE, "#^/1/log/" . Id::PATTERN . "$#", new Action\DeleteLogAction()) + ->register(Method::POST, "#^/1/bulk/log/delete/?$#", new Action\BulkDeleteLogsAction()) + ->register(Method::GET, "#^/1/insights/" . Id::PATTERN . "$#", new Action\LogInsightsAction()) + ->register(Method::GET, "#^/1/raw/" . Id::PATTERN . "$#", new Action\RawLogAction()) + ->register(Method::POST, "#^/1/analyse/?$#", new Action\AnalyseLogAction()) + ->register(Method::GET, "#^/1/errors/rate$#", new Action\RateLimitErrorAction()) + ->register(Method::GET, "#^/1/limits$#", new Action\GetLimitsAction()) + ->register(Method::GET, "#^/1/filters#", new Action\GetFiltersAction()) + ->setDefaultAction(new Action\EndpointNotFoundAction()); + } +} diff --git a/src/Api/ContentParser.php b/src/Api/ContentParser.php new file mode 100644 index 0000000..cb3c600 --- /dev/null +++ b/src/Api/ContentParser.php @@ -0,0 +1,85 @@ +get(ConfigKey::STORAGE_LIMIT_BYTES) * 2; + $body = file_get_contents('php://input', length: $limit + 1); + if ($body === false) { + return new ApiError(500, "Failed to read request body."); + } + if (strlen($body) > $limit) { + return new ApiError(413, "Request body exceeds maximum allowed size."); + } + + $encodingHeader = $_SERVER['HTTP_CONTENT_ENCODING'] ?? ''; + if ($encodingHeader) { + $encodingSteps = explode(',', $encodingHeader); + if (count($encodingSteps) > static::MAX_ENCODING_STEPS) { + return new ApiError(400, "Too many Content-Encoding steps."); + } + foreach (array_reverse($encodingSteps) as $step) { + switch (trim(strtolower($step))) { + case "deflate": + $body = @gzinflate($body, $limit); + break; + case "x-gzip": + case "gzip": + $body = @gzdecode($body, $limit); + break; + default: + return new ApiError(415, "Unsupported Content-Encoding: " . htmlspecialchars($step)); + } + if ($body === false) { + return new ApiError(400, "Failed to decode request body with encoding: " . htmlspecialchars($step)); + } + } + } + + $contentTypeHeader = $_SERVER['CONTENT_TYPE'] ?? ''; + if ($pos = strpos($contentTypeHeader, ';')) { + $contentTypeHeader = substr($contentTypeHeader, 0, $pos); + } + switch ($contentTypeHeader) { + case "application/x-www-form-urlencoded": + parse_str($body, $data); + break; + case "application/json": + $data = @json_decode($body, true); + if (!is_array($data)) { + return new ApiError(400, "Failed to parse JSON body."); + } + break; + default: + return new ApiError(415, "Unsupported Content-Type: " . htmlspecialchars($contentTypeHeader)); + } + + return $data; + } +} diff --git a/src/Api/LogContentParser.php b/src/Api/LogContentParser.php new file mode 100644 index 0000000..c07a867 --- /dev/null +++ b/src/Api/LogContentParser.php @@ -0,0 +1,33 @@ +setHttpCode($httpCode); + } + + public function jsonSerialize(): array + { + $data = parent::jsonSerialize(); + $data['error'] = $this->message; + return $data; + } +} diff --git a/src/Api/Response/ApiResponse.php b/src/Api/Response/ApiResponse.php new file mode 100644 index 0000000..95ca74e --- /dev/null +++ b/src/Api/Response/ApiResponse.php @@ -0,0 +1,63 @@ + $this->success, + ]; + } + + /** + * @param int $httpCode + * @return $this + */ + public function setHttpCode(int $httpCode): static + { + $this->httpCode = $httpCode; + return $this; + } + + /** + * @return int + */ + public function getHttpCode(): int + { + return $this->httpCode; + } + + /** + * @param bool $success + * @return $this + */ + public function setSuccess(bool $success): static + { + $this->success = $success; + return $this; + } + + /** + * @return bool + */ + public function isSuccess(): bool + { + return $this->success; + } + + /** + * @return $this + */ + public function output(): static + { + header('Content-Type: application/json'); + http_response_code($this->httpCode); + echo json_encode($this, JSON_UNESCAPED_SLASHES); + return $this; + } +} diff --git a/src/Api/Response/CodexLogResponse.php b/src/Api/Response/CodexLogResponse.php new file mode 100644 index 0000000..f7835dd --- /dev/null +++ b/src/Api/Response/CodexLogResponse.php @@ -0,0 +1,17 @@ +codexLog->jsonSerialize()); + } +} \ No newline at end of file diff --git a/src/Api/Response/FiltersResponse.php b/src/Api/Response/FiltersResponse.php new file mode 100644 index 0000000..a2d137e --- /dev/null +++ b/src/Api/Response/FiltersResponse.php @@ -0,0 +1,13 @@ +get(ConfigKey::STORAGE_TTL); + $data['maxLength'] = $config->get(ConfigKey::STORAGE_LIMIT_BYTES); + $data['maxLines'] = $config->get(ConfigKey::STORAGE_LIMIT_LINES); + return $data; + } +} \ No newline at end of file diff --git a/src/Api/Response/LogResponse.php b/src/Api/Response/LogResponse.php new file mode 100644 index 0000000..2a48d85 --- /dev/null +++ b/src/Api/Response/LogResponse.php @@ -0,0 +1,64 @@ +loadFromGet(); + } + + public function loadFromGet(): static + { + $url = URL::getCurrent(); + $query = $url->getQuery(); + if (empty($query)) { + return $this; + } + parse_str($url->getQuery(), $get); + $this->withInsights = isset($get['insights']); + $this->withRaw = isset($get['raw']); + $this->withParsed = isset($get['parsed']); + return $this; + } + + public function jsonSerialize(): array + { + $data = parent::jsonSerialize(); + $data['id'] = $this->log->getId(); + $data['source'] = $this->log->getSource(); + $data['created'] = $this->log->getCreated()?->toDateTime()->getTimestamp(); + $data['expires'] = $this->log->getExpires()?->toDateTime()->getTimestamp(); + $data['size'] = $this->log->getSize(); + $data['lines'] = $this->log->getLinesCount(); + $data['errors'] = $this->log->getErrorsCount(); + $data['url'] = $this->log->getUrl()->toString(); + $data['raw'] = $this->log->getRawURL()->toString(); + if ($this->withToken) { + $data['token'] = $this->log->getToken(); + } + $data['metadata'] = $this->log->getMetadata(); + if ($this->withInsights || $this->withRaw || $this->withParsed) { + $data['content'] = []; + } + if ($this->withInsights) { + $data['content']['insights'] = $this->log->getCodexLog()->setIncludeEntries(false); + } + if ($this->withRaw) { + $data['content']['raw'] = $this->log->getContent(); + } + if ($this->withParsed) { + $data['content']['parsed'] = $this->log->getCodexLog()->getEntries(); + } + return $data; + } +} \ No newline at end of file diff --git a/src/Api/Response/MultiResponse.php b/src/Api/Response/MultiResponse.php new file mode 100644 index 0000000..cfb1ba4 --- /dev/null +++ b/src/Api/Response/MultiResponse.php @@ -0,0 +1,38 @@ +responses[$id] = $response; + return $this; + } + + public function jsonSerialize(): array + { + $response = parent::jsonSerialize(); + $results = []; + foreach ($this->responses as $id => $apiResponse) { + $result = $apiResponse->jsonSerialize(); + $result["id"] = $id; + $result["status"] = $apiResponse->getHttpCode(); + $results[] = $result; + } + $response["results"] = $results; + return $response; + } +} diff --git a/src/Api/Response/RawLogResponse.php b/src/Api/Response/RawLogResponse.php new file mode 100644 index 0000000..9e77a57 --- /dev/null +++ b/src/Api/Response/RawLogResponse.php @@ -0,0 +1,22 @@ +log->getContent(); + + return $this; + } + +} \ No newline at end of file diff --git a/src/Cache/CacheEntry.php b/src/Cache/CacheEntry.php new file mode 100644 index 0000000..685256b --- /dev/null +++ b/src/Cache/CacheEntry.php @@ -0,0 +1,43 @@ +getCacheCollection()->findOne([ + "_id" => $this->key + ]); + return $result?->data; + } + + /** + * @param string $data + * @param int $ttl + * @return $this + */ + public function set(string $data, int $ttl = 24 * 60 * 60): static + { + MongoDBClient::getInstance()->getCacheCollection()->updateOne( + ["_id" => $this->key], + ['$set' => [ + 'data' => $data, + 'expires' => new UTCDateTime((time() + $ttl) * 1000) + ]], + ['upsert' => true] + ); + return $this; + } + +} \ No newline at end of file diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..ca5a155 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,88 @@ +jsonData = $data; + } + } + } + + /** + * Get config value by checking environment variable, then config file, then default value + * + * @param ConfigKey $key + * @return mixed + */ + public function get(ConfigKey $key): mixed + { + $env = getenv($key->getEnvironmentVariable()); + if ($env !== false) { + if ($env === "true") { + return true; + } else if ($env === "false") { + return false; + } + return $env; + } + + $json = $this->getJsonValue($key->getJSONPath()); + if ($json !== null) { + return $json; + } + + return $key->getDefaultValue(); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->get(ConfigKey::FRONTEND_NAME) ?? URL::getBase()->getHost(); + } + + /** + * Recursively get a value from the json data by path + * + * @param array $path + * @param array|null $data + * @return mixed + */ + protected function getJsonValue(array $path, ?array $data = null): mixed + { + if ($data === null) { + $data = $this->jsonData; + } + + $nextKey = array_shift($path); + + if (!isset($data[$nextKey])) { + return null; + } + + $nextData = $data[$nextKey]; + if (count($path) === 0) { + return $nextData; + } + if (!is_array($nextData)) { + return null; + } + return $this->getJsonValue($path, $nextData); + } +} \ No newline at end of file diff --git a/src/Config/ConfigKey.php b/src/Config/ConfigKey.php new file mode 100644 index 0000000..260fc83 --- /dev/null +++ b/src/Config/ConfigKey.php @@ -0,0 +1,80 @@ + 90 * 24 * 60 * 60, + ConfigKey::STORAGE_LIMIT_BYTES => 10 * 1024 * 1024, + ConfigKey::STORAGE_LIMIT_LINES => 25000, + + ConfigKey::MONGODB_URL => 'mongodb://mongo:27017', + ConfigKey::MONGODB_DATABASE => 'mclogs', + + ConfigKey::ID_LENGTH => 7, + + ConfigKey::FRONTEND_ANALYTICS => false, + + ConfigKey::FRONTEND_ASSETS_INTEGRITY => true, + + ConfigKey::FRONTEND_COLOR_BACKGROUND => "#1a1a1a", + ConfigKey::FRONTEND_COLOR_TEXT => "#e8e8e8", + ConfigKey::FRONTEND_COLOR_ACCENT => "#5cb85c", + ConfigKey::FRONTEND_COLOR_ERROR => "#f62451", + + ConfigKey::WORKER_REQUESTS => 500, + + default => null + }; + } + + /** + * Get environment variable name + * + * @return string + */ + public function getEnvironmentVariable(): string + { + return "MCLOGS_" . $this->name; + } + + /** + * @return array + */ + public function getJSONPath(): array + { + $parts = explode("_", $this->name); + return array_map(fn($part) => strtolower($part), $parts); + } +} diff --git a/src/Data/Deobfuscator.php b/src/Data/Deobfuscator.php new file mode 100644 index 0000000..3952a82 --- /dev/null +++ b/src/Data/Deobfuscator.php @@ -0,0 +1,138 @@ +codexLog instanceof AnalysableLog) { + return null; + } + if (!$this->codexLog instanceof VanillaLog) { + return null; + } + $analysis = $this->codexLog->analyse(); + + /** + * @var ?Information $version + */ + $version = $analysis->getFilteredInsights(VanillaVersionInformation::class)[0] ?? null; + if (!$version) { + return null; + } + $version = $version->getValue(); + + try { + $map = $this->getObfuscationMap($version); + } catch (Exception) { + $map = null; + } + + if ($map === null) { + return null; + } + + $obfuscatedContent = new ObfuscatedString($this->codexLog->getLogFile()->getContent(), $map); + if ($content = $obfuscatedContent->getMappedContent()) { + return $content; + } + return null; + } + + /** + * Get the obfuscation map matching this log + * + * @param $version + * @return ObfuscationMap|null + * @throws Exception + */ + protected function getObfuscationMap($version): ?ObfuscationMap + { + if (in_array(get_class($this->codexLog), [ + VanillaServerLog::class, + VanillaClientLog::class, + VanillaCrashReportLog::class, + VanillaNetworkProtocolErrorReportLog::class + ])) { + $urlCache = new CacheEntry("sherlock:vanilla:$version:client"); + + $mapURL = $urlCache->get(); + if (!$mapURL) { + $mapURL = new LauncherMetaMapLocator($version, "client")->findMappingURL(); + + if (!$mapURL) { + return null; + } + + $urlCache->set($mapURL, 30 * 24 * 60 * 60); + } + + try { + $mapCache = new CacheEntry("sherlock:$mapURL"); + if ($mapContent = $mapCache->get()) { + $map = new VanillaObfuscationMap($mapContent); + } else { + $map = new URLVanillaObfuscationMap($mapURL); + $mapCache->set($map->getContent()); + } + } catch (Exception) { + } + return $map ?? null; + } + + if ($this->codexLog instanceof FabricLog) { + $urlCache = new CacheEntry("sherlock:yarn:$version:server"); + + $mapURL = $urlCache->get(); + if (!$mapURL) { + $mapURL = new FabricMavenMapLocator($version)->findMappingURL(); + + if (!$mapURL) { + return null; + } + + $urlCache->set($mapURL, 24 * 60 * 60); + } + + try { + $mapCache = new CacheEntry("sherlock:$mapURL"); + if ($mapContent = $mapCache->get()) { + $map = new YarnMap($mapContent); + } else { + $map = new GZURLYarnMap($mapURL); + $mapCache->set($map->getContent()); + } + } catch (Exception) { + } + return $map ?? null; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Data/MetadataEntry.php b/src/Data/MetadataEntry.php new file mode 100644 index 0000000..04667c3 --- /dev/null +++ b/src/Data/MetadataEntry.php @@ -0,0 +1,198 @@ += static::MAX_ENTRIES) { + break; + } + } + return $entries; + } + + /** + * @param array $data + * @return MetadataEntry|null + */ + public static function fromArray(array $data): ?MetadataEntry + { + $entry = new MetadataEntry()->setFromArray($data); + if (!$entry->isValid()) { + return null; + } + return $entry; + } + + /** + * @param object $data + * @return MetadataEntry|null + */ + public static function fromObject(object $data): ?MetadataEntry + { + if ($data instanceof BSONDocument) { + $arrayData = $data->getArrayCopy(); + } else { + $arrayData = get_object_vars($data); + } + return static::fromArray($arrayData); + } + + public function jsonSerialize(): array + { + return [ + "key" => $this->key, + "value" => $this->value, + "label" => $this->label, + "visible" => $this->visible, + ]; + } + + public function bsonSerialize(): array + { + return $this->jsonSerialize(); + } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(?string $key): static + { + if (is_string($key) && strlen($key) > static::MAX_KEY_LENGTH) { + $key = substr($key, 0, static::MAX_KEY_LENGTH); + } + $this->key = $key; + return $this; + } + + public function getValue(): mixed + { + return $this->value; + } + + /** + * @param mixed $value + * @return $this + */ + public function setValue(mixed $value): static + { + if (is_string($value)) { + if (strlen($value) > static::MAX_VALUE_LENGTH) { + $value = substr($value, 0, static::MAX_VALUE_LENGTH); + } + $this->value = $value; + return $this; + } + if (is_int($value) || is_float($value) || is_bool($value) || is_null($value)) { + $this->value = $value; + return $this; + } + $encodedValue = @json_encode($value); + if ($encodedValue === false) { + $this->value = null; + return $this; + } + if (strlen($encodedValue) > static::MAX_VALUE_LENGTH) { + $encodedValue = substr($encodedValue, 0, static::MAX_VALUE_LENGTH); + } + $this->value = $encodedValue; + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function getDisplayLabel(): ?string + { + return $this->label ?? $this->key; + } + + public function getDisplayValue(): string + { + return $this->value; + } + + public function setLabel(?string $label): static + { + if (is_string($label) && strlen($label) > static::MAX_LABEL_LENGTH) { + $label = substr($label, 0, static::MAX_LABEL_LENGTH); + } + $this->label = $label; + return $this; + } + + public function isVisible(): bool + { + return $this->visible; + } + + public function setVisible(bool $visible): static + { + $this->visible = $visible; + return $this; + } + + public function isValid(): bool + { + return $this->key !== null && $this->value !== null; + } + + /** + * @param array $data + * @return $this + */ + public function setFromArray(array $data): static + { + if (isset($data['key']) && is_string($data['key'])) { + $this->setKey($data['key']); + } + if (isset($data['value'])) { + $this->setValue($data['value']); + } + if (isset($data['label']) && is_string($data['label'])) { + $this->setLabel($data['label']); + } + if (isset($data['visible']) && is_bool($data['visible'])) { + $this->setVisible($data['visible']); + } + return $this; + } +} diff --git a/src/Data/Token.php b/src/Data/Token.php new file mode 100644 index 0000000..920e96f --- /dev/null +++ b/src/Data/Token.php @@ -0,0 +1,42 @@ +value === null) { + $this->generate(); + } + } + + /** + * @param string $token + * @return bool + */ + public function matches(string $token): bool + { + return hash_equals($this->value, $token); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + /** + * @throws RandomException + */ + protected function generate(): void + { + $this->value = bin2hex(random_bytes(32)); + } + + public function get(): ?string + { + return $this->value; + } +} \ No newline at end of file diff --git a/src/Detective.php b/src/Detective.php new file mode 100644 index 0000000..ab6efdc --- /dev/null +++ b/src/Detective.php @@ -0,0 +1,16 @@ +addDetective(new \Aternos\Codex\Minecraft\Detective\Detective()) + ->addDetective(new \Aternos\Codex\Hytale\Detective\Detective()); + } +} \ No newline at end of file diff --git a/src/Filter/AccessTokenFilter.php b/src/Filter/AccessTokenFilter.php new file mode 100644 index 0000000..1481b77 --- /dev/null +++ b/src/Filter/AccessTokenFilter.php @@ -0,0 +1,21 @@ +filter($data); + } + return $data; + } + + /** + * @return FilterType + */ + abstract public function getType(): FilterType; + + /** + * @return array + */ + abstract public function getData(): mixed; + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + "type" => $this->getType()->value, + "data" => $this->getData(), + ]; + } + + /** + * Filter the $data string and return it + * + * @param string $data + * @return string + */ + abstract public function filter(string $data): string; +} \ No newline at end of file diff --git a/src/Filter/FilterType.php b/src/Filter/FilterType.php new file mode 100644 index 0000000..e1d30e1 --- /dev/null +++ b/src/Filter/FilterType.php @@ -0,0 +1,11 @@ +get(ConfigKey::STORAGE_LIMIT_BYTES); + return mb_strcut($data, 0, $lengthLimit); + } + + /** + * @return FilterType + */ + public function getType(): FilterType + { + return FilterType::LIMIT_BYTES; + } + + /** + * @return array + */ + public function getData(): array + { + return [ + "limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES) + ]; + } +} \ No newline at end of file diff --git a/src/Filter/LimitLinesFilter.php b/src/Filter/LimitLinesFilter.php new file mode 100644 index 0000000..e6db1cc --- /dev/null +++ b/src/Filter/LimitLinesFilter.php @@ -0,0 +1,41 @@ +get(ConfigKey::STORAGE_LIMIT_LINES); + return implode("\n", array_slice(explode("\n", $data), 0, $linesLimit)); + } + + /** + * @return FilterType + */ + public function getType(): FilterType + { + return FilterType::LIMIT_LINES; + } + + /** + * @return array + */ + public function getData(): array + { + return [ + "limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES) + ]; + } +} \ No newline at end of file diff --git a/src/Filter/Pattern/Modifier.php b/src/Filter/Pattern/Modifier.php new file mode 100644 index 0000000..cd4c7af --- /dev/null +++ b/src/Filter/Pattern/Modifier.php @@ -0,0 +1,16 @@ +value; + } +} \ No newline at end of file diff --git a/src/Filter/Pattern/Pattern.php b/src/Filter/Pattern/Pattern.php new file mode 100644 index 0000000..a1508c9 --- /dev/null +++ b/src/Filter/Pattern/Pattern.php @@ -0,0 +1,51 @@ +modifiers as $modifier) { + $modifiersString .= $modifier->value; + } + return static::DELIMITER . $this->pattern . static::DELIMITER . $modifiersString; + } + + public function getPattern(): string + { + return $this->pattern; + } + + public function getModifiers(): array + { + return $this->modifiers; + } + + public function jsonSerialize(): array + { + return [ + 'pattern' => $this->getPattern(), + 'modifiers' => $this->getModifiers() + ]; + } +} \ No newline at end of file diff --git a/src/Filter/Pattern/PatternWithReplacement.php b/src/Filter/Pattern/PatternWithReplacement.php new file mode 100644 index 0000000..885fc13 --- /dev/null +++ b/src/Filter/Pattern/PatternWithReplacement.php @@ -0,0 +1,26 @@ +replacement; + } + + public function jsonSerialize(): array + { + return array_merge( + parent::jsonSerialize(), + [ + 'replacement' => $this->getReplacement() + ] + ); + } +} \ No newline at end of file diff --git a/src/Filter/RegexFilter.php b/src/Filter/RegexFilter.php new file mode 100644 index 0000000..b92dc8c --- /dev/null +++ b/src/Filter/RegexFilter.php @@ -0,0 +1,59 @@ + $this->getPatterns(), + "exemptions" => $this->getExemptions(), + ]; + } + + /** + * @inheritDoc + */ + public function filter(string $data): string + { + foreach ($this->getPatterns() as $pattern) { + $data = preg_replace_callback($pattern->get(), function ($matches) use ($pattern) { + foreach ($this->getExemptions() as $exemptionPattern) { + if (preg_match($exemptionPattern->get(), $matches[0])) { + return $matches[0]; + } + } + return $pattern->getReplacement(); + }, $data); + } + return $data; + } +} \ No newline at end of file diff --git a/src/Filter/TrimFilter.php b/src/Filter/TrimFilter.php new file mode 100644 index 0000000..235129c --- /dev/null +++ b/src/Filter/TrimFilter.php @@ -0,0 +1,29 @@ +toString(); + } + + protected function shouldAllowCredentials(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Frontend/Action/DeleteLogAction.php b/src/Frontend/Action/DeleteLogAction.php new file mode 100644 index 0000000..11be73d --- /dev/null +++ b/src/Frontend/Action/DeleteLogAction.php @@ -0,0 +1,30 @@ +toString(); + } + + protected function shouldAllowCredentials(): bool + { + return true; + } + + protected function getRequestToken(): ?string + { + return new TokenCookie()->getValue(); + } + + protected function handleDeletedLog(Log $log): void + { + new TokenCookie()->setLog($log)->delete(); + } +} \ No newline at end of file diff --git a/src/Frontend/Action/FaviconAction.php b/src/Frontend/Action/FaviconAction.php new file mode 100644 index 0000000..1c20e9a --- /dev/null +++ b/src/Frontend/Action/FaviconAction.php @@ -0,0 +1,15 @@ +renew(); + + require __DIR__ . "/../../../web/frontend/log.php"; + return true; + } +} \ No newline at end of file diff --git a/src/Frontend/Assets/Asset.php b/src/Frontend/Assets/Asset.php new file mode 100644 index 0000000..7bac960 --- /dev/null +++ b/src/Frontend/Assets/Asset.php @@ -0,0 +1,105 @@ +type) || !isset($data->path) || !isset($data->hash)) { + return null; + } + + $type = AssetType::tryFrom($data->type); + if ($type === null) { + return null; + } + + return new static($type, $data->path, $data->hash); + } + + public function __construct( + protected AssetType $type, + protected string $path, + protected ?string $hash = null) + { + $this->path = ltrim($this->path, '/'); + } + + public function getPath(): string + { + return $this->path; + } + + public function getPathWithVersion(): string + { + return $this->path . '?v=' . rawurlencode(substr($this->getHash(), 0, 16)); + } + + protected function getAbsoluteBasePath(): string + { + return __DIR__ . "/../../../web/public/"; + } + + protected function getAbsolutePath(): string + { + return $this->getAbsoluteBasePath() . $this->path; + } + + protected function buildHash(): string + { + return hash_file(static::HASH_ALGORITHM, $this->getAbsolutePath()); + } + + protected function getHash(): string + { + if ($this->hash === null) { + return $this->buildHash(); + } + return $this->hash; + } + + protected function getBase64Hash(): string + { + return base64_encode(hex2bin($this->getHash())); + } + + public function jsonSerialize(): array + { + return [ + 'type' => $this->getType()->value, + 'path' => $this->getPath(), + 'hash' => $this->getHash(), + ]; + } + + public function getType(): AssetType + { + return $this->type; + } + + public function getHTML(): string + { + return match ($this->type) { + AssetType::CSS => 'getIntegrityAttribute() . ' />', + AssetType::JS => '' + }; + } + + protected function getIntegrityAttribute(): string + { + if (!Config::getInstance()->get(ConfigKey::FRONTEND_ASSETS_INTEGRITY)) { + return ''; + } + return ' integrity="' . static::HASH_ALGORITHM . '-' . $this->getBase64Hash() . '"'; + } +} \ No newline at end of file diff --git a/src/Frontend/Assets/AssetLoader.php b/src/Frontend/Assets/AssetLoader.php new file mode 100644 index 0000000..ea8ddad --- /dev/null +++ b/src/Frontend/Assets/AssetLoader.php @@ -0,0 +1,101 @@ +loadCache(); + } + + /** + * @param AssetType $assetType + * @param string $path + * @return string + */ + public function getHTML(AssetType $assetType, string $path): string + { + return $this->getAsset($assetType, $path)->getHTML(); + } + + /** + * @param AssetType $assetType + * @param string $path + * @return Asset + */ + protected function getAsset(AssetType $assetType, string $path): Asset + { + $cachedAsset = $this->findCachedAsset($assetType, $path); + if ($cachedAsset !== null) { + return $cachedAsset; + } + return new Asset($assetType, $path); + } + + /** + * @param AssetType $assetType + * @param string $path + * @return Asset|null + */ + protected function findCachedAsset(AssetType $assetType, string $path): ?Asset + { + foreach ($this->cachedAssets as $asset) { + if ($asset->getPath() === $path && $asset->getType() === $assetType) { + return $asset; + } + } + return null; + } + + protected function loadCache(): void + { + if (!file_exists(self::CACHE_PATH)) { + return; + } + + $content = file_get_contents(self::CACHE_PATH); + if ($content === false) { + return; + } + + $data = json_decode($content); + if (!is_array($data)) { + return; + } + + foreach ($data as $assetData) { + if (!is_object($assetData)) { + continue; + } + $asset = Asset::fromObject($assetData); + if ($asset === null) { + continue; + } + $this->cachedAssets[] = $asset; + } + } + + public function writeCache(): void + { + $assets = [ + new Asset(AssetType::CSS, "css/mclogs.css"), + new Asset(AssetType::JS, "js/start.js"), + new Asset(AssetType::JS, "js/log.js"), + new Asset(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css") + ]; + + file_put_contents(static::CACHE_PATH, json_encode($assets)); + } +} \ No newline at end of file diff --git a/src/Frontend/Assets/AssetType.php b/src/Frontend/Assets/AssetType.php new file mode 100644 index 0000000..0493e28 --- /dev/null +++ b/src/Frontend/Assets/AssetType.php @@ -0,0 +1,9 @@ +getScheme() === "https"; + } + + /** + * @return bool + */ + protected function isHttpOnly(): bool + { + return true; + } + + /** + * @return string + */ + protected function getSameSite(): string + { + return "Lax"; + } + + public function __construct() + { + $this->value = $_COOKIE[$this->getKey()] ?? null; + } + + /** + * @param string $value + * @return bool + */ + public function set(string $value): bool + { + $options = [ + 'expires' => $this->getMaxAge() !== null ? time() + $this->getMaxAge() : 0, + 'path' => $this->getPath(), + 'domain' => $this->getDomain(), + 'secure' => $this->isSecure(), + 'httponly' => $this->isHttpOnly(), + 'samesite' => $this->getSameSite() + ]; + + $result = setcookie( + $this->getKey(), + $value, + $options + ); + + if ($result) { + $this->value = $value; + } + + return $result; + } + + /** + * @return bool + */ + public function delete(): bool + { + $options = [ + 'expires' => time() - 3600, + 'path' => $this->getPath(), + 'domain' => $this->getDomain(), + 'secure' => $this->isSecure(), + 'httponly' => $this->isHttpOnly(), + 'samesite' => $this->getSameSite() + ]; + + $result = setcookie( + $this->getKey(), + '', + $options + ); + + if ($result) { + $this->value = null; + } + + return $result; + } + + /** + * @return string|null + */ + public function getValue(): ?string + { + return $this->value; + } + + /** + * @return bool + */ + public function exists(): bool + { + return $this->getValue() !== null; + } +} \ No newline at end of file diff --git a/src/Frontend/Cookie/SettingsCookie.php b/src/Frontend/Cookie/SettingsCookie.php new file mode 100644 index 0000000..0e0522f --- /dev/null +++ b/src/Frontend/Cookie/SettingsCookie.php @@ -0,0 +1,14 @@ +log = $log; + return $this; + } + + /** + * @inheritDoc + */ + protected function getKey(): string + { + return "MCLOGS_LOG_TOKEN"; + } + + /** + * @param Log|null $log + */ + public function __construct(protected ?Log $log = null) + { + parent::__construct(); + } + + /** + * @return string + */ + protected function getPath(): string + { + if (!$this->log) { + return "/"; + } + return "/" . $this->log->getId()->get(); + } + + protected function getMaxAge(): ?int + { + return Config::getInstance()->get(ConfigKey::STORAGE_TTL); + } +} \ No newline at end of file diff --git a/src/Frontend/FrontendRouter.php b/src/Frontend/FrontendRouter.php new file mode 100644 index 0000000..42bc91f --- /dev/null +++ b/src/Frontend/FrontendRouter.php @@ -0,0 +1,21 @@ +register(Method::GET, "#^/$#", new Action\StartAction()) + ->register(Method::GET, "#^/" . Id::PATTERN . "$#", new Action\ViewLogAction()) + ->register(Method::POST, "#^/new$#", new Action\CreateLogAction()) + ->register(Method::DELETE, "#^/" . Id::PATTERN . "$#", new Action\DeleteLogAction()) + ->register(Method::GET, "#^/favicon\.svg$#", new Action\FaviconAction()) + ->setDefaultAction(new Action\NotFoundAction()); + } +} \ No newline at end of file diff --git a/src/Frontend/Settings/Setting.php b/src/Frontend/Settings/Setting.php new file mode 100644 index 0000000..2a59093 --- /dev/null +++ b/src/Frontend/Settings/Setting.php @@ -0,0 +1,39 @@ + "Full Width", + Setting::NO_WRAP => "No Wrap", + Setting::FLOATING_SCROLLBAR => "Floating Scrollbar", + Setting::OVERFLOW => "Overflow" + }; + } + + /** + * @return string|null + */ + function getBodyClass(): ?string + { + return match ($this) { + Setting::FULL_WIDTH => "setting-full-width", + Setting::NO_WRAP => "setting-no-wrap", + Setting::FLOATING_SCROLLBAR => "setting-floating-scrollbar", + Setting::OVERFLOW => "setting-overflow", + default => null + }; + } +} diff --git a/src/Frontend/Settings/Settings.php b/src/Frontend/Settings/Settings.php new file mode 100644 index 0000000..50c12f9 --- /dev/null +++ b/src/Frontend/Settings/Settings.php @@ -0,0 +1,66 @@ + + */ + protected array $data = []; + + public function __construct() + { + $rawData = new SettingsCookie()->getValue(); + if ($rawData) { + $parsedData = json_decode($rawData, true); + if (is_array($parsedData)) { + $this->data = $parsedData; + } + } + } + + /** + * @param Setting $key + * @return bool + */ + public function get(Setting $key): bool + { + $value = $this->data[$key->value] ?? false; + if (is_bool($value)) { + return $value; + } + return false; + } + + /** + * @return string[] + */ + public function getBodyClasses(): array + { + $classes = []; + foreach (Setting::cases() as $setting) { + if ($this->get($setting)) { + $bodyClass = $setting->getBodyClass(); + if ($bodyClass) { + $classes[] = $bodyClass; + } + } + } + return $classes; + } + + /** + * @return string + */ + public function getBodyClassesString(): string + { + $classes = $this->getBodyClasses(); + if (empty($classes)) { + return ""; + } + return " " . implode(" ", $this->getBodyClasses()); + } +} \ No newline at end of file diff --git a/src/Id.php b/src/Id.php new file mode 100644 index 0000000..a2f25d1 --- /dev/null +++ b/src/Id.php @@ -0,0 +1,60 @@ +id === null) { + $this->generate(); + } + } + + /** + * Generates a new id + * + * @return string + */ + protected function generate(): string + { + $config = \Aternos\Mclogs\Config\Config::getInstance(); + $idLength = $config->get(ConfigKey::ID_LENGTH); + + $newId = ""; + for ($i = 0; $i < $idLength; $i++) { + $newId .= static::CHARACTERS[rand(0, strlen(static::CHARACTERS) - 1)]; + } + + return $this->id = $newId; + } + + /** + * @return string + */ + public function get(): string + { + return $this->id; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->id; + } + + public function jsonSerialize(): string + { + return $this->id; + } +} \ No newline at end of file diff --git a/src/Log.php b/src/Log.php new file mode 100644 index 0000000..c5881b8 --- /dev/null +++ b/src/Log.php @@ -0,0 +1,516 @@ +findLog($id, $includeContent); + if ($data === null) { + return null; + } + + return static::fromObject($id, $data); + } + + /** + * @param (string|Id)[] $ids + * @param bool $includeContent + * @return array + */ + public static function findAll(array $ids, bool $includeContent = true): array + { + $ids = array_map(fn($id) => (string)$id, $ids); + $objects = MongoDBClient::getInstance()->findLogs($ids, $includeContent); + $logs = []; + foreach ($objects as $data) { + $id = new Id($data->_id); + $logs[$id->get()] = static::fromObject($id, $data); + } + return $logs; + } + + /** + * @param Id $id + * @param object $data + * @return static + */ + protected static function fromObject(Id $id, object $data): static + { + return new static($id) + ->setContent($data->data ?? "") + ->setToken(isset($data->token) ? new Token($data->token) : null) + ->setMetadata(MetadataEntry::allFromArray($data->metadata ?? [])) + ->setSource($data->source ?? null) + ->setCreated($data->created ?? null) + ->setExpires($data->expires ?? null); + } + + /** + * Create and save a new log + * + * @param string $content + * @param MetadataEntry[] $metadata + * @param string|null $source + * @return static + */ + public static function create(string $content, array $metadata = [], ?string $source = null): static + { + return new static() + ->setMetadata($metadata) + ->setSource($source) + ->setToken(new Token()) + ->save($content); + } + + /** + * @param Id|null $id + */ + public function __construct(protected ?Id $id = null) + { + } + + /** + * @param Token|null $token + * @return $this + */ + public function setToken(?Token $token): static + { + $this->token = $token; + return $this; + } + + /** + * @param MetadataEntry[] $metadata + * @return $this + */ + public function setMetadata(array $metadata): static + { + $this->metadata = $metadata; + return $this; + } + + /** + * @param MetadataEntry $metadataEntry + * @return $this + */ + public function addMetadata(MetadataEntry $metadataEntry): static + { + $this->metadata[] = $metadataEntry; + return $this; + } + + /** + * @param string|null $source + * @return $this + */ + public function setSource(?string $source): static + { + if (is_string($source) && strlen($source) > static::SOURCE_MAX_LENGTH) { + $source = substr($source, 0, static::SOURCE_MAX_LENGTH); + } + $this->source = $source; + return $this; + } + + /** + * @return string|null + */ + public function getSource(): ?string + { + return $this->source; + } + + /** + * @param UTCDateTime|null $created + * @return $this + */ + public function setCreated(?UTCDateTime $created): static + { + $this->created = $created; + return $this; + } + + /** + * @param UTCDateTime|null $expires + * @return $this + */ + public function setExpires(?UTCDateTime $expires): static + { + $this->expires = $expires; + return $this; + } + + /** + * @return UTCDateTime|null + */ + public function getCreated(): ?UTCDateTime + { + return $this->created; + } + + /** + * @return UTCDateTime|null + */ + public function getExpires(): ?UTCDateTime + { + return $this->expires; + } + + /** + * @param string $content + * @return $this + */ + public function setContent(string $content): static + { + $this->processAndDeobfuscate($content); + return $this; + } + + public function getContent(): string + { + return $this->log->getLogFile()->getContent(); + } + + protected function processAndDeobfuscate(string $data): void + { + $this->process($data); + $deobfuscator = new Deobfuscator($this->getCodexLog()); + if ($deobfuscatedData = $deobfuscator->deobfuscate()) { + $this->process($deobfuscatedData); + } + } + + protected function process($data): void + { + $this->log = new Detective()->setLogFile(new StringLogFile($data))->detect(); + $this->log->parse(); + if ($this->log instanceof AnalysableLogInterface) { + $this->log->analyse(); + } + } + + /** + * Get the codex log object + * + * @return LogInterface + */ + public function getCodexLog(): LogInterface + { + return $this->log; + } + + /** + * Get the log analysis + * + * @return Analysis|null + */ + public function getAnalysis(): ?Analysis + { + $log = $this->getCodexLog(); + if ($log instanceof AnalysableLogInterface) { + return $log->analyse(); + } + return null; + } + + /** + * @return Printer + */ + public function getPrinter(): Printer + { + if ($this->printer === null) { + $this->printer = new Printer()->setLog($this->log)->setId($this->id); + } + return $this->printer; + } + + /** + * Get the amount of lines in this log + * + * @return int + */ + public function getLinesCount(): int + { + $codexLog = $this->getCodexLog(); + $lines = 0; + foreach ($codexLog as $entry) { + $lines += count($entry); + } + return $lines; + } + + /** + * @return string + */ + public function getLinesString(): string + { + $lineCount = $this->getLinesCount(); + return $lineCount . ($lineCount === 1 ? " line" : " lines"); + } + + /** + * @return int + */ + public function getSize(): int + { + return strlen($this->getContent()); + } + + /** + * Get the amount of error entries in the log + * + * @return int + */ + public function getErrorsCount(): int + { + $errorCount = 0; + + foreach ($this->log as $entry) { + if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) { + $errorCount++; + } + } + + return $errorCount; + } + + /** + * @return bool + */ + public function hasErrors(): bool + { + return $this->getErrorsCount() > 0; + } + + /** + * @return string + */ + public function getErrorsString(): string + { + $errorCount = $this->getErrorsCount(); + return $errorCount . ($errorCount === 1 ? " error" : " errors"); + } + + protected function generateId(): Id + { + do { + $this->id = new Id(); + } while (MongoDBClient::getInstance()->hasLog($this->id)); + return $this->id; + } + + /** + * Save the log to the database + * + * @return $this + */ + public function save(string $content): static + { + if ($this->id === null) { + $this->generateId(); + } + + $content = Filter::filterAll($content); + + MongoDBClient::getInstance()->getLogsCollection()->insertOne([ + "_id" => $this->id->get(), + "data" => $content, + "token" => $this->token?->get(), + "source" => $this->source, + "metadata" => $this->metadata, + "expires" => $this->expires = $this->getExpiryTimestamp(), + "created" => $this->created = new UTCDateTime() + ]); + + return $this->setContent($content); + } + + /** + * @return UTCDateTime + */ + protected function getExpiryTimestamp(): UTCDateTime + { + $ttl = \Aternos\Mclogs\Config\Config::getInstance()->get(ConfigKey::STORAGE_TTL); + $expires = time() + $ttl; + return new UTCDateTime($expires * 1000); + } + + /** + * Renew the expiry timestamp to expand the ttl + * + * @return bool + */ + public function renew(): bool + { + $expires = $this->getExpiryTimestamp(); + $result = MongoDBClient::getInstance()->setLogExpires($this->id, $expires); + if ($result) { + $this->expires = $expires; + } + return $result; + } + + /** + * @return Uri + */ + public function getURL(): Uri + { + return URL::getBase()->withPath("/" . $this->id->get()); + } + + /** + * + * @return string + */ + public function getDisplayURL(): string + { + $url = $this->getURL(); + return $url->getHost() . $url->getPath(); + } + + /** + * @return Uri + */ + public function getRawURL(): Uri + { + return URL::getApi()->withPath("/1/raw/" . $this->id->get()); + } + + /** + * @return Id|null + */ + public function getId(): ?Id + { + return $this->id; + } + + /** + * @return Token|null + */ + public function getToken(): ?Token + { + return $this->token; + } + + /** + * @return bool + */ + public function delete(): bool + { + return MongoDBClient::getInstance()->deleteLog($this->id->get()); + } + + /** + * @return MetadataEntry[] + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * @return MetadataEntry[] + */ + public function getVisibleMetadata(): array + { + return array_filter($this->metadata, function (MetadataEntry $entry) { + return $entry->isVisible(); + }); + } + + /** + * @return bool + */ + public function setTokenCookie(): bool + { + if (!$this->getToken()) { + return false; + } + return new TokenCookie($this)->set($this->getToken()->get()); + } + + /** + * @return bool + */ + public function hasValidTokenCookie(): bool + { + $tokenCookie = new TokenCookie(); + $cookieValue = $tokenCookie->getValue(); + if ($cookieValue === null || !$this->getToken()) { + return false; + } + return $this->getToken()->matches($cookieValue); + } + + /** + * @return string + */ + public function getPageTitle(): string + { + return $this->getCodexLog()?->getTitle() . " [#" . $this->getId()?->get() . "]"; + } + + /** + * @return string + */ + public function getPageDescription(): string + { + $description = $this->getLinesString(); + if ($this->hasErrors()) { + $description .= " | " . $this->getErrorsString(); + } + + $problems = $this->getAnalysis()->getProblems(); + + if (count($problems) > 0) { + $problemString = "problems"; + if (count($problems) === 1) { + $problemString = "problem"; + } + $description .= " | " . count($problems) . " " . $problemString . " automatically detected"; + } + + return $description; + } +} diff --git a/src/Printer/FormatModification.php b/src/Printer/FormatModification.php new file mode 100644 index 0000000..a4f425c --- /dev/null +++ b/src/Printer/FormatModification.php @@ -0,0 +1,20 @@ +addModification(new FormatModification()); + } + + /** + * @var Id + */ + protected Id $id; + + /** + * @param Id $id + * @return Printer + */ + public function setId(Id $id): static + { + $this->id = $id; + return $this; + } + + /** + * @return string + */ + protected function printLog(): string + { + return '
' . parent::printLog() . '
'; + } + + /** + * @param EntryInterface|null $entry + * @return string + * @throws \Exception + */ + protected function printEntry(?EntryInterface $entry = null): string + { + $entry = $entry ?? $this->entry; + /** @var Entry $entry */ + $return = ''; + $first = true; + foreach ($entry as $line) { + $entryClass = "entry-no-error"; + if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) { + $entryClass = "entry-error"; + } + $return .= '
'; + $return .= ''; + $return .= '
'; + $lineString = $this->printLine($line); + if ($entry->getPrefix() !== null) { + $prefix = htmlentities($entry->getPrefix()); + $lineString = str_replace($prefix, '' . $prefix . '', $lineString); + } + $return .= $lineString; + $return .= '
'; + $return .= '
'; + $first = false; + } + + return $return; + } + + /** + * @param LineInterface $line + * @return string + */ + protected function printLine(LineInterface $line): string + { + return $this->runModifications(htmlentities($line->getText())) . PHP_EOL; + } +} diff --git a/src/Router/Action.php b/src/Router/Action.php new file mode 100644 index 0000000..0accfc3 --- /dev/null +++ b/src/Router/Action.php @@ -0,0 +1,8 @@ +getMethod() !== $method) { + return false; + } + return preg_match($this->getPattern(), $path) === 1; + } + + /** + * @return Method + */ + public function getMethod(): Method + { + return $this->method; + } + + /** + * @return string + */ + public function getPattern(): string + { + return $this->pattern; + } + + /** + * @return Action + */ + public function getAction(): Action + { + return $this->action; + } +} \ No newline at end of file diff --git a/src/Router/Router.php b/src/Router/Router.php new file mode 100644 index 0000000..2126f68 --- /dev/null +++ b/src/Router/Router.php @@ -0,0 +1,73 @@ +routes[] = new Route($method, $pattern, $action); + return $this; + } + + /** + * @param Action $defaultAction + * @return $this + */ + public function setDefaultAction(Action $defaultAction): static + { + $this->defaultAction = $defaultAction; + return $this; + } + + /** + * @return $this + */ + public function run(): static + { + $route = $this->findRoute(); + if (!$route) { + $this->defaultAction?->run(); + return $this; + } + $result = $route->getAction()->run(); + if (!$result) { + $this->defaultAction?->run(); + } + return $this; + } + + /** + * @return Route|null + */ + protected function findRoute(): ?Route + { + $path = URL::getCurrent()->getPath(); + $method = Method::getCurrent(); + + foreach ($this->routes as $route) { + if ($route->matches($method, $path)) { + return $route; + } + } + return null; + } +} diff --git a/src/Storage/MongoDBClient.php b/src/Storage/MongoDBClient.php new file mode 100644 index 0000000..43e3079 --- /dev/null +++ b/src/Storage/MongoDBClient.php @@ -0,0 +1,234 @@ +get(ConfigKey::MONGODB_URL); + $url = new Uri($configUrl); + $query = $url->getQuery(); + $queryParams = []; + if ($query !== null) { + parse_str($query, $queryParams); + } + if (!isset($queryParams['serverSelectionTimeoutMS'])) { + $queryParams['serverSelectionTimeoutMS'] = 5_000; + } + if (!isset($queryParams['socketTimeoutMS'])) { + $queryParams['socketTimeoutMS'] = 60_000; + } + $newQuery = http_build_query($queryParams); + $newUrl = $url->withQuery($newQuery); + return $newUrl->toString(); + } + + /** + * Connect to MongoDB + */ + protected function connect(): void + { + if ($this->connection === null) { + $config = Config::getInstance(); + $this->connection = new Client($this->getConnectionURL()); + $this->database = $this->connection->getDatabase($config->get(ConfigKey::MONGODB_DATABASE)); + } + } + + /** + * Ensure indexes exist + * + * @return void + */ + public function ensureIndexes(): void + { + $logs = $this->getLogsCollection(); + $logs->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]); + + $cache = $this->getCacheCollection(); + $cache->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]); + } + + /** + * @return void + */ + public function reset(): void + { + $this->connection = null; + $this->logs = null; + $this->cache = null; + } + + /** + * Get the collection for logs + * + * @return Collection + */ + public function getLogsCollection(): Collection + { + if ($this->logs === null) { + $this->connect(); + $this->logs = $this->database->getCollection('logs'); + } + return $this->logs; + } + + /** + * @param string $id + * @param bool $includeContent + * @return object|null + */ + public function findLog(string $id, bool $includeContent = true): ?object + { + $options = []; + if (!$includeContent) { + $options['projection'] = ['data' => 0]; + } + + $collection = $this->getLogsCollection(); + $result = $collection->findOne(['_id' => $id], $options); + if ($result === null) { + // Check for legacy ID without the first character + return $collection->findOne(['_id' => substr($id, 1)], $options); + } + return $result; + } + + /** + * @param string[] $ids + * @param bool $includeContent + * @return object[] + */ + public function findLogs(array $ids, bool $includeContent = true): array + { + $options = []; + if (!$includeContent) { + $options['projection'] = ['data' => 0]; + } + + $collection = $this->getLogsCollection(); + $results = $collection->find(['_id' => ['$in' => $ids]], $options)->toArray(); + $foundIds = []; + foreach ($results as $result) { + $foundIds[] = (string)$result->_id; + } + + $missingIds = array_diff($ids, $foundIds); + if (!empty($missingIds)) { + $legacyIds = []; + foreach ($missingIds as $id) { + $legacyIds[substr($id, 1)] = $id; + } + + // Check for legacy IDs without the first character + $legacyResults = $collection->find(['_id' => ['$in' => array_keys($legacyIds)]], $options)->toArray(); + foreach ($legacyResults as $result) { + // Map the legacy ID back to the original ID + $originalId = $legacyIds[(string)$result->_id]; + $result->_id = $originalId; + + // Add the found legacy results to the main results array + $results[] = $result; + } + } + return $results; + } + + /** + * @param string $id + * @return bool + */ + public function deleteLog(string $id): bool + { + $collection = $this->getLogsCollection(); + $result = $collection->deleteOne(['_id' => $id]); + if ($result->getDeletedCount() === 0) { + // Check for legacy ID without the first character + $result = $collection->deleteOne(['_id' => substr($id, 1)]); + return $result->getDeletedCount() === 1; + } + return true; + } + + /** + * @param array $ids + * @return int Number of logs deleted + */ + public function deleteLogs(array $ids): int + { + $collection = $this->getLogsCollection(); + $result = $collection->deleteMany(['_id' => ['$in' => $ids]]); + $deletedCount = $result->getDeletedCount(); + + if ($deletedCount === count($ids)) { + return $deletedCount; + } + + // Check for legacy IDs without the first character + $legacyIds = []; + foreach ($ids as $id) { + $legacyIds[] = substr($id, 1); + } + $legacyResult = $collection->deleteMany(['_id' => ['$in' => $legacyIds]]); + return $deletedCount + $legacyResult->getDeletedCount(); + } + + /** + * @param string $id + * @return bool + */ + public function hasLog(string $id): bool + { + return $this->findLog($id) !== null; + } + + /** + * @param string $id + * @param UTCDateTime $expires + * @return bool + */ + public function setLogExpires(string $id, UTCDateTime $expires): bool + { + $collection = $this->getLogsCollection(); + $result = $collection->updateOne( + ['_id' => $id], + ['$set' => ['expires' => $expires]] + ); + return $result->getModifiedCount() === 1; + } + + /** + * Get the collection for caching + * + * @return Collection + */ + public function getCacheCollection(): Collection + { + if ($this->cache === null) { + $this->connect(); + $this->cache = $this->database->getCollection('cache'); + } + return $this->cache; + } +} diff --git a/src/Util/Singleton.php b/src/Util/Singleton.php new file mode 100644 index 0000000..26d1bbd --- /dev/null +++ b/src/Util/Singleton.php @@ -0,0 +1,37 @@ + 365 * 24 * 60 * 60, + "month" => 30 * 24 * 60 * 60, + "week" => 7 * 24 * 60 * 60, + "day" => 24 * 60 * 60, + "hour" => 60 * 60, + "minute" => 60, + "second" => 1, + ]; + + /** + * @param int $value + * @param string $unit + * @return string + */ + protected function formatUnit(int $value, string $unit): string + { + if ($value === 1) { + return $value . " " . $unit; + } else { + return $value . " " . $unit . "s"; + } + } + + /** + * @param int $duration + * @param string $separator + * @return string + */ + public function format(int $duration, string $separator = ", "): string + { + $parts = []; + while ($duration > 0) { + foreach (self::UNITS as $unit => $seconds) { + if ($duration >= $seconds) { + $value = intdiv($duration, $seconds); + $duration -= $value * $seconds; + $parts[] = $this->formatUnit($value, $unit); + break; + } + } + } + return implode($separator, $parts); + } +} diff --git a/src/Util/URL.php b/src/Util/URL.php new file mode 100644 index 0000000..d47ee72 --- /dev/null +++ b/src/Util/URL.php @@ -0,0 +1,128 @@ +withHost(static::API_SUBDOMAIN . $base->getHost()); + } + + /** + * @return Uri + */ + public static function getCurrent(): Uri + { + if (static::$current) { + return static::$current; + } + $scheme = $_SERVER['REQUEST_SCHEME']; + $host = $_SERVER['HTTP_HOST']; + $requestUri = $_SERVER['REQUEST_URI']; + return static::$current = new Uri("$scheme://$host$requestUri"); + } + + /** + * @return bool + */ + public static function isApi(): bool + { + $currentHost = static::getCurrent()->getHost(); + $apiHost = static::getApi()->getHost(); + return $currentHost === $apiHost; + } + + /** + * @return string + */ + public static function getLastPathPart(): string + { + $path = static::getCurrent()->getPath(); + $parts = explode("/", $path); + do { + $part = trim(array_pop($parts)); + } while ($part === "" && count($parts) > 0); + return $part; + } +} \ No newline at end of file diff --git a/web/frontend/404.php b/web/frontend/404.php new file mode 100644 index 0000000..e1b1d57 --- /dev/null +++ b/web/frontend/404.php @@ -0,0 +1,22 @@ + + + + + 404 - Page not found + + + +
+
+
404
+
Page not found
+

The log you're looking for doesn't exist or has expired.

+ + + Back to Home + +
+
+ + + diff --git a/web/frontend/api-docs.php b/web/frontend/api-docs.php new file mode 100644 index 0000000..562541d --- /dev/null +++ b/web/frontend/api-docs.php @@ -0,0 +1,639 @@ + + + + + + API Documentation - <?= htmlspecialchars($config->getName()); ?> + + + + +
+
+
+

API Documentation

+

Integrate getName()); ?> 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.

+
+
+ +
+

Create a log

+ +
+ POST withPath("/1/log")->toString()); ?> application/json +
+
+ Posting content with the content type application/x-www-form-urlencoded is still supported for backwards compatibility, but does not support setting metadata. +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldRequiredTypeDescription
contentstring + The raw log file content as string. + Limited to get(ConfigKey::STORAGE_LIMIT_BYTES) / 1024 / 1024, 2); ?> MiB and get(ConfigKey::STORAGE_LIMIT_LINES)); ?> lines. + Will be truncated if possible and necessary, but truncating on the client side is recommended. +
sourcestringThe name of the source, e.g. a domain or software name.
metadataarrayAn array of metadata entries.
+ +

Example body application/json

+
{
+    "content": "[log file content...]",
+    "source": "example.org"
+}
+ +

Metadata

+

+ You can send metadata alongside the log content to be displayed on the log page and/or be read by other applications through this API. + This is entirely optional, but can help to provide additional context, e.g. internal server IDs, software versions etc. +

+

+ A metadata entry is an object with the following fields: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldRequiredTypeDescription
keystringThe metadata key. Can be used to identify the entry in your code later.
valuestring|int|float|bool|nullThe metadata value.
labelstringThe display label. If not provided, the key will be used as label.
visibleboolWhether this metadata should be visible on the log page or is only available through the API. Default is true.
+ +

Example body with metadata application/json

+
{
+    "content": "[log file content...]",
+    "source": "example.org",
+    "metadata": [
+        {
+            "key": "server_id",
+            "value": 12345,
+            "visible": false
+        },
+        {
+            "key": "software_version",
+            "value": "1.2.3",
+            "label": "Software Version",
+            "visible": true
+        }
+    ]
+}
+ +

Responses

+

Success application/json

+
+ 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. +
+
{
+    "success":true,
+    "id":"WnMMikq",
+    "source":null,
+    "created":1769597979,
+    "expires":1777373979,
+    "size":157369,
+    "lines":1201,
+    "errors":8,
+    "url": "withPath("/WnMMikq")->toString()); ?>",
+    "raw": "withPath("/1/raw/WnMMikq")->toString()); ?>",
+    "token":"78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771",
+    "metadata": [
+        {
+            "key": "server_id",
+            "value": 12345,
+            "visible": false
+        },
+        {
+            "key": "software_version",
+            "value": "1.2.3",
+            "label": "Software Version",
+            "visible": true
+        }
+    ]
+}
+

Error application/json

+
+{
+    "success": false,
+    "error": "Required field 'content' not found."
+}
+
+ +
+

Get log info and content

+
+ GET toString()); ?>/1/log/[id] +
+

+ This endpoint only returns the log info and metadata by default (same response as creating a log), you can also get the content in the same request by enabling it in different + formats using GET parameters. You can combine multiple parameters to get multiple content formats in one request, but keep in mind that this will + increase the response size. +

+ + + + + + + + + + + + + + + + + + + + + +
GET ParameterResponse fieldDescription
rawcontent.rawIncludes the raw log content as string in the response.
parsedcontent.parsedIncludes the parsed log content as array/objects in the response.
insightscontent.insightsIncludes the automatically detected insights in the response.
+

Responses

+

Success application/json

+
+ All content fields are only included if the corresponding GET parameter is provided. + If no content parameter is provided, the entire content object is omitted from the response. +
+
{
+    "success":true,
+    "id":"WnMMikq",
+    "source":null,
+    "created":1769597979,
+    "expires":1777373979,
+    "size":157369,
+    "lines":1201,
+    "errors":8,
+    "url": "withPath("/WnMMikq")->toString()); ?>",
+    "raw": "withPath("/1/raw/WnMMikq")->toString()); ?>",
+    "metadata": [
+        {
+            "key": "server_id",
+            "value": 12345,
+            "visible": false
+        },
+        {
+            "key": "software_version",
+            "value": "1.2.3",
+            "label": "Software Version",
+            "visible": true
+        }
+    ],
+    "content": {
+        "raw": "[log file content...]",
+        "parsed": [ /* parsed log entries */ ],
+        "insights": { "problems": [ /* detected problems */ ], "information": [ /* detected information */ ] }
+    }
+}
+

Error application/json

+
+{
+    "success": false,
+    "error": "Log not found."
+}
+
+
+

Delete a log

+
+ Deleting a log requires the token that was provided when creating the log. +
+ +
+ DELETE toString()); ?>/1/log/[id] +
+ +

Headers

+ + + + + + + + + + + +
HeaderExampleDescription
AuthorizationAuthorization: Bearer 78351fafe495398163f...The type (always "Bearer") and the log token received when creating the log.
+ +

Responses

+

Success application/json

+
{
+    "success": true
+}
+

Error application/json

+
+{
+    "success": false,
+    "error": "Invalid token."
+}
+
+
+

Bulk delete multiple logs

+
+ This method allows deleting up to at once. + Deleting logs requires the tokens that were provided when the logs were created. +
+ +
+ POST toString()); ?>/1/bulk/log/delete +
+ +

Example body application/json

+
 "6wexMDE",
+                                        "token" => "78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771"
+                                ],
+                                [
+                                        "id" => "OahzhMG",
+                                        "token" => "6520dd42ec3d5fd0e83f28220974fb83d3bdc0746853f5022373f8e5b062651b"
+                                ],
+                        ], JSON_PRETTY_PRINT); ?>
+ +

Responses

+

Success application/json

+
+ The bulk delete request will return a successful result and status code 207, + indicating that the request was processed. + Results for the individual operations are included in the response body. +
+
addResponse("6wexMDE", new ApiResponse())
+                                ->addResponse("OahzhMG", new ApiResponse()), JSON_PRETTY_PRINT); ?>
+

Partial success application/json

+
+ If a bulk delete request is valid, but not all logs can be deleted (e.g. due to invalid tokens or non-existing logs), + it will still overall be considered successful, but the response body will include error results for the logs that could not be deleted. +
+
addResponse("6wexMDE", new ApiResponse())
+                                ->addResponse("OahzhMG", new ApiError(404, "Log not found.")), JSON_PRETTY_PRINT); ?>
+

Error application/json

+
+ If a bulk delete request is malformed or invalid, the entire request will be + rejected with an error response and no logs will be deleted. +
+
+{
+    "success": false,
+    "error": "No logs provided."
+}
+
+
+

Get the raw log file content

+
+ 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. +
+
+ GET toString()); ?>/1/raw/[id] +
+ + + + + + + + + + + +
FieldTypeDescription
[id]stringThe log file id, received from the paste endpoint or from a URL (toString()); ?>/[id]).
+ +

Success text/plain

+
+[18:25:33] [Server thread/INFO]: Starting minecraft server version 1.16.2
+[18:25:33] [Server thread/INFO]: Loading properties
+[18:25:34] [Server thread/INFO]: Default game type: SURVIVAL
+...
+
+

Error application/json

+
+{
+    "success": false,
+    "error": "Log not found."
+}
+
+
+

Get insights

+
+ 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. +
+
+ GET toString()); ?>/1/insights/[id] +
+ + + + + + + + + + + +
FieldTypeDescription
[id]stringThe log file id, received from the paste endpoint or from a URL (toString()); ?>/[id]).
+ +

Success application/json

+
+{
+  "id": "name/type",
+  "name": "Software name, e.g. Vanilla",
+  "type": "Type name, e.g. Server Log",
+  "version": "Version, e.g. 1.12.2",
+  "title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
+  "analysis": {
+    "problems": [
+      {
+        "message": "A message explaining the problem.",
+        "counter": 1,
+        "entry": {
+          "level": 6,
+          "time": null,
+          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
+          "lines": [
+            {
+              "number": 1,
+              "content": "The full content of the line."
+            }
+          ]
+        },
+        "solutions": [
+          {
+            "message": "A message explaining a possible solution."
+          }
+        ]
+      }
+    ],
+    "information": [
+      {
+        "message": "Label: value",
+        "counter": 1,
+        "label": "The label of this information, e.g. Minecraft version",
+        "value": "The value of this information, e.g. 1.12.2",
+        "entry": {
+          "level": 6,
+          "time": null,
+          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
+          "lines": [
+            {
+              "number": 6,
+              "content": "The full content of the line."
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
+

Error application/json

+
+{
+    "success": false,
+    "error": "Log not found."
+}
+
+
+

Analyse a log without saving it

+

+ If you only want to use the analysis features of this service without saving the log, you can use this endpoint. + Please do not save logs that you only want to analyse, as this wastes storage space and resources. +

+ +
+ POST withPath("/1/analyse")->toString()); ?> application/x-www-form-urlencoded application/json +
+ + + + + + + + + + + +
FieldTypeDescription
contentstringThe raw log file content as string. Maximum length is 10MiB and 25k lines, will be shortened if necessary.
+ +

Success application/json

+
+{
+  "id": "name/type",
+  "name": "Software name, e.g. Vanilla",
+  "type": "Type name, e.g. Server Log",
+  "version": "Version, e.g. 1.12.2",
+  "title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
+  "analysis": {
+    "problems": [
+      {
+        "message": "A message explaining the problem.",
+        "counter": 1,
+        "entry": {
+          "level": 6,
+          "time": null,
+          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
+          "lines": [
+            {
+              "number": 1,
+              "content": "The full content of the line."
+            }
+          ]
+        },
+        "solutions": [
+          {
+            "message": "A message explaining a possible solution."
+          }
+        ]
+      }
+    ],
+    "information": [
+      {
+        "message": "Label: value",
+        "counter": 1,
+        "label": "The label of this information, e.g. Minecraft version",
+        "value": "The value of this information, e.g. 1.12.2",
+        "entry": {
+          "level": 6,
+          "time": null,
+          "prefix": "The prefix of this entry, usually the part containing time and loglevel.",
+          "lines": [
+            {
+              "number": 6,
+              "content": "The full content of the line."
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
+

Error application/json

+
+{
+    "success": false,
+    "error": "Required field 'content' is empty."
+}
+
+
+

Check storage limits

+ +
+ GET withPath("/1/limits")->toString()); ?> +
+

Success application/json

+
+{
+  "storageTime": 7776000,
+  "maxLength": 10485760,
+  "maxLines": 25000
+}
+ + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
storageTimeintegerThe duration in seconds that a log is stored for after the last view.
maxLengthintegerMaximum file length in bytes. Logs over this limit will be truncated to this length.
maxLinesintegerMaximum number of lines. Additional lines will be removed.
+
+
+

Get filters

+

+ Filters modify the log content before storing it. They are applied automatically when creating a new log on the server side. + You can get a list of active filters from this endpoint if you want to apply the same filters on the client side before uploading a log. +

+
+ GET withPath("/1/filters")->toString()); ?> +
+

Success application/json

+
+
+

Filter types

+ + + + + + + + + + + + + + + + + + + + + +
TypeDescription
trim + Trim any whitespace characters from the beginning and end of the log content. +
limit-bytes + Limit the log content to a maximum number of bytes (data.limit). Content exceeding this limit will be truncated. +
limit-lines + Limit the log content to a maximum number of lines (data.limit). Additional lines will be removed. +
regex + 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. +
+
+ Make sure to handle any filter error, e.g. unknown filter types gracefully, as new filter types may be added in the future. +
+
+
+
+

Notes

+

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.

+ +
+
+
+ + + diff --git a/web/frontend/log.php b/web/frontend/log.php new file mode 100644 index 0000000..97c5fb1 --- /dev/null +++ b/web/frontend/log.php @@ -0,0 +1,230 @@ + + + + + <?=htmlspecialchars($log->getPageTitle()); ?> + + + + +
+
+
+
+
+

+ + getCodexLog()->getTitle()); ?> +

+ +
+
+
+
+
+ hasErrors()): ?> +
+ + getErrorsString()); ?> +
+ +
+ + getLinesString()); ?> +
+ + + Raw + +
+
+
+
+ getAnalysis()->getInformation(); ?> + getVisibleMetadata()) > 0 || count($information) > 0): ?> +
+ getVisibleMetadata()) > 0): ?> +
+
+
+ + Metadata +
+ getVisibleMetadata() as $metadata): ?> + + getDisplayLabel()); ?>: + getDisplayValue()); ?> + + +
+
+ + 0): ?> +
+
+
+ + Detected +
+ + + getLabel()); ?>: + getValue()); ?> + + +
+
+ +
+ + getAnalysis()?->getProblems(); ?> + 0): ?> +
+
+
+ + detected +
+
+ + getEntry()[0]->getNumber(); ?> +
+ " class="problem-entry" onclick="updateLineNumber('#L');"> + + + Problem + + getMessage()); ?> + Line + + getSolutions()) > 0): ?> +
+ getSolutions()) === 1 ? 'Solution:' : 'Solutions:'; ?> + getSolutions() as $solution): ?> +
+ + $1'", htmlspecialchars($solution->getMessage())); ?> +
+ +
+ +
+ +
+
+
+ +
+
+
+
+ getPrinter()->print(); + ?> +
+
+ + +
+
+
+
+
+
+ getHTML(AssetType::JS, "js/log.js"); ?> + + diff --git a/web/frontend/parts/favicon.php b/web/frontend/parts/favicon.php new file mode 100644 index 0000000..0447ee8 --- /dev/null +++ b/web/frontend/parts/favicon.php @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/frontend/parts/footer.php b/web/frontend/parts/footer.php new file mode 100644 index 0000000..242c061 --- /dev/null +++ b/web/frontend/parts/footer.php @@ -0,0 +1,28 @@ +get(ConfigKey::LEGAL_IMPRINT); +$privacyUrl = Config::getInstance()->get(ConfigKey::LEGAL_PRIVACY); +?> + diff --git a/web/frontend/parts/head.php b/web/frontend/parts/head.php new file mode 100644 index 0000000..bf35fbf --- /dev/null +++ b/web/frontend/parts/head.php @@ -0,0 +1,44 @@ + + + + + getHTML(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css"); ?> + getHTML(AssetType::CSS, "css/mclogs.css"); ?> + + + + + toString()); ?>" type="image/svg+xml"> + + +get(ConfigKey::FRONTEND_ANALYTICS)): ?> + + diff --git a/web/frontend/parts/header.php b/web/frontend/parts/header.php new file mode 100644 index 0000000..4386391 --- /dev/null +++ b/web/frontend/parts/header.php @@ -0,0 +1,49 @@ +
+ +
+

Paste your logs.

+
Built for Minecraft & Hytale
+
+ +
diff --git a/web/frontend/start.php b/web/frontend/start.php new file mode 100644 index 0000000..2c13e74 --- /dev/null +++ b/web/frontend/start.php @@ -0,0 +1,37 @@ + + + + + <?= htmlspecialchars(Config::getInstance()->getName()); ?> - Paste, share & analyse your logs + + + + +
+
+
+ +

Paste or drop your log here

+
+ + + Drop +
+
+ + +
+
+
+ + + getHTML(AssetType::JS, "js/start.js"); ?> + + diff --git a/web/public/css/mclogs.css b/web/public/css/mclogs.css new file mode 100644 index 0000000..46a4398 --- /dev/null +++ b/web/public/css/mclogs.css @@ -0,0 +1,2006 @@ +/* plus-jakarta-sans-regular - latin */ +@font-face { + font-display: swap; + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 400; + src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2'); +} +/* plus-jakarta-sans-500 - latin */ +@font-face { + font-display: swap; + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 500; + src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2'); +} +/* plus-jakarta-sans-600 - latin */ +@font-face { + font-display: swap; + font-family: 'Plus Jakarta Sans'; + font-style: normal; + font-weight: 600; + src: url('../vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2') format('woff2'); +} +/* jetbrains-mono-regular - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */ +@font-face { + font-display: swap; + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2') format('woff2'); +} +/* jetbrains-mono-italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */ +@font-face { + font-display: swap; + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: 400; + src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2') format('woff2'); +} +/* jetbrains-mono-700 - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */ +@font-face { + font-display: swap; + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2') format('woff2'); +} +/* jetbrains-mono-700italic - cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese */ +@font-face { + font-display: swap; + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: 700; + src: url('../vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2') format('woff2'); +} + +:root { + --bg-surface: color-mix(in srgb, var(--bg) 92%, var(--text) 8%); + --bg-elevated: color-mix(in srgb, var(--bg) 95%, var(--text) 5%); + --bg-inset: var(--bg-surface); + --text-muted: color-mix(in srgb, var(--text) 55%, var(--bg) 45%); + --accent-hover: color-mix(in srgb, var(--accent) 78%, var(--bg) 22%); + --accent-bg: color-mix(in srgb, var(--accent) 12%, transparent); + --accent-border: var(--accent); + --error-bg: color-mix(in srgb, var(--error) 10%, transparent); + --error-border: color-mix(in srgb, var(--error) 40%, transparent); + --border: rgba(255, 255, 255, 0.08); + --surface: rgba(255, 255, 255, 0.04); + --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --max-width: 1400px; + --page-padding: clamp(1rem, 2.5vw, 1.25rem); + --max-width-content: min(100%, calc(var(--max-width)) - var(--page-padding) * 2); + --radius: 12px; + --scrollbar-height: 8px; + --browser: unset; + scroll-behavior: smooth; +} + +@view-transition { + navigation: auto; +} + +/* Global scrollbar styling */ +*::-webkit-scrollbar { + width: 8px; + height: var(--scrollbar-height); +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--accent); + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--accent-hover); +} + +::selection { + background-color: var(--accent); + color: var(--text); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + scrollbar-color: var(--accent) transparent; +} + +html { + height: 100%; + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-sans), system-ui, sans-serif; + background-color: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100%; + display: flex; + flex-direction: column; + position: relative; + font-weight: 400; +} + +/* Log Settings */ +body.setting-full-width { + --max-width: 100%; + --max-width-content: calc(100% - var(--page-padding) * 2); +} + +body.setting-overflow .log-container { + max-width: unset; + min-width: 100%; +} + +body.setting-no-wrap .log-inner { + white-space: pre; +} + +body.setting-no-wrap .log-inner .line-content { + word-break: normal; + overflow-wrap: normal; +} + +body.setting-no-wrap .log-inner .level { + white-space: pre; +} + +body.setting-no-wrap .log-inner .collapsed-lines-count { + justify-content: flex-start; +} + +a { + color: inherit; + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover:not(.btn) { + color: var(--accent); +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + linear-gradient(color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--text-muted) 5%, var(--bg) 95%) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + z-index: 0; +} + +/** Buttons **/ + +.btn { + background-color: var(--accent); + color: var(--bg); + font-family: inherit; + font-size: clamp(0.85rem, 2vw, 0.9rem); + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + border: 2px solid transparent; + padding: clamp(0.6rem, 2vw, 0.7rem) clamp(1.2rem, 3vw, 1.5rem); + border-radius: 8px; + gap: .4rem; + line-height: 1; + transition: color .15s ease, background-color .15s ease, border-color .15s ease; +} + +.btn:hover:not(:disabled) { + background-image: linear-gradient(#00000014,#00000014); +} + +.btn:disabled, +.btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-small { + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + padding: clamp(0.35rem, 1.5vw, 0.4rem) clamp(0.85rem, 2.5vw, 1rem); +} + +.btn-transparent { + background-color: transparent; + color: var(--accent); + border: 0 none; +} + +.btn-transparent:hover { + color: var(--accent); +} + +.btn-danger { + background-color: var(--error); + color: var(--text); +} + +#error-toggle { + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +#error-toggle.toggled { + background-color: var(--error-bg); + color: var(--text); + border-color: var(--error); +} + +#error-toggle.toggled:hover { + background-color: var(--error-bg); +} + +.btn-white { + background-color: #fff; + color: var(--bg); +} + +.btn-dark { + background-color: var(--surface); + color: var(--text); + border-color: var(--border); +} + +/** Header **/ + +header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: clamp(1rem, 3vw, 2rem) var(--page-padding); + position: relative; + z-index: 1; + transition: max-width .25s ease; +} + +.logo { + view-transition-name: logo; + display: flex; + align-items: center; + gap: .9rem; + text-decoration: none; + transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1); + transform-origin: center; +} + +.logo:active { + transform: scale(.9); +} + +.logo-icon { + height: clamp(1.5rem, 3vw, 2rem); + width: auto; + margin-top: 3px; + color: var(--accent); +} + +.logo-text { + font-size: clamp(1.75rem, 3vw, 2rem); + font-weight: 600; + color: var(--text); + margin-top: -3px; +} + +.tagline { + display: flex; + flex-direction: column; + gap: 0.25rem; + text-align: right; +} + +.tagline-main { + font-size: clamp(1rem, 3vw, 1.5rem); + color: var(--text); + font-weight: 400; +} + +.tagline-sub { + font-size: clamp(0.75rem, 2vw, 1rem); + color: var(--text-muted); +} + +.title-verb { + font-weight: 600; + color: var(--accent); +} + +/** Footer **/ + +footer { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + color: var(--text-muted); + font-size: clamp(0.75rem, 2vw, 0.9rem); + max-width: var(--max-width); + width: 100%; + margin: 0 auto; + padding: clamp(1rem, 3vw, 2rem) clamp(1rem, 2.5vw, 1.25rem); + position: relative; + z-index: 1; + transition: max-width .25s ease; +} + +.legal { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.footer-nav { + display: flex; + gap: 1.5rem; +} + +.footer-nav a { + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.footer-nav a:hover { + color: var(--accent); +} + +.footer-nav a i { + font-size: clamp(0.9rem, 2vw, 1rem); +} + +.footer-text a { + color: var(--text-muted); +} + +.footer-text a:hover { + color: var(--accent); +} + +/** Main **/ + +main { + max-width: var(--max-width-content); + width: 100%; + margin: 0 auto; + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--bg-surface); + border-radius: var(--radius); + position: relative; + overflow: hidden; + z-index: 1; + transition: max-width .25s ease; +} + +.paste-area { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + border-radius: var(--radius); + position: relative; + transition: background-color 0.25s ease, border-color 0.25s ease; + border: 2px dashed transparent; +} + +.paste-area::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 120px; + background: linear-gradient(to bottom, + transparent 0%, + color-mix(in srgb, var(--bg-surface) 40%, transparent) 40%, + color-mix(in srgb, var(--bg-surface) 80%, transparent) 70%, + var(--bg-surface) 100%); + pointer-events: none; + z-index: 5; + border-radius: 0 0 var(--radius) var(--radius); +} + +.paste-area.dragover, +.paste-area.window-dragover { + background-color: color-mix(in srgb, var(--bg-surface) 90%, var(--accent) 10%); + border-color: var(--accent); +} + +.paste-area.dragover .paste-placeholder i.fa-cloud-arrow-up, +.paste-area.window-dragover .paste-placeholder i.fa-cloud-arrow-up { + color: var(--accent); + transform: scale(1.1) translateY(-4px); +} + +.paste-area.dragover .paste-placeholder p, +.paste-area.window-dragover .paste-placeholder p { + color: var(--accent); +} + +.paste-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + z-index: 2; + font-size: clamp(1rem, 3vw, 1.5rem); +} + +.paste-placeholder i.fa-cloud-arrow-up { + font-size: clamp(2rem, 8vw, 3.5rem); + color: var(--text-muted); + margin-bottom: clamp(0.5rem, 2vw, 1.5rem); + transition: color 0.25s ease, transform 0.25s ease; +} + +.paste-placeholder p { + color: var(--text); + margin-bottom: clamp(1.2rem, 2vw, 1.5rem); + transition: color 0.25s ease; + font-weight: 600; +} + +.paste-hints { + display: flex; + gap: clamp(1rem, 3vw, 1.5rem); + justify-content: center; + color: var(--text-muted); + font-size: clamp(0.75rem, 1.8vw, 0.8rem); +} + +.paste-hints span, +.paste-hints button { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: clamp(0.75rem, 1.8vw, 0.8rem); +} + +.paste-hints button { + background: none; + border: none; + padding: 0; + color: var(--text-muted); + font-weight: 400; +} + +.paste-hints button.btn:hover { + background-image: none; +} + +.paste-hints i { + font-size: clamp(0.85rem, 2vw, 0.9rem); +} + +.paste-area .btn-save { + position: absolute; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + width: fit-content; + z-index: 10; + font-size: clamp(1rem, 2.5vw, 1.1rem); + padding: 0.85rem 2rem; +} + +.paste-area .btn-save:not(:disabled) { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: btn-save-pulse 1.5s ease-in-out infinite; +} + +@keyframes btn-save-pulse { + 0% { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 80%, transparent); + } + 70% { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 12px color-mix(in srgb, var(--accent) 0%, transparent); + } + 100% { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); + } +} + +.paste-area textarea { + view-transition-name: log; + flex: 1; + width: 100%; + background: transparent; + border: none; + outline: none; + resize: none; + padding: clamp(.5rem, 3vw, 1.2rem); + font-family: var(--font-mono), monospace; + font-size: clamp(0.75rem, 2vw, 0.9rem); + color: var(--text); + position: relative; +} + +.paste-error { + display: none; + position: absolute; + top: clamp(1rem, 2.5vw, 1.5rem); + right: clamp(1rem, 2.5vw, 1.5rem); + color: var(--error); + font-weight: 600; + font-size: clamp(0.85rem, 2vw, 0.9rem); + padding: clamp(0.7rem, 2vw, 0.8rem) clamp(1rem, 2.5vw, 1.25rem); + background-color: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: 8px; + z-index: 1000; + animation: error-slide-in 0.3s ease-out; +} + +.paste-error.show { + display: block; +} + +@keyframes error-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/** Log Page Layout **/ + +.log-body main { + flex: 0 0 auto; + border-radius: var(--radius) var(--radius) 0 0; +} + +.log-container { + max-width: var(--max-width-content); + min-width: var(--max-width-content); + margin: 0 auto; + background-color: var(--bg-surface); + position: relative; + z-index: 1; + transition: max-width .25s ease, min-width .25s ease; +} + +.log-footer { + max-width: var(--max-width-content); + width: 100%; + margin: 0 auto; + padding: 0 var(--page-padding); + background-color: var(--bg-surface); + border-radius: 0 0 var(--radius) var(--radius); + position: relative; + z-index: 1; + transition: max-width .25s ease; +} + +/** Log Header **/ + +.log-header { + padding: clamp(1rem, 3vw, 1.5rem) var(--page-padding); + border-bottom: 1px solid var(--border); +} + +.log-header-inner { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 1rem; +} + +.log-header .left { + flex: 1 1 300px; + min-width: 0; +} + +.log-header .right { + flex-shrink: 0; +} + +.log-header .log-title h1 { + font-size: clamp(1.1rem, 3vw, 1.25rem); + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; + gap: 0.5rem; + line-height: 1.3; + flex-wrap: wrap; +} + +.log-header .log-title h1 i { + color: var(--accent); +} + +.log-header .log-title { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.log-header .log-title-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.log-header .log-url-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.4rem, 1.5vw, 0.5rem); + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + font-size: clamp(0.7rem, 1.8vw, 0.75rem); + color: var(--text-muted); + font-family: var(--font-mono), monospace; + line-height: 1; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; + vertical-align: middle; + cursor: pointer; +} + +.log-header .log-url-btn:hover { + border-color: var(--accent-border); + background-color: var(--accent-bg); + color: var(--text); +} + +.log-header .log-url-btn i { + font-size: 0.85em; + opacity: 0.5; + color: var(--accent); +} + +.log-info-rows { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; +} + +.log-info-row { + padding: clamp(0.4rem, 1.5vw, 0.5rem) clamp(0.6rem, 2vw, 0.75rem); + background-color: var(--surface); + border-radius: 6px; +} + +.info-row-header { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: clamp(0.7rem, 1.8vw, 0.75rem); + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.03em; + padding-right: clamp(0.6rem, 2vw, 0.75rem); + border-right: 1px solid var(--border); +} + +.info-row-header i { + font-size: 0.7rem; + opacity: 0.8; +} + +.info-row-items { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.info-item { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + color: var(--text-muted); +} + +.info-label { + font-weight: 500; +} + +.info-value { + color: var(--text); + font-weight: 500; + font-family: var(--font-mono), monospace; +} + +.log-header .details { + display: flex; + align-items: center; +} + +.log-header .log-info-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +/** Problems Panel **/ + +.problems-panel-container { + border-top: 1px solid var(--border); + padding-top: clamp(0.75rem, 2vw, 1rem); + margin-top: clamp(0.75rem, 2vw, 1rem); +} + +.problems-panel { + overflow: hidden; + border: 1px solid var(--border); + background-color: var(--surface); + border-radius: 8px; +} + +.problems-header { + display: flex; + align-items: center; + gap: clamp(0.5rem, 1.5vw, 0.6rem); + padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem); + background-color: var(--surface); + border-bottom: 1px solid var(--border); +} + +.problems-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: clamp(1.25rem, 2.5vw, 1.4rem); + height: clamp(1.25rem, 2.5vw, 1.4rem); + background-color: var(--accent); + color: var(--bg); + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + font-weight: 600; + border-radius: 4px; +} + +.problems-title { + font-size: clamp(0.9rem, 2vw, 1rem); + font-weight: 600; + color: var(--text); +} + +.problems-list { + display: flex; + flex-direction: column; +} + +.problem-item { + display: flex; + flex-direction: column; + gap: clamp(0.4rem, 1vw, 0.5rem); + padding: clamp(0.75rem, 2vw, 1rem) clamp(0.85rem, 2.5vw, 1rem); + border-bottom: 1px solid var(--border); +} + +.problem-item:last-child { + border-bottom: none; +} + +.problem-entry { + display: flex; + border-radius: 5px; + overflow: hidden; + font-size: clamp(0.85rem, 2vw, 0.9rem); + background: var(--error-bg); + border: 1px solid var(--error-border); + text-decoration: none; + transition: border-color 0.15s ease; +} + +.problem-entry:hover { + border-color: var(--error); +} + +.problem-label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem); + font-weight: 600; + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + white-space: nowrap; + background-color: var(--error); + color: #fff; +} + +.problem-text { + display: flex; + align-items: center; + padding: clamp(0.3rem, 1vw, 0.4rem) clamp(0.55rem, 1.5vw, 0.65rem); + color: var(--text); + font-weight: 500; + flex: 1; + word-break: break-word; +} + +.problem-line { + display: inline-flex; + align-items: center; + margin: clamp(0.25rem, 0.8vw, 0.35rem) clamp(0.55rem, 1.5vw, 0.65rem); + padding: 0.2em 0.5em; + font-family: var(--font-mono), monospace; + font-size: clamp(0.7rem, 1.6vw, 0.75rem); + font-weight: 500; + color: var(--text-muted); + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + white-space: nowrap; +} + +.problem-solutions { + display: flex; + flex-direction: column; + gap: clamp(0.25rem, 0.5vw, 0.3rem); + padding: clamp(0.4rem, 1vw, 0.5rem) clamp(0.55rem, 1.5vw, 0.65rem); + background-color: var(--surface); + border-radius: 5px; +} + +.problem-solutions-label { + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + font-weight: 600; + color: var(--text-muted); +} + +.problem-solution { + display: flex; + align-items: baseline; + gap: clamp(0.4rem, 1vw, 0.5rem); + font-size: clamp(0.8rem, 1.8vw, 0.85rem); +} + +.problem-solution i { + color: var(--accent); + font-size: 0.85em; +} + +.problem-solution span { + color: var(--text); +} + +/** Log Viewer **/ + +.log { + view-transition-name: log; + padding: 0; + border-bottom: 1px solid var(--border); + background-color: var(--bg-elevated); + position: relative; + flex: 1; +} + +.setting-floating-scrollbar .floating-scrollbar-container { + display: flex; +} + +.floating-scrollbar-container { + --floating-scrollbar-width: 0; + --floating-scrollbar-content-width: 0; + + position: fixed; + display: none; + justify-content: center; + bottom: 0; + width: 100%; + z-index: 10; +} + +.floating-scrollbar { + overflow-x: scroll; + width: var(--floating-scrollbar-width); +} + +.floating-scrollbar-content { + width: var(--floating-scrollbar-content-width); + height: var(--scrollbar-height); +} + +.log-inner { + overflow-y: hidden; + font-family: var(--font-mono), monospace; + font-size: clamp(0.75rem, 2vw, 0.9rem); + line-height: 1.6; + overflow-x: auto; + position: relative; + padding: 0.5rem 0 0; + display: grid; + grid-template-columns: auto 1fr; + contain: layout style paint; + will-change: scroll-position; +} + +.log-inner .entry { + display: contents; + width: 100%; +} + +.log-inner .entry.entry-error .line-content, +.log-inner .entry.entry-error .line-number-container{ + background-color: var(--error-bg); +} + +.log-inner .line-number-container { + min-width: 2.75rem; + padding: 0 0.4rem; + border-right: 1px solid var(--border); + text-align: right; + user-select: none; +} + + +.log-inner .line-number { + padding: clamp(0.08rem, 1vw, 0.1rem) clamp(0.2rem, 1.5vw, 0.25rem); + color: var(--text-muted); + font-weight: 500; + font-size: clamp(0.65rem, 1.8vw, 0.8rem); + border-radius: 4px; +} + +.log-inner .entry.line-active .line-number { + background-color: var(--accent); + color: var(--bg); + font-weight: 600; +} + + +.log-inner .entry.line-active .line-number-container, +.log-inner .entry.line-active .line-content { + background-color: color-mix(in srgb, var(--accent) 15%, var(--bg) 85%); +} + +.log-inner .entry.entry-error.line-active .line-number { + background-color: var(--error); + color: #fff; +} + +.log-inner .entry.entry-error.line-active .line-number-container, +.log-inner .entry.entry-error.line-active .line-content { + background-color: color-mix(in srgb, var(--error) 25%, var(--bg) 75%); +} + +.log-inner .line-content { + padding-left: clamp(0.4rem, 1vw, 0.9rem); + padding-right: clamp(0.4rem, 2vw, 0.6rem); + word-break: break-word; + overflow-wrap: anywhere; + color: var(--text); +} + +/* Firefox fallback: use table layout instead of grid */ +@supports (-moz-appearance: none) { + :root { + --browser: 'firefox'; + } + .log-inner { + display: table; + table-layout: fixed; + width: 100%; + } + + .log-inner .entry, + .log-inner .collapsed-lines { + display: table-row; + } + + .log-inner .line-number-container, + .log-inner .collapsed-lines > div:first-child { + display: table-cell; + width: 3.6rem; + } + + @media (max-width: 600px) { + .log-inner .line-number-container { + width: 2.7rem; + } + } + + .log-inner .line-content, + .log-inner .collapsed-lines-count { + display: table-cell; + } + + .log-inner .collapsed-lines-count { + text-align: center; + vertical-align: middle; + } + + body.setting-no-wrap .log { + overflow-x: auto; + } + + body.setting-no-wrap .log-inner { + table-layout: auto; + } +} + +.collapsed-lines { + display: contents; + cursor: pointer; +} + +.collapsed-lines > div:first-child { + background-color: var(--surface); + border-right: 1px solid var(--border); +} + +.collapsed-lines-count { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1.25rem; + background-color: var(--surface); + color: var(--text); + font-size: clamp(0.85rem, 2vw, 0.9rem); + font-family: var(--font-mono), monospace; + font-weight: 500; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.collapsed-lines:hover .collapsed-lines-count { + background-color: var(--accent-bg); + color: var(--accent); +} + +.collapsed-lines-count i { + font-size: 0.75rem; + color: var(--text-muted); + transition: color 0.15s ease; +} + +.collapsed-lines:hover .collapsed-lines-count i { + color: var(--accent); +} + +.log-inner .level { + display: block; + white-space: pre-wrap; + tab-size: 4; + width: 100%; +} + +.log-inner .level-prefix { + font-weight: 500; + opacity: 0.9; +} + +/** Log Level Styles **/ + +.level { + white-space: pre-wrap; + tab-size: 4; + word-break: normal; +} + +.level-prefix { + font-weight: bold; +} + +.level-info { + color: var(--text); +} + +.level-title { + font-weight: bold; + color: var(--bg); + background-color: var(--accent); + padding: 0 8px; + border-radius: 2px; +} + +.level-info .level-prefix, +.level-notice .level-prefix, +.level-debug .level-prefix { + color: var(--accent); +} + +.level-warning { + color: #FF6625; +} + +.level-error, +.level-critical, +.level-emergency, +.level-stacktrace { + color: var(--error); +} + +.level-comment { + color: #A4A4A4; +} + +/** Minecraft Format Colors **/ + +.format-black { + color: #000; +} + +.format-darkblue { + color: #0000AA; +} + +.format-darkgreen { + color: #00AA00; +} + +.format-darkaqua { + color: #00AAAA; +} + +.format-darkred { + color: #AA0000; +} + +.format-darkpurple { + color: #AA00AA; +} + +.format-gold { + color: #FFAA00; +} + +.format-gray { + color: #AAAAAA; +} + +.format-darkgray { + color: #555555; +} + +.format-blue { + color: #5555FF; +} + +.format-green { + color: #55FF55; +} + +.format-aqua { + color: #55FFFF; +} + +.format-red { + color: #FF5555; +} + +.format-lightpurple { + color: #FF55FF; +} + +.format-yellow { + color: #FFFF55; +} + +.format-white { + color: #FFFFFF; +} + +.format-reset { + color: #FFFFFF; + font-weight: normal; + text-decoration: none; + font-style: normal; + display: inline-block; +} + +.format-bold { + font-weight: bold; +} + +.format-underline { + text-decoration: underline; +} + +.format-italic { + font-style: italic; +} + +.format-strike { + text-decoration: line-through; +} + +/** Log Content Styles **/ + +.multiline { + padding-left: 64px; +} + +.highlight-error { + background: var(--error); + color: #fff; + padding: 0 3px; + border-radius: 2px; + font-weight: bold; + display: inline-block; +} + +.highlight-warning { + background: #FF6625; + color: var(--text); + padding: 0 3px; + border-radius: 2px; + font-weight: bold; + display: inline-block; +} + +.entry { + overflow-wrap: anywhere; +} + +@media (max-width: 800px) { + .multiline { + padding-left: 0; + } + + .problem-line { + display: none; + } +} + +/** Log bottom **/ + +.log-bottom { + display: flex; + justify-content: space-between; + align-items: center; + padding: clamp(0.75rem, 2vw, 1rem) 0; + border-bottom: 1px solid var(--border); +} + +.log-bottom .actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/** Generic Popover **/ + +.popover-wrapper { + position: relative; +} + +.popover-trigger { + cursor: pointer; +} + +.popover-trigger i { + transition: transform 0.2s ease; +} + +.popover-content { + position: fixed; + inset: unset; + margin-bottom: 0.5rem; + background-color: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem; + min-width: 200px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +.popover-content:popover-open { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.popover-content::after { + content: ''; + position: absolute; + bottom: -6px; + right: 1rem; + width: 10px; + height: 10px; + background-color: var(--bg-surface); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + transform: rotate(45deg); +} + +.popover-content::backdrop { + background: transparent; +} + +/* Popover danger variant */ +.popover-content.popover-danger { + background-color: var(--bg-surface); + border-color: var(--error); + text-align: center; +} + +.popover-content.popover-danger::after { + background-color: var(--bg-surface); + border-color: var(--error); +} + +.popover-error { + display: none; + font-weight: 600; + font-size: clamp(0.85rem, 2vw, 0.9rem); + padding: clamp(0.2rem, 2vw, 0.2rem); + color: var(--text); + background-color: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: 8px; + margin-bottom: 0.5rem; +} + +/** Settings Popover **/ + +.settings-trigger { + anchor-name: --settings-trigger; +} + +.settings-overlay { + position-anchor: --settings-trigger; + bottom: anchor(top); + right: anchor(right); +} + +/** Delete Popover **/ + +.delete-trigger { + anchor-name: --delete-trigger; +} + +.delete-trigger:hover { + opacity: 1; +} + +.delete-overlay { + position-anchor: --delete-trigger; + bottom: anchor(top); + right: anchor(right); + min-width: 250px; + padding: 1rem; + gap: 0.75rem; +} + +.delete-message { + font-size: 0.9rem; + color: var(--text); + font-weight: 500; + margin-bottom: 10px; +} + +.delete-actions { + display: flex; + gap: 0.5rem; +} + +.delete-actions .btn { + flex: 1; + justify-content: center; +} + +.setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.1s ease; +} + +.setting:hover { + background-color: var(--surface); +} + +.setting-label { + font-size: 0.9rem; + color: var(--text); +} + +.setting-checkbox { + appearance: none; + width: 2.5rem; + height: 1.4rem; + background-color: var(--surface); + border-radius: 1rem; + position: relative; + cursor: pointer; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.setting-checkbox::before { + content: ''; + position: absolute; + top: 0.2rem; + left: 0.2rem; + width: 1rem; + height: 1rem; + background-color: var(--text-muted); + border-radius: 50%; + transition: left 0.15s ease, background-color 0.15s ease; +} + +.setting-checkbox:checked { + background-color: var(--accent); +} + +.setting-checkbox:checked::before { + left: 1.3rem; + background-color: var(--bg); +} + +.log-details { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: clamp(0.75rem, 2vw, 1.25rem); + padding: clamp(0.75rem, 2vw, 1rem) 0; + border-top: 1px solid var(--border); + font-size: clamp(0.85rem, 2vw, 0.9rem); + color: var(--text-muted); +} + +.log-details:has(:nth-child(3)) { + grid-template-columns: 1fr 1fr 1fr; +} + +.log-details .meta-data { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; +} + +.log-details i { + margin-right: 0.25rem; +} + +.log-details *:nth-child(2) { + text-align: center; +} + +.log-details *:last-child { + text-align: right; +} + +@media (max-width: 640px) { + .log-details { + grid-template-columns: 1fr; + gap: 0.5rem; + justify-content: center; + } + + .log-details:has(:nth-child(3)) { + grid-template-columns: 1fr; + } + + .log-details .meta-data { + justify-content: center; + } + + .log-details *:nth-child(2), + .log-details *:last-child { + text-align: center; + } +} + +/** Error Page **/ + +.error-page { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: clamp(2rem, 5vw, 3rem) clamp(1rem, 3vw, 1.5rem); +} + +.error-code { + font-size: clamp(4rem, 15vw, 8rem); + font-weight: 600; + color: var(--text); + line-height: 1; + opacity: 0.15; +} + +.error-message { + font-size: clamp(1.25rem, 4vw, 1.8rem); + font-weight: 700; + color: var(--text); + margin-top: -0.5rem; +} + +.error-description { + font-size: clamp(0.9rem, 2vw, 1rem); + color: var(--text-muted); + margin-top: 0.75rem; + margin-bottom: clamp(1.5rem, 4vw, 2rem); +} + +/** API Documentation **/ + +.api-docs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(1rem, 3vw, 2rem); + padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem); + border-bottom: 1px solid var(--border); + background-color: var(--bg-elevated); +} + +.api-docs-header-content { + flex: 1; +} + +.api-docs-header h1 { + font-size: clamp(1.5rem, 4vw, 2rem); + font-weight: 600; + color: var(--text); + margin-bottom: 0.75rem; +} + +.api-docs-header p { + font-size: clamp(0.9rem, 2vw, 1rem); + color: var(--text-muted); + line-height: 1.6; +} + +.api-docs-header p strong { + color: var(--text); + font-weight: 600; +} + +.api-docs-toc { + padding: clamp(1rem, 2.5vw, 1.25rem) clamp(1rem, 3vw, 1.5rem); + margin-bottom: 0; + background-color: var(--bg-elevated); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: clamp(0.75rem, 2vw, 1rem); + flex-wrap: wrap; +} + +.api-docs-toc h3 { + font-size: clamp(0.85rem, 2vw, 0.95rem); + font-weight: 500; + color: var(--text-muted); + margin: 0; + white-space: nowrap; + opacity: 0.6; + pointer-events: none; + user-select: none; +} + +.api-docs-toc h3::after { + content: ':'; + margin-left: 0.25rem; + opacity: 0.4; +} + +.api-docs-toc-nav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: flex-start; +} + +.api-docs-toc-nav a { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.85rem; + color: var(--text-muted); + font-size: clamp(0.8rem, 2vw, 0.85rem); + border-radius: 6px; + transition: background-color 0.15s ease, color 0.15s ease; + text-decoration: none; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.api-docs-toc-nav a:hover { + background-color: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.api-docs-toc-nav a:active { + background-color: rgba(255, 255, 255, 0.06); +} + +.api-docs-section { + padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem); + border-bottom: 1px solid var(--border); + scroll-margin-top: 1rem; +} + +.api-docs-section:last-of-type { + border-bottom: none; +} + +.api-docs-section h2 { + font-size: clamp(1.25rem, 3vw, 1.5rem); + font-weight: 600; + color: var(--text); + margin-top: 0; + margin-bottom: 1rem; +} + +.api-docs-section p { + font-size: clamp(0.9rem, 2vw, 1rem); + color: var(--text); + line-height: 1.6; + margin-top: 0; + margin-bottom: 1.5rem; +} + +.api-docs-section p + p { + margin-top: -0.75rem; +} + +.api-docs-section p + .api-endpoint, +.api-docs-section p + .api-table, +.api-docs-section p + h3, +.api-docs-section p + h4 { + margin-top: 0; +} + +.api-docs-section h3 { + font-size: clamp(1rem, 2.5vw, 1.1rem); + font-weight: 600; + color: var(--text); + margin-top: 2rem; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.api-docs-section h2 + h3, +.api-docs-section .api-endpoint + h3, +.api-docs-section .api-table + h3, +.api-docs-section .api-note + h3 { + margin-top: 1.5rem; +} + +.api-docs-section h4 { + font-size: clamp(0.95rem, 2vw, 1rem); + font-weight: 600; + color: var(--text); + margin-top: 1.5rem; + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.api-docs-section h3 + h4 { + margin-top: 1rem; +} + +.api-endpoint { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: clamp(0.5rem, 1.5vw, 0.75rem); + padding: clamp(0.75rem, 2vw, 1rem) clamp(1rem, 2.5vw, 1.25rem); + background-color: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 8px; + margin-top: 0; + margin-bottom: 1.5rem; + font-family: var(--font-mono), monospace; + font-size: clamp(0.85rem, 2vw, 0.9rem); +} + +.api-endpoint + .api-note, +.api-endpoint + .api-table, +.api-endpoint + h3, +.api-endpoint + h4 { + margin-top: 0; +} + +.api-method { + display: inline-flex; + align-items: center; + padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.6rem, 1.5vw, 0.75rem); + background-color: var(--accent); + color: var(--bg); + font-weight: 600; + border-radius: 4px; + font-size: clamp(0.75rem, 1.8vw, 0.8rem); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.api-url { + color: var(--text); + font-weight: 500; + word-break: break-all; +} + +.content-type { + display: inline-flex; + align-items: center; + padding: clamp(0.2rem, 1vw, 0.25rem) clamp(0.6rem, 1.5vw, 0.75rem); + background-color: var(--surface); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 4px; + font-size: clamp(0.7rem, 1.8vw, 0.75rem); + font-weight: 500; + font-family: var(--font-mono), monospace; +} + +.api-table { + width: 100%; + border-collapse: collapse; + margin-top: 0; + margin-bottom: 1.5rem; + background-color: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.api-table + .api-note, +.api-table + h3, +.api-table + h4, +.api-table + .api-code { + margin-top: 0; +} + +.api-table th { + background-color: var(--surface); + padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem); + text-align: left; + font-weight: 600; + font-size: clamp(0.8rem, 2vw, 0.85rem); + color: var(--text); + border-bottom: 1px solid var(--border); +} + +.api-table td { + padding: clamp(0.6rem, 2vw, 0.75rem) clamp(0.85rem, 2.5vw, 1rem); + border-bottom: 1px solid var(--border); + font-size: clamp(0.85rem, 2vw, 0.9rem); +} + +.api-table tr:last-child td { + border-bottom: none; +} + +.api-table tr:hover { + background-color: var(--surface); +} + +.api-field { + white-space: nowrap; + font-family: var(--font-mono), monospace; + color: var(--accent); + font-weight: 500; +} + +.api-type { + font-family: var(--font-mono), monospace; + color: var(--text-muted); + font-weight: 500; +} + +.api-description { + color: var(--text); + line-height: 1.5; +} + +.api-required { + text-align: center; + font-size: 1rem; +} + +.api-required i { + color: var(--text-muted); + opacity: 0.5; +} + +.api-required.required i { + color: var(--accent); + opacity: 1; +} + +.api-code { + background-color: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 8px; + padding: clamp(1rem, 2.5vw, 1.25rem); + overflow-x: auto; + font-family: var(--font-mono), monospace; + font-size: clamp(0.8rem, 2vw, 0.85rem); + line-height: 1.6; + color: var(--text); + margin-top: 0; + margin-bottom: 1.5rem; + white-space: pre; + tab-size: 2; + font-variant-ligatures: none; +} + +.api-code + h3, +.api-code + h4, +.api-code + .api-note { + margin-top: 1.5rem; +} + +.api-note { + margin-top: 0; + margin-bottom: 1.5rem; + padding: clamp(0.75rem, 2vw, 0.85rem) clamp(0.85rem, 2.5vw, 1rem); + border-radius: 8px; + border: 1px solid var(--accent-border); + background-color: var(--accent-bg); + font-size: clamp(0.85rem, 1.8vw, 0.9rem); + color: var(--text); + line-height: 1.6; +} + +.api-note .content-type { + margin: 0 10px; + white-space: normal; + word-break: break-word; + display: inline; + vertical-align: baseline; +} + +.api-docs-notes { + display: flex; + align-items: center; + justify-content: space-between; + gap: clamp(1rem, 3vw, 2rem); + padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 3vw, 1.5rem); + background-color: var(--bg-elevated); + border: 1px solid var(--border); +} + +.api-docs-notes-content { + flex: 1; +} + +.api-docs-notes-content h2 { + font-size: clamp(1.25rem, 3vw, 1.5rem); + font-weight: 600; + color: var(--text); + margin-bottom: 0.75rem; +} + +.api-docs-notes-content p { + font-size: clamp(0.9rem, 2vw, 1rem); + color: var(--text-muted); + line-height: 1.6; + margin-bottom: 1rem; +} + +.api-docs-notes-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +@media (max-width: 1024px) { + body.setting-full-width { + --max-width-content: min(100%, calc(var(--max-width))); + } + + main { + padding: 0; + border-radius: 0; + } + + .log-body main { + border-radius: 0; + } + + .log-container, + .log-footer { + border-radius: 0; + } +} + +@media (max-width: 640px) { + .log-inner .line-number-container { + min-width: unset; + padding: 0; + } + + footer { + justify-content: center; + } + + .legal, + .footer-text { + order: 2; + } + + .footer-nav { + width: 100%; + order: 1; + justify-content: center; + } + + .problem-entry { + align-items: stretch; + } + + .problem-line { + display: none; + } + + .api-docs-header { + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + } + + .api-docs-section { + padding: 1.5rem 1rem; + } + + .api-docs-notes { + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + } + + .api-endpoint { + flex-direction: column; + align-items: flex-start; + } + + .api-docs-toc { + padding: 1rem; + } + + .api-docs-toc-nav { + gap: 0.25rem; + } + + .api-docs-toc-nav a { + padding: 0.35rem 0.7rem; + } +} diff --git a/web/public/img/favicon.ico b/web/public/img/favicon.ico new file mode 100644 index 0000000..bbdb0ee Binary files /dev/null and b/web/public/img/favicon.ico differ diff --git a/web/public/img/logo-icon.svg b/web/public/img/logo-icon.svg new file mode 100644 index 0000000..d422c82 --- /dev/null +++ b/web/public/img/logo-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/img/logo.svg b/web/public/img/logo.svg new file mode 100644 index 0000000..f423eb2 --- /dev/null +++ b/web/public/img/logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web/public/js/log.js b/web/public/js/log.js new file mode 100644 index 0000000..3eacc48 --- /dev/null +++ b/web/public/js/log.js @@ -0,0 +1,309 @@ +/* line numbers */ +updateLineNumber(location.hash); + +for (let line of document.querySelectorAll('.line-number')) { + line.addEventListener("click", () => + updateLineNumber(line.attributes.getNamedItem("id").value)); +} + +function updateLineNumber(id) { + if (id && id.startsWith('#')) { + id = id.substring(1); + } + + if (!id) { + return; + } + + let element = document.getElementById(id); + if (element.classList.contains("line-number")) { + for (const line of document.querySelectorAll(".line-active")) { + line.classList.remove("line-active"); + } + element.closest('.entry').classList.add('line-active'); + } +} + +/* Scroll to top/bottom buttons */ +const downButton = document.getElementById("down-button"); +if (downButton) { + downButton.addEventListener("click", () => scrollToHeight(document.body.scrollHeight)); +} + +const upButton = document.getElementById("up-button"); +if (upButton) { + upButton.addEventListener("click", () => scrollToHeight(0)); +} + +/** + * Scroll to a specific height + * Disable smooth scrolling for large pages + * @param {number} top height to scroll to + * @param {number} [smoothScrollLimit] only use smooth scrolling if the distance is less than this value + */ +function scrollToHeight(top, smoothScrollLimit = 10000) { + const distance = Math.abs(document.documentElement.scrollTop - top); + const behavior = (distance < smoothScrollLimit) ? "smooth" : "instant"; + window.scrollTo({left: 0, top, behavior}); +} + +/* error collapse toggle */ +const toggleErrorsButton = document.getElementById("error-toggle"); +if (toggleErrorsButton) { + toggleErrorsButton.addEventListener("click", toggleErrors); +} + +function toggleErrors() { + if (toggleErrorsButton.classList.contains("toggled")) { + toggleErrorsButton.classList.remove("toggled"); + uncollapseAllErrors(); + } else { + toggleErrorsButton.classList.add("toggled"); + collapseAllErrors(); + } +} + +function collapseAllErrors() { + let firstNoErrorLine = false; + let lines = document.querySelectorAll('.log-inner > .entry'); + let totalLines = lines.length; + for (const [i, line] of lines.entries()) { + let lineNumber = line.querySelector(".line-number").innerHTML; + if (line.classList.contains("entry-no-error")) { + line.style.display = "none"; + + if (firstNoErrorLine === false) { + firstNoErrorLine = lineNumber; + } + + if (i + 1 === totalLines && firstNoErrorLine) { + line.insertAdjacentElement("afterend", generateCollapsedLines(firstNoErrorLine, lineNumber)); + } + } else { + if (firstNoErrorLine) { + line.insertAdjacentElement("beforebegin", generateCollapsedLines(firstNoErrorLine, lineNumber - 1)); + firstNoErrorLine = false; + } + } + } +} + +function uncollapseAllErrors() { + document.querySelectorAll('.entry-no-error').forEach(line => line.style.removeProperty("display")); + document.querySelectorAll('.collapsed-lines').forEach(collapsed => collapsed.remove()); +} + +function handleCollapsedClick(e) { + let collapsed = e.currentTarget; + let positionElement = document.getElementById(`L${parseInt(collapsed.dataset.end) + 1}`); + let position; + if (positionElement) { + position = positionElement.getBoundingClientRect().top - window.scrollY; + } + for (let i = parseInt(collapsed.dataset.start); i <= parseInt(collapsed.dataset.end); i++) { + document.getElementById(`L${i}`).parentElement.parentElement.style.removeProperty("display"); + } + if (positionElement) { + window.scrollTo({ + left: 0, + top: positionElement.getBoundingClientRect().top - position - collapsed.offsetHeight, + behavior: "instant" + }); + } + collapsed.remove(); +} + +function generateCollapsedLines(start, end) { + let count = end - start + 1; + let string = count === 1 ? "line" : "lines"; + + let collapsedRow = document.createElement("div"); + collapsedRow.classList.add("collapsed-lines"); + collapsedRow.dataset.start = start; + collapsedRow.dataset.end = end; + collapsedRow.appendChild(document.createElement("div")); + collapsedRow.addEventListener("click", handleCollapsedClick); + + let collapsedLinesCount = document.createElement("div"); + collapsedLinesCount.classList.add("collapsed-lines-count"); + let icon = document.createElement("i"); + icon.classList.add("fa-solid", "fa-angle-up"); + collapsedLinesCount.appendChild(icon); + collapsedLinesCount.append(` ${count} ${string} `); + collapsedLinesCount.append(icon.cloneNode()); + collapsedRow.appendChild(collapsedLinesCount); + + return collapsedRow; +} + +/* convert timestamps */ +let timeElements = document.querySelectorAll('[data-time]'); +for (const element of timeElements) { + const timestamp = parseInt(element.dataset.time); + if (isNaN(timestamp)) { + continue; + } + const date = new Date(timestamp * 1000); + element.innerHTML = date.toLocaleString(); +} + +/* settings */ +const settingCheckboxes = document.querySelectorAll(".setting-checkbox"); +settingCheckboxes.forEach(checkbox => checkbox.addEventListener("change", handleSettingChange)); + +let settingsChannel = null; +if (typeof BroadcastChannel !== "undefined") { + settingsChannel = new BroadcastChannel("mc-logs-settings"); + settingsChannel.onmessage = (e) => { + if (e.data.type === "settings-updated") { + for (const checkbox of settingCheckboxes) { + checkbox.checked = !!e.data.settings[checkbox.dataset.key]; + applySetting(checkbox); + } + } + }; +} + +function handleSettingChange(e) { + let checkbox = e.target; + applySetting(checkbox); + saveSettings(); + if (settingsChannel) { + settingsChannel.postMessage({ + type: "settings-updated", + settings: getCurrentSettings() + }); + } +} + +function applySetting(checkbox) { + let bodyClass = checkbox.dataset.bodyClass; + if (checkbox.checked) { + document.body.classList.add(bodyClass); + } else { + document.body.classList.remove(bodyClass); + } + switch (checkbox.dataset.key) { + case "floatingScrollbar": + initFloatingScrollbar(); + break; + } +} + +function getCurrentSettings() { + const data = {}; + for (const checkbox of settingCheckboxes) { + data[checkbox.dataset.key] = checkbox.checked; + } + return data; +} + +function saveSettings() { + const data = {}; + for (const checkbox of settingCheckboxes) { + data[checkbox.dataset.key] = checkbox.checked; + } + document.cookie = "MCLOGS_SETTINGS=" + encodeURIComponent(JSON.stringify(data)) + ";path=/;expires=" + new Date(new Date().getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toUTCString(); +} + +/* copy to clipboard */ +const copyButtons = document.querySelectorAll("[data-clipboard]"); +copyButtons.forEach(button => button.addEventListener("click", handleCopyButtonClick)); +const doneClassName = "fa-solid fa-check"; + +async function handleCopyButtonClick(e) { + const button = e.currentTarget; + const data = button.dataset.clipboard; + await navigator.clipboard.writeText(data); + + const iconElement = button.querySelector("i"); + if (!iconElement) { + return; + } + const originalClassName = iconElement.className; + if (originalClassName === doneClassName) { + return; + } + iconElement.className = doneClassName; + setTimeout(() => { + iconElement.className = originalClassName; + }, 2000); +} + +/* delete button */ +const deleteButton = document.querySelector(".delete-log-button"); +const deleteErrorElement = document.querySelector(".delete-overlay .popover-error"); +if (deleteButton) { + deleteButton.addEventListener("click", handleDeleteButtonClick); +} + +async function handleDeleteButtonClick() { + deleteErrorElement.style.display = "none"; + const response = await fetch(window.location.href, { + method: "DELETE", + credentials: "include" + }); + if (!response.ok) { + deleteErrorElement.style.display = "block"; + deleteErrorElement.textContent = `${response.status} (${response.statusText})`; + return; + } + window.location.href = "/"; +} + +/* floating scroll bar */ +const browser = getComputedStyle(document.body) + .getPropertyValue("--browser") + .replaceAll(/['"]/g, '') + .trim() + .toLowerCase(); +const floatingScrollbar = document.querySelector(".floating-scrollbar"); +let logContainer = null; +if (browser === "firefox") { + logContainer = document.querySelector(".log"); +} else { + logContainer = document.querySelector(".log-inner"); +} + +if (floatingScrollbar && logContainer) { + updateFloatingScrollbarWidths(); + + floatingScrollbar.addEventListener("scroll", () => { + syncScroll(floatingScrollbar, logContainer); + }); + + logContainer.addEventListener("scroll", () => { + syncScroll(logContainer, floatingScrollbar); + }); + + const observer = new ResizeObserver(() => { + updateFloatingScrollbarWidths(); + }); + observer.observe(logContainer); +} + +function syncScroll(source, target) { + if (Math.abs(source.scrollLeft - target.scrollLeft) > 1) { + target.scrollLeft = source.scrollLeft; + } +} + +function initFloatingScrollbar() { + if (!floatingScrollbar || !logContainer) { + return; + } + updateFloatingScrollbarWidths(); + syncScroll(logContainer, floatingScrollbar); +} + +function updateFloatingScrollbarWidths() { + floatingScrollbar.style.setProperty( + "--floating-scrollbar-width", + `${logContainer.clientWidth}px` + ); + + floatingScrollbar.style.setProperty( + "--floating-scrollbar-content-width", + `${logContainer.scrollWidth}px` + ); +} diff --git a/web/public/js/start.js b/web/public/js/start.js new file mode 100644 index 0000000..3af6f87 --- /dev/null +++ b/web/public/js/start.js @@ -0,0 +1,365 @@ +/* Paste area */ +const source = document.body.dataset.name || location.host; +const pasteArea = document.getElementById('paste-text'); +const pastePlaceholder = document.querySelector('.paste-placeholder'); +const pasteSaveButtons = document.querySelectorAll('.paste-save'); +const fileSelectButton = document.getElementById('paste-select-file'); +const pasteClipboardButton = document.getElementById('paste-clipboard'); +const pasteError = document.getElementById('paste-error'); + +pasteArea.focus(); +pasteArea.addEventListener('input', reevaluateContentStatus); +pasteArea.addEventListener('paste', handlePasteEvent); +pasteSaveButtons.forEach(button => button.addEventListener('click', sendLog)); +fileSelectButton.addEventListener('click', selectLogFile); +pasteClipboardButton.addEventListener('click', pasteFromClipboard); + +reevaluateContentStatus(); + +document.addEventListener('keydown', event => { + if (event.key.toLowerCase() === 's' && (event.ctrlKey || event.metaKey)) { + void sendLog(); + event.preventDefault(); + return false; + } + + return true; +}); + +/** + * Save the log to the API + * @returns {Promise} + */ +async function sendLog() { + if (pasteArea.value === "") { + return; + } + + clearError(); + pasteSaveButtons.forEach(button => button.classList.add("btn-working")); + + try { + let log = pasteArea.value; + log = applyFilters(log); + + const bodyData = { + "content": log, + "source": source, + "metadata": Array.isArray(self.METADATA) ? self.METADATA : [] + }; + + let headers = { + "Content-Type": "application/json" + } + + let body = JSON.stringify(bodyData); + if (isGzSupported()) { + headers["Content-Encoding"] = "gzip"; + body = await packGz(body); + } + + const response = await fetch(`/new`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip" + }, + body + }); + + if (!response.ok) { + showError(`${response.status} (${response.statusText})`); + return; + } + + let data = null; + try { + data = await response.json(); + } catch (e) { + console.error("Failed to parse JSON returned by API", e); + showError("API returned invalid JSON"); + return; + } + + if (typeof data === 'object' && !data.success && data.error) { + console.error(new Error("API returned an error"), data.error); + showError(data.error); + return; + } + + if (typeof data !== 'object' || !data.success || !data.id) { + console.error(new Error("API returned an invalid response"), data); + showError("API returned an invalid response"); + return; + } + + location.href = data.url; + } catch (e) { + showError("Network error"); + } +} + +/* filters */ +function applyFilters(text) { + if (typeof FILTERS === "undefined" || !Array.isArray(FILTERS)) { + return text; + } + for (let filter of FILTERS) { + text = applyFilter(text, filter); + } + return text; +} + +function applyFilter(text, filter) { + switch (filter.type) { + case 'trim': + return text.trim(); + case 'limit-bytes': + return text.substring(0, filter.data.limit); + case 'limit-lines': + return text.split('\n').slice(0, filter.data.limit).join('\n'); + case 'regex': + try { + for (const pattern of filter.data.patterns) { + const regex = new RegExp(pattern.pattern, 'g' + pattern.modifiers.join()); + text = text.replace(regex, (match) => { + for (const exemption of filter.data.exemptions) { + if (new RegExp(exemption.pattern, exemption.modifiers.join()).test(match)) { + return match; + } + } + return pattern.replacement; + }); + } + } catch (e) { + console.error('Error applying regex filter', e); + } + return text; + default: + console.error('Unknown filter type', filter.type); + return text; + } +} + +async function pasteFromClipboard() { + try { + let content = await navigator.clipboard.readText(); + if (!content || content.trim().length === 0) { + showError("Clipboard is empty."); + return; + } + pasteArea.value = content; + reevaluateContentStatus(); + } catch (err) { + showError("Clipboard is empty or not accessible."); + } +} + +function reevaluateContentStatus() { + clearError(); + if (pasteArea.value.length > 0) { + pastePlaceholder.style.display = 'none'; + pasteSaveButtons.forEach(button => button.removeAttribute("disabled")); + } else { + pastePlaceholder.style.display = 'flex'; + pasteSaveButtons.forEach(button => button.setAttribute("disabled", "disabled")); + } +} + +function showError(message) { + pasteSaveButtons.forEach(button => button.classList.remove("btn-working")); + pasteError.innerText = message; + pasteError.style.display = 'block'; +} + +function clearError() { + pasteSaveButtons.forEach(button => button.classList.remove("btn-working")); + pasteError.innerText = ''; + pasteError.style.display = 'none'; +} + +/* File handling */ +async function handlePasteEvent(e) { + if (e.clipboardData?.files?.length > 0) { + e.preventDefault(); + await loadFileContents(e.clipboardData.files[0]); + } +} + +/** + * @param {Blob} file + * @return {Promise} + */ +function readFile(file) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + // noinspection JSCheckFunctionSignatures + reader.onload = () => resolve(new Uint8Array(reader.result)); + reader.onerror = e => reject(e); + reader.readAsArrayBuffer(file); + }); +} + +async function loadFileContents(file) { + if (file.size > 1024 * 1024 * 100) { + showError(`File is too large.`); + return; + } + let content = await readFile(file); + if (file.name.endsWith('.gz')) { + if (!isGzSupported()) { + showError(`Gzip files are not supported in this browser.`); + return; + } + content = await unpackGz(content); + } + + if (content.includes(0)) { + showError(`This file is not supported.`); + return; + } + + pasteArea.value = new TextDecoder().decode(content); + reevaluateContentStatus(); +} + +function selectLogFile() { + let input = document.createElement('input'); + input.type = 'file'; + input.style.display = 'none'; + document.body.appendChild(input); + input.onchange = async () => { + if (input.files.length) { + await loadFileContents(input.files[0]); + } + } + input.click(); + document.body.removeChild(input); +} + +/* Gzip compression */ +function isGzSupported() { + return (typeof CompressionStream !== 'undefined') && (typeof DecompressionStream !== 'undefined'); +} + +/** + * @param {string} raw + * @returns {Promise} + */ +async function packGz(raw) { + let data = new TextEncoder().encode(raw); + let inputStream = new ReadableStream({ + start: (controller) => { + controller.enqueue(data); + controller.close(); + } + }); + const cs = new CompressionStream('gzip'); + const compressedStream = inputStream.pipeThrough(cs); + return new Uint8Array(await new Response(compressedStream).arrayBuffer()); +} + +/** + * @param {Uint8Array} data + * @return {Promise} + */ +async function unpackGz(data) { + let inputStream = new ReadableStream({ + start: (controller) => { + controller.enqueue(data); + controller.close(); + } + }); + const ds = new DecompressionStream('gzip'); + const decompressedStream = inputStream.pipeThrough(ds); + return new Uint8Array(await new Response(decompressedStream).arrayBuffer()); +} + +function isDragEventValid(e) { + if (!e.dataTransfer) { + return false; + } + let types = Array.from(e.dataTransfer.types); + if (types.includes('text/uri-list')) { + return false; + } + return types.includes('Files') || types.includes('text/plain'); +} + +/* Drag and drop */ +const dropZone = document.getElementById('dropzone'); +let windowDragCount = 0; +let dropZoneDragCount = 0; + +window.addEventListener('dragover', e => e.preventDefault()); +window.addEventListener('dragenter', e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateWindowDragCount(1); + } +}); +window.addEventListener('dragleave', e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateWindowDragCount(-1); + } +}); +window.addEventListener('drop', e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateWindowDragCount(-1); + } +}); + +dropZone.addEventListener('dragenter', e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateDropZoneDragCount(1); + } +}); +dropZone.addEventListener('dragleave', e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateDropZoneDragCount(-1); + } +}); +dropZone.addEventListener('drop', async e => { + e.preventDefault(); + if (isDragEventValid(e)) { + updateDropZoneDragCount(-1); + } + await handleDropEvent(e); +}); + +function updateWindowDragCount(amount) { + windowDragCount = Math.max(0, windowDragCount + amount); + if (windowDragCount > 0) { + dropZone.classList.add('window-dragover'); + } else { + dropZone.classList.remove('window-dragover'); + } +} + +function updateDropZoneDragCount(amount) { + dropZoneDragCount = Math.max(0, dropZoneDragCount + amount); + if (dropZoneDragCount > 0) { + dropZone.classList.add('dragover'); + } else { + dropZone.classList.remove('dragover'); + } +} + +async function handleDropEvent(e) { + console.log(e.dataTransfer?.types); + let files = e.dataTransfer.files; + if (files.length !== 1) { + if (Array.from(e.dataTransfer.types).includes('text/plain')) { + pasteArea.value = e.dataTransfer.getData('text/plain'); + reevaluateContentStatus(); + } + return; + } + + await loadFileContents(files[0]); +} diff --git a/web/public/vendor/fontawesome/css/fontawesome.min.css b/web/public/vendor/fontawesome/css/fontawesome.min.css new file mode 100644 index 0000000..29fc203 --- /dev/null +++ b/web/public/vendor/fontawesome/css/fontawesome.min.css @@ -0,0 +1,12 @@ +/*! + * Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2025 Fonticons, Inc. + */ + +.fa,.fa-brands,.fa-classic,.fa-regular,.fa-solid,.fab,.far,.fas{--_fa-family:var(--fa-family,var(--fa-style-family,"Font Awesome 7 Free"));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:var(--fa-display,inline-block);font-family:var(--_fa-family);font-feature-settings:normal;font-style:normal;font-synthesis:none;font-variant:normal;font-weight:var(--fa-style,900);line-height:1;text-align:center;text-rendering:auto;width:var(--fa-width,1.25em)}:is(.fas,.far,.fab,.fa-solid,.fa-regular,.fa-brands,.fa-classic,.fa):before{content:var(--fa)/""}@supports not (content:""/""){:is(.fas,.far,.fab,.fa-solid,.fa-regular,.fa-brands,.fa-classic,.fa):before{content:var(--fa)}}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-width-auto{--fa-width:auto}.fa-fw,.fa-width-fixed{--fa-width:1.25em}.fa-ul{list-style-type:none;margin-inline-start:var(--fa-li-margin,2.5em);padding-inline-start:0}.fa-ul>li{position:relative}.fa-li{inset-inline-start:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.0625em) var(--fa-border-style,solid) var(--fa-border-color,#eee);box-sizing:var(--fa-border-box-sizing,content-box);padding:var(--fa-border-padding,.1875em .25em)}.fa-pull-left,.fa-pull-start{float:inline-start;margin-inline-end:var(--fa-pull-margin,.3em)}.fa-pull-end,.fa-pull-right{float:inline-end;margin-inline-start:var(--fa-pull-margin,.3em)}.fa-beat{animation-name:fa-beat;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{animation-name:fa-bounce;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{animation-name:fa-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{animation-name:fa-beat-fade;animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{animation-name:fa-flip;animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{animation-name:fa-shake;animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{animation-delay:var(--fa-animation-delay,0s);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{animation-name:fa-spin;animation-duration:var(--fa-animation-duration,2s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{animation-name:fa-spin;animation-direction:var(--fa-animation-direction,normal);animation-duration:var(--fa-animation-duration,1s);animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{animation:none!important;transition:none!important}}@keyframes fa-beat{0%,90%{transform:scale(1)}45%{transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-bounce{0%{transform:scale(1) translateY(0)}10%{transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{transform:scale(1) translateY(0)}to{transform:scale(1) translateY(0)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);transform:scale(1)}50%{opacity:1;transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-flip{50%{transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-shake{0%{transform:rotate(-15deg)}4%{transform:rotate(15deg)}8%,24%{transform:rotate(-18deg)}12%,28%{transform:rotate(18deg)}16%{transform:rotate(-22deg)}20%{transform:rotate(22deg)}32%{transform:rotate(-12deg)}36%{transform:rotate(12deg)}40%,to{transform:rotate(0deg)}}@keyframes fa-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{transform:rotate(90deg)}.fa-rotate-180{transform:rotate(180deg)}.fa-rotate-270{transform:rotate(270deg)}.fa-flip-horizontal{transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}.fa-rotate-by{transform:rotate(var(--fa-rotate-angle,0))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{--fa-width:100%;inset:0;position:absolute;text-align:center;width:var(--fa-width);z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} +.fa-0{--fa:"\30 "}.fa-1{--fa:"\31 "}.fa-2{--fa:"\32 "}.fa-3{--fa:"\33 "}.fa-4{--fa:"\34 "}.fa-5{--fa:"\35 "}.fa-6{--fa:"\36 "}.fa-7{--fa:"\37 "}.fa-8{--fa:"\38 "}.fa-9{--fa:"\39 "}.fa-exclamation{--fa:"\!"}.fa-hashtag{--fa:"\#"}.fa-dollar,.fa-dollar-sign,.fa-usd{--fa:"\$"}.fa-percent,.fa-percentage{--fa:"\%"}.fa-asterisk{--fa:"\*"}.fa-add,.fa-plus{--fa:"\+"}.fa-less-than{--fa:"\<"}.fa-equals{--fa:"\="}.fa-greater-than{--fa:"\>"}.fa-question{--fa:"\?"}.fa-at{--fa:"\@"}.fa-a{--fa:"A"}.fa-b{--fa:"B"}.fa-c{--fa:"C"}.fa-d{--fa:"D"}.fa-e{--fa:"E"}.fa-f{--fa:"F"}.fa-g{--fa:"G"}.fa-h{--fa:"H"}.fa-i{--fa:"I"}.fa-j{--fa:"J"}.fa-k{--fa:"K"}.fa-l{--fa:"L"}.fa-m{--fa:"M"}.fa-n{--fa:"N"}.fa-o{--fa:"O"}.fa-p{--fa:"P"}.fa-q{--fa:"Q"}.fa-r{--fa:"R"}.fa-s{--fa:"S"}.fa-t{--fa:"T"}.fa-u{--fa:"U"}.fa-v{--fa:"V"}.fa-w{--fa:"W"}.fa-x{--fa:"X"}.fa-y{--fa:"Y"}.fa-z{--fa:"Z"}.fa-faucet{--fa:"\e005"}.fa-faucet-drip{--fa:"\e006"}.fa-house-chimney-window{--fa:"\e00d"}.fa-house-signal{--fa:"\e012"}.fa-temperature-arrow-down,.fa-temperature-down{--fa:"\e03f"}.fa-temperature-arrow-up,.fa-temperature-up{--fa:"\e040"}.fa-trailer{--fa:"\e041"}.fa-bacteria{--fa:"\e059"}.fa-bacterium{--fa:"\e05a"}.fa-box-tissue{--fa:"\e05b"}.fa-hand-holding-medical{--fa:"\e05c"}.fa-hand-sparkles{--fa:"\e05d"}.fa-hands-bubbles,.fa-hands-wash{--fa:"\e05e"}.fa-handshake-alt-slash,.fa-handshake-simple-slash,.fa-handshake-slash{--fa:"\e060"}.fa-head-side-cough{--fa:"\e061"}.fa-head-side-cough-slash{--fa:"\e062"}.fa-head-side-mask{--fa:"\e063"}.fa-head-side-virus{--fa:"\e064"}.fa-house-chimney-user{--fa:"\e065"}.fa-house-laptop,.fa-laptop-house{--fa:"\e066"}.fa-lungs-virus{--fa:"\e067"}.fa-people-arrows,.fa-people-arrows-left-right{--fa:"\e068"}.fa-plane-slash{--fa:"\e069"}.fa-pump-medical{--fa:"\e06a"}.fa-pump-soap{--fa:"\e06b"}.fa-shield-virus{--fa:"\e06c"}.fa-sink{--fa:"\e06d"}.fa-soap{--fa:"\e06e"}.fa-stopwatch-20{--fa:"\e06f"}.fa-shop-slash,.fa-store-alt-slash{--fa:"\e070"}.fa-store-slash{--fa:"\e071"}.fa-toilet-paper-slash{--fa:"\e072"}.fa-users-slash{--fa:"\e073"}.fa-virus{--fa:"\e074"}.fa-virus-slash{--fa:"\e075"}.fa-viruses{--fa:"\e076"}.fa-vest{--fa:"\e085"}.fa-vest-patches{--fa:"\e086"}.fa-arrow-trend-down{--fa:"\e097"}.fa-arrow-trend-up{--fa:"\e098"}.fa-arrow-up-from-bracket{--fa:"\e09a"}.fa-austral-sign{--fa:"\e0a9"}.fa-baht-sign{--fa:"\e0ac"}.fa-bitcoin-sign{--fa:"\e0b4"}.fa-bolt-lightning{--fa:"\e0b7"}.fa-book-bookmark{--fa:"\e0bb"}.fa-camera-rotate{--fa:"\e0d8"}.fa-cedi-sign{--fa:"\e0df"}.fa-chart-column{--fa:"\e0e3"}.fa-chart-gantt{--fa:"\e0e4"}.fa-clapperboard{--fa:"\e131"}.fa-clover{--fa:"\e139"}.fa-code-compare{--fa:"\e13a"}.fa-code-fork{--fa:"\e13b"}.fa-code-pull-request{--fa:"\e13c"}.fa-colon-sign{--fa:"\e140"}.fa-cruzeiro-sign{--fa:"\e152"}.fa-display{--fa:"\e163"}.fa-dong-sign{--fa:"\e169"}.fa-elevator{--fa:"\e16d"}.fa-filter-circle-xmark{--fa:"\e17b"}.fa-florin-sign{--fa:"\e184"}.fa-folder-closed{--fa:"\e185"}.fa-franc-sign{--fa:"\e18f"}.fa-guarani-sign{--fa:"\e19a"}.fa-gun{--fa:"\e19b"}.fa-hands-clapping{--fa:"\e1a8"}.fa-home-user,.fa-house-user{--fa:"\e1b0"}.fa-indian-rupee,.fa-indian-rupee-sign,.fa-inr{--fa:"\e1bc"}.fa-kip-sign{--fa:"\e1c4"}.fa-lari-sign{--fa:"\e1c8"}.fa-litecoin-sign{--fa:"\e1d3"}.fa-manat-sign{--fa:"\e1d5"}.fa-mask-face{--fa:"\e1d7"}.fa-mill-sign{--fa:"\e1ed"}.fa-money-bills{--fa:"\e1f3"}.fa-naira-sign{--fa:"\e1f6"}.fa-notdef{--fa:"\e1fe"}.fa-panorama{--fa:"\e209"}.fa-peseta-sign{--fa:"\e221"}.fa-peso-sign{--fa:"\e222"}.fa-plane-up{--fa:"\e22d"}.fa-rupiah-sign{--fa:"\e23d"}.fa-stairs{--fa:"\e289"}.fa-timeline{--fa:"\e29c"}.fa-truck-front{--fa:"\e2b7"}.fa-try,.fa-turkish-lira,.fa-turkish-lira-sign{--fa:"\e2bb"}.fa-vault{--fa:"\e2c5"}.fa-magic-wand-sparkles,.fa-wand-magic-sparkles{--fa:"\e2ca"}.fa-wheat-alt,.fa-wheat-awn{--fa:"\e2cd"}.fa-wheelchair-alt,.fa-wheelchair-move{--fa:"\e2ce"}.fa-bangladeshi-taka-sign{--fa:"\e2e6"}.fa-bowl-rice{--fa:"\e2eb"}.fa-person-pregnant{--fa:"\e31e"}.fa-home-lg,.fa-house-chimney{--fa:"\e3af"}.fa-house-crack{--fa:"\e3b1"}.fa-house-medical{--fa:"\e3b2"}.fa-cent-sign{--fa:"\e3f5"}.fa-plus-minus{--fa:"\e43c"}.fa-sailboat{--fa:"\e445"}.fa-section{--fa:"\e447"}.fa-shrimp{--fa:"\e448"}.fa-brazilian-real-sign{--fa:"\e46c"}.fa-chart-simple{--fa:"\e473"}.fa-diagram-next{--fa:"\e476"}.fa-diagram-predecessor{--fa:"\e477"}.fa-diagram-successor{--fa:"\e47a"}.fa-earth-oceania,.fa-globe-oceania{--fa:"\e47b"}.fa-bug-slash{--fa:"\e490"}.fa-file-circle-plus{--fa:"\e494"}.fa-shop-lock{--fa:"\e4a5"}.fa-virus-covid{--fa:"\e4a8"}.fa-virus-covid-slash{--fa:"\e4a9"}.fa-anchor-circle-check{--fa:"\e4aa"}.fa-anchor-circle-exclamation{--fa:"\e4ab"}.fa-anchor-circle-xmark{--fa:"\e4ac"}.fa-anchor-lock{--fa:"\e4ad"}.fa-arrow-down-up-across-line{--fa:"\e4af"}.fa-arrow-down-up-lock{--fa:"\e4b0"}.fa-arrow-right-to-city{--fa:"\e4b3"}.fa-arrow-up-from-ground-water{--fa:"\e4b5"}.fa-arrow-up-from-water-pump{--fa:"\e4b6"}.fa-arrow-up-right-dots{--fa:"\e4b7"}.fa-arrows-down-to-line{--fa:"\e4b8"}.fa-arrows-down-to-people{--fa:"\e4b9"}.fa-arrows-left-right-to-line{--fa:"\e4ba"}.fa-arrows-spin{--fa:"\e4bb"}.fa-arrows-split-up-and-left{--fa:"\e4bc"}.fa-arrows-to-circle{--fa:"\e4bd"}.fa-arrows-to-dot{--fa:"\e4be"}.fa-arrows-to-eye{--fa:"\e4bf"}.fa-arrows-turn-right{--fa:"\e4c0"}.fa-arrows-turn-to-dots{--fa:"\e4c1"}.fa-arrows-up-to-line{--fa:"\e4c2"}.fa-bore-hole{--fa:"\e4c3"}.fa-bottle-droplet{--fa:"\e4c4"}.fa-bottle-water{--fa:"\e4c5"}.fa-bowl-food{--fa:"\e4c6"}.fa-boxes-packing{--fa:"\e4c7"}.fa-bridge{--fa:"\e4c8"}.fa-bridge-circle-check{--fa:"\e4c9"}.fa-bridge-circle-exclamation{--fa:"\e4ca"}.fa-bridge-circle-xmark{--fa:"\e4cb"}.fa-bridge-lock{--fa:"\e4cc"}.fa-bridge-water{--fa:"\e4ce"}.fa-bucket{--fa:"\e4cf"}.fa-bugs{--fa:"\e4d0"}.fa-building-circle-arrow-right{--fa:"\e4d1"}.fa-building-circle-check{--fa:"\e4d2"}.fa-building-circle-exclamation{--fa:"\e4d3"}.fa-building-circle-xmark{--fa:"\e4d4"}.fa-building-flag{--fa:"\e4d5"}.fa-building-lock{--fa:"\e4d6"}.fa-building-ngo{--fa:"\e4d7"}.fa-building-shield{--fa:"\e4d8"}.fa-building-un{--fa:"\e4d9"}.fa-building-user{--fa:"\e4da"}.fa-building-wheat{--fa:"\e4db"}.fa-burst{--fa:"\e4dc"}.fa-car-on{--fa:"\e4dd"}.fa-car-tunnel{--fa:"\e4de"}.fa-child-combatant,.fa-child-rifle{--fa:"\e4e0"}.fa-children{--fa:"\e4e1"}.fa-circle-nodes{--fa:"\e4e2"}.fa-clipboard-question{--fa:"\e4e3"}.fa-cloud-showers-water{--fa:"\e4e4"}.fa-computer{--fa:"\e4e5"}.fa-cubes-stacked{--fa:"\e4e6"}.fa-envelope-circle-check{--fa:"\e4e8"}.fa-explosion{--fa:"\e4e9"}.fa-ferry{--fa:"\e4ea"}.fa-file-circle-exclamation{--fa:"\e4eb"}.fa-file-circle-minus{--fa:"\e4ed"}.fa-file-circle-question{--fa:"\e4ef"}.fa-file-shield{--fa:"\e4f0"}.fa-fire-burner{--fa:"\e4f1"}.fa-fish-fins{--fa:"\e4f2"}.fa-flask-vial{--fa:"\e4f3"}.fa-glass-water{--fa:"\e4f4"}.fa-glass-water-droplet{--fa:"\e4f5"}.fa-group-arrows-rotate{--fa:"\e4f6"}.fa-hand-holding-hand{--fa:"\e4f7"}.fa-handcuffs{--fa:"\e4f8"}.fa-hands-bound{--fa:"\e4f9"}.fa-hands-holding-child{--fa:"\e4fa"}.fa-hands-holding-circle{--fa:"\e4fb"}.fa-heart-circle-bolt{--fa:"\e4fc"}.fa-heart-circle-check{--fa:"\e4fd"}.fa-heart-circle-exclamation{--fa:"\e4fe"}.fa-heart-circle-minus{--fa:"\e4ff"}.fa-heart-circle-plus{--fa:"\e500"}.fa-heart-circle-xmark{--fa:"\e501"}.fa-helicopter-symbol{--fa:"\e502"}.fa-helmet-un{--fa:"\e503"}.fa-hill-avalanche{--fa:"\e507"}.fa-hill-rockslide{--fa:"\e508"}.fa-house-circle-check{--fa:"\e509"}.fa-house-circle-exclamation{--fa:"\e50a"}.fa-house-circle-xmark{--fa:"\e50b"}.fa-house-fire{--fa:"\e50c"}.fa-house-flag{--fa:"\e50d"}.fa-house-flood-water{--fa:"\e50e"}.fa-house-flood-water-circle-arrow-right{--fa:"\e50f"}.fa-house-lock{--fa:"\e510"}.fa-house-medical-circle-check{--fa:"\e511"}.fa-house-medical-circle-exclamation{--fa:"\e512"}.fa-house-medical-circle-xmark{--fa:"\e513"}.fa-house-medical-flag{--fa:"\e514"}.fa-house-tsunami{--fa:"\e515"}.fa-jar{--fa:"\e516"}.fa-jar-wheat{--fa:"\e517"}.fa-jet-fighter-up{--fa:"\e518"}.fa-jug-detergent{--fa:"\e519"}.fa-kitchen-set{--fa:"\e51a"}.fa-land-mine-on{--fa:"\e51b"}.fa-landmark-flag{--fa:"\e51c"}.fa-laptop-file{--fa:"\e51d"}.fa-lines-leaning{--fa:"\e51e"}.fa-location-pin-lock{--fa:"\e51f"}.fa-locust{--fa:"\e520"}.fa-magnifying-glass-arrow-right{--fa:"\e521"}.fa-magnifying-glass-chart{--fa:"\e522"}.fa-mars-and-venus-burst{--fa:"\e523"}.fa-mask-ventilator{--fa:"\e524"}.fa-mattress-pillow{--fa:"\e525"}.fa-mobile-retro{--fa:"\e527"}.fa-money-bill-transfer{--fa:"\e528"}.fa-money-bill-trend-up{--fa:"\e529"}.fa-money-bill-wheat{--fa:"\e52a"}.fa-mosquito{--fa:"\e52b"}.fa-mosquito-net{--fa:"\e52c"}.fa-mound{--fa:"\e52d"}.fa-mountain-city{--fa:"\e52e"}.fa-mountain-sun{--fa:"\e52f"}.fa-oil-well{--fa:"\e532"}.fa-people-group{--fa:"\e533"}.fa-people-line{--fa:"\e534"}.fa-people-pulling{--fa:"\e535"}.fa-people-robbery{--fa:"\e536"}.fa-people-roof{--fa:"\e537"}.fa-person-arrow-down-to-line{--fa:"\e538"}.fa-person-arrow-up-from-line{--fa:"\e539"}.fa-person-breastfeeding{--fa:"\e53a"}.fa-person-burst{--fa:"\e53b"}.fa-person-cane{--fa:"\e53c"}.fa-person-chalkboard{--fa:"\e53d"}.fa-person-circle-check{--fa:"\e53e"}.fa-person-circle-exclamation{--fa:"\e53f"}.fa-person-circle-minus{--fa:"\e540"}.fa-person-circle-plus{--fa:"\e541"}.fa-person-circle-question{--fa:"\e542"}.fa-person-circle-xmark{--fa:"\e543"}.fa-person-dress-burst{--fa:"\e544"}.fa-person-drowning{--fa:"\e545"}.fa-person-falling{--fa:"\e546"}.fa-person-falling-burst{--fa:"\e547"}.fa-person-half-dress{--fa:"\e548"}.fa-person-harassing{--fa:"\e549"}.fa-person-military-pointing{--fa:"\e54a"}.fa-person-military-rifle{--fa:"\e54b"}.fa-person-military-to-person{--fa:"\e54c"}.fa-person-rays{--fa:"\e54d"}.fa-person-rifle{--fa:"\e54e"}.fa-person-shelter{--fa:"\e54f"}.fa-person-walking-arrow-loop-left{--fa:"\e551"}.fa-person-walking-arrow-right{--fa:"\e552"}.fa-person-walking-dashed-line-arrow-right{--fa:"\e553"}.fa-person-walking-luggage{--fa:"\e554"}.fa-plane-circle-check{--fa:"\e555"}.fa-plane-circle-exclamation{--fa:"\e556"}.fa-plane-circle-xmark{--fa:"\e557"}.fa-plane-lock{--fa:"\e558"}.fa-plate-wheat{--fa:"\e55a"}.fa-plug-circle-bolt{--fa:"\e55b"}.fa-plug-circle-check{--fa:"\e55c"}.fa-plug-circle-exclamation{--fa:"\e55d"}.fa-plug-circle-minus{--fa:"\e55e"}.fa-plug-circle-plus{--fa:"\e55f"}.fa-plug-circle-xmark{--fa:"\e560"}.fa-ranking-star{--fa:"\e561"}.fa-road-barrier{--fa:"\e562"}.fa-road-bridge{--fa:"\e563"}.fa-road-circle-check{--fa:"\e564"}.fa-road-circle-exclamation{--fa:"\e565"}.fa-road-circle-xmark{--fa:"\e566"}.fa-road-lock{--fa:"\e567"}.fa-road-spikes{--fa:"\e568"}.fa-rug{--fa:"\e569"}.fa-sack-xmark{--fa:"\e56a"}.fa-school-circle-check{--fa:"\e56b"}.fa-school-circle-exclamation{--fa:"\e56c"}.fa-school-circle-xmark{--fa:"\e56d"}.fa-school-flag{--fa:"\e56e"}.fa-school-lock{--fa:"\e56f"}.fa-sheet-plastic{--fa:"\e571"}.fa-shield-cat{--fa:"\e572"}.fa-shield-dog{--fa:"\e573"}.fa-shield-heart{--fa:"\e574"}.fa-square-nfi{--fa:"\e576"}.fa-square-person-confined{--fa:"\e577"}.fa-square-virus{--fa:"\e578"}.fa-rod-asclepius,.fa-rod-snake,.fa-staff-aesculapius,.fa-staff-snake{--fa:"\e579"}.fa-sun-plant-wilt{--fa:"\e57a"}.fa-tarp{--fa:"\e57b"}.fa-tarp-droplet{--fa:"\e57c"}.fa-tent{--fa:"\e57d"}.fa-tent-arrow-down-to-line{--fa:"\e57e"}.fa-tent-arrow-left-right{--fa:"\e57f"}.fa-tent-arrow-turn-left{--fa:"\e580"}.fa-tent-arrows-down{--fa:"\e581"}.fa-tents{--fa:"\e582"}.fa-toilet-portable{--fa:"\e583"}.fa-toilets-portable{--fa:"\e584"}.fa-tower-cell{--fa:"\e585"}.fa-tower-observation{--fa:"\e586"}.fa-tree-city{--fa:"\e587"}.fa-trowel{--fa:"\e589"}.fa-trowel-bricks{--fa:"\e58a"}.fa-truck-arrow-right{--fa:"\e58b"}.fa-truck-droplet{--fa:"\e58c"}.fa-truck-field{--fa:"\e58d"}.fa-truck-field-un{--fa:"\e58e"}.fa-truck-plane{--fa:"\e58f"}.fa-users-between-lines{--fa:"\e591"}.fa-users-line{--fa:"\e592"}.fa-users-rays{--fa:"\e593"}.fa-users-rectangle{--fa:"\e594"}.fa-users-viewfinder{--fa:"\e595"}.fa-vial-circle-check{--fa:"\e596"}.fa-vial-virus{--fa:"\e597"}.fa-wheat-awn-circle-exclamation{--fa:"\e598"}.fa-worm{--fa:"\e599"}.fa-xmarks-lines{--fa:"\e59a"}.fa-child-dress{--fa:"\e59c"}.fa-child-reaching{--fa:"\e59d"}.fa-file-circle-check{--fa:"\e5a0"}.fa-file-circle-xmark{--fa:"\e5a1"}.fa-person-through-window{--fa:"\e5a9"}.fa-plant-wilt{--fa:"\e5aa"}.fa-stapler{--fa:"\e5af"}.fa-train-tram{--fa:"\e5b4"}.fa-table-cells-column-lock{--fa:"\e678"}.fa-table-cells-row-lock{--fa:"\e67a"}.fa-web-awesome{--fa:"\e682"}.fa-thumb-tack-slash,.fa-thumbtack-slash{--fa:"\e68f"}.fa-table-cells-row-unlock{--fa:"\e691"}.fa-chart-diagram{--fa:"\e695"}.fa-comment-nodes{--fa:"\e696"}.fa-file-fragment{--fa:"\e697"}.fa-file-half-dashed{--fa:"\e698"}.fa-hexagon-nodes{--fa:"\e699"}.fa-hexagon-nodes-bolt{--fa:"\e69a"}.fa-square-binary{--fa:"\e69b"}.fa-pentagon{--fa:"\e790"}.fa-non-binary{--fa:"\e807"}.fa-spiral{--fa:"\e80a"}.fa-mobile-vibrate{--fa:"\e816"}.fa-single-quote-left{--fa:"\e81b"}.fa-single-quote-right{--fa:"\e81c"}.fa-bus-side{--fa:"\e81d"}.fa-heptagon,.fa-septagon{--fa:"\e820"}.fa-glass-martini,.fa-martini-glass-empty{--fa:"\f000"}.fa-music{--fa:"\f001"}.fa-magnifying-glass,.fa-search{--fa:"\f002"}.fa-heart{--fa:"\f004"}.fa-star{--fa:"\f005"}.fa-user,.fa-user-alt,.fa-user-large{--fa:"\f007"}.fa-film,.fa-film-alt,.fa-film-simple{--fa:"\f008"}.fa-table-cells-large,.fa-th-large{--fa:"\f009"}.fa-table-cells,.fa-th{--fa:"\f00a"}.fa-table-list,.fa-th-list{--fa:"\f00b"}.fa-check{--fa:"\f00c"}.fa-close,.fa-multiply,.fa-remove,.fa-times,.fa-xmark{--fa:"\f00d"}.fa-magnifying-glass-plus,.fa-search-plus{--fa:"\f00e"}.fa-magnifying-glass-minus,.fa-search-minus{--fa:"\f010"}.fa-power-off{--fa:"\f011"}.fa-signal,.fa-signal-5,.fa-signal-perfect{--fa:"\f012"}.fa-cog,.fa-gear{--fa:"\f013"}.fa-home,.fa-home-alt,.fa-home-lg-alt,.fa-house{--fa:"\f015"}.fa-clock,.fa-clock-four{--fa:"\f017"}.fa-road{--fa:"\f018"}.fa-download{--fa:"\f019"}.fa-inbox{--fa:"\f01c"}.fa-arrow-right-rotate,.fa-arrow-rotate-forward,.fa-arrow-rotate-right,.fa-redo{--fa:"\f01e"}.fa-arrows-rotate,.fa-refresh,.fa-sync{--fa:"\f021"}.fa-list-alt,.fa-rectangle-list{--fa:"\f022"}.fa-lock{--fa:"\f023"}.fa-flag{--fa:"\f024"}.fa-headphones,.fa-headphones-alt,.fa-headphones-simple{--fa:"\f025"}.fa-volume-off{--fa:"\f026"}.fa-volume-down,.fa-volume-low{--fa:"\f027"}.fa-volume-high,.fa-volume-up{--fa:"\f028"}.fa-qrcode{--fa:"\f029"}.fa-barcode{--fa:"\f02a"}.fa-tag{--fa:"\f02b"}.fa-tags{--fa:"\f02c"}.fa-book{--fa:"\f02d"}.fa-bookmark{--fa:"\f02e"}.fa-print{--fa:"\f02f"}.fa-camera,.fa-camera-alt{--fa:"\f030"}.fa-font{--fa:"\f031"}.fa-bold{--fa:"\f032"}.fa-italic{--fa:"\f033"}.fa-text-height{--fa:"\f034"}.fa-text-width{--fa:"\f035"}.fa-align-left{--fa:"\f036"}.fa-align-center{--fa:"\f037"}.fa-align-right{--fa:"\f038"}.fa-align-justify{--fa:"\f039"}.fa-list,.fa-list-squares{--fa:"\f03a"}.fa-dedent,.fa-outdent{--fa:"\f03b"}.fa-indent{--fa:"\f03c"}.fa-video,.fa-video-camera{--fa:"\f03d"}.fa-image{--fa:"\f03e"}.fa-location-pin,.fa-map-marker{--fa:"\f041"}.fa-adjust,.fa-circle-half-stroke{--fa:"\f042"}.fa-droplet,.fa-tint{--fa:"\f043"}.fa-edit,.fa-pen-to-square{--fa:"\f044"}.fa-arrows,.fa-arrows-up-down-left-right{--fa:"\f047"}.fa-backward-step,.fa-step-backward{--fa:"\f048"}.fa-backward-fast,.fa-fast-backward{--fa:"\f049"}.fa-backward{--fa:"\f04a"}.fa-play{--fa:"\f04b"}.fa-pause{--fa:"\f04c"}.fa-stop{--fa:"\f04d"}.fa-forward{--fa:"\f04e"}.fa-fast-forward,.fa-forward-fast{--fa:"\f050"}.fa-forward-step,.fa-step-forward{--fa:"\f051"}.fa-eject{--fa:"\f052"}.fa-chevron-left{--fa:"\f053"}.fa-chevron-right{--fa:"\f054"}.fa-circle-plus,.fa-plus-circle{--fa:"\f055"}.fa-circle-minus,.fa-minus-circle{--fa:"\f056"}.fa-circle-xmark,.fa-times-circle,.fa-xmark-circle{--fa:"\f057"}.fa-check-circle,.fa-circle-check{--fa:"\f058"}.fa-circle-question,.fa-question-circle{--fa:"\f059"}.fa-circle-info,.fa-info-circle{--fa:"\f05a"}.fa-crosshairs{--fa:"\f05b"}.fa-ban,.fa-cancel{--fa:"\f05e"}.fa-arrow-left{--fa:"\f060"}.fa-arrow-right{--fa:"\f061"}.fa-arrow-up{--fa:"\f062"}.fa-arrow-down{--fa:"\f063"}.fa-mail-forward,.fa-share{--fa:"\f064"}.fa-expand{--fa:"\f065"}.fa-compress{--fa:"\f066"}.fa-minus,.fa-subtract{--fa:"\f068"}.fa-circle-exclamation,.fa-exclamation-circle{--fa:"\f06a"}.fa-gift{--fa:"\f06b"}.fa-leaf{--fa:"\f06c"}.fa-fire{--fa:"\f06d"}.fa-eye{--fa:"\f06e"}.fa-eye-slash{--fa:"\f070"}.fa-exclamation-triangle,.fa-triangle-exclamation,.fa-warning{--fa:"\f071"}.fa-plane{--fa:"\f072"}.fa-calendar-alt,.fa-calendar-days{--fa:"\f073"}.fa-random,.fa-shuffle{--fa:"\f074"}.fa-comment{--fa:"\f075"}.fa-magnet{--fa:"\f076"}.fa-chevron-up{--fa:"\f077"}.fa-chevron-down{--fa:"\f078"}.fa-retweet{--fa:"\f079"}.fa-cart-shopping,.fa-shopping-cart{--fa:"\f07a"}.fa-folder,.fa-folder-blank{--fa:"\f07b"}.fa-folder-open{--fa:"\f07c"}.fa-arrows-up-down,.fa-arrows-v{--fa:"\f07d"}.fa-arrows-h,.fa-arrows-left-right{--fa:"\f07e"}.fa-bar-chart,.fa-chart-bar{--fa:"\f080"}.fa-camera-retro{--fa:"\f083"}.fa-key{--fa:"\f084"}.fa-cogs,.fa-gears{--fa:"\f085"}.fa-comments{--fa:"\f086"}.fa-star-half{--fa:"\f089"}.fa-arrow-right-from-bracket,.fa-sign-out{--fa:"\f08b"}.fa-thumb-tack,.fa-thumbtack{--fa:"\f08d"}.fa-arrow-up-right-from-square,.fa-external-link{--fa:"\f08e"}.fa-arrow-right-to-bracket,.fa-sign-in{--fa:"\f090"}.fa-trophy{--fa:"\f091"}.fa-upload{--fa:"\f093"}.fa-lemon{--fa:"\f094"}.fa-phone{--fa:"\f095"}.fa-phone-square,.fa-square-phone{--fa:"\f098"}.fa-unlock{--fa:"\f09c"}.fa-credit-card,.fa-credit-card-alt{--fa:"\f09d"}.fa-feed,.fa-rss{--fa:"\f09e"}.fa-hard-drive,.fa-hdd{--fa:"\f0a0"}.fa-bullhorn{--fa:"\f0a1"}.fa-certificate{--fa:"\f0a3"}.fa-hand-point-right{--fa:"\f0a4"}.fa-hand-point-left{--fa:"\f0a5"}.fa-hand-point-up{--fa:"\f0a6"}.fa-hand-point-down{--fa:"\f0a7"}.fa-arrow-circle-left,.fa-circle-arrow-left{--fa:"\f0a8"}.fa-arrow-circle-right,.fa-circle-arrow-right{--fa:"\f0a9"}.fa-arrow-circle-up,.fa-circle-arrow-up{--fa:"\f0aa"}.fa-arrow-circle-down,.fa-circle-arrow-down{--fa:"\f0ab"}.fa-globe{--fa:"\f0ac"}.fa-wrench{--fa:"\f0ad"}.fa-list-check,.fa-tasks{--fa:"\f0ae"}.fa-filter{--fa:"\f0b0"}.fa-briefcase{--fa:"\f0b1"}.fa-arrows-alt,.fa-up-down-left-right{--fa:"\f0b2"}.fa-users{--fa:"\f0c0"}.fa-chain,.fa-link{--fa:"\f0c1"}.fa-cloud{--fa:"\f0c2"}.fa-flask{--fa:"\f0c3"}.fa-cut,.fa-scissors{--fa:"\f0c4"}.fa-copy{--fa:"\f0c5"}.fa-paperclip{--fa:"\f0c6"}.fa-floppy-disk,.fa-save{--fa:"\f0c7"}.fa-square{--fa:"\f0c8"}.fa-bars,.fa-navicon{--fa:"\f0c9"}.fa-list-dots,.fa-list-ul{--fa:"\f0ca"}.fa-list-1-2,.fa-list-numeric,.fa-list-ol{--fa:"\f0cb"}.fa-strikethrough{--fa:"\f0cc"}.fa-underline{--fa:"\f0cd"}.fa-table{--fa:"\f0ce"}.fa-magic,.fa-wand-magic{--fa:"\f0d0"}.fa-truck{--fa:"\f0d1"}.fa-money-bill{--fa:"\f0d6"}.fa-caret-down{--fa:"\f0d7"}.fa-caret-up{--fa:"\f0d8"}.fa-caret-left{--fa:"\f0d9"}.fa-caret-right{--fa:"\f0da"}.fa-columns,.fa-table-columns{--fa:"\f0db"}.fa-sort,.fa-unsorted{--fa:"\f0dc"}.fa-sort-desc,.fa-sort-down{--fa:"\f0dd"}.fa-sort-asc,.fa-sort-up{--fa:"\f0de"}.fa-envelope{--fa:"\f0e0"}.fa-arrow-left-rotate,.fa-arrow-rotate-back,.fa-arrow-rotate-backward,.fa-arrow-rotate-left,.fa-undo{--fa:"\f0e2"}.fa-gavel,.fa-legal{--fa:"\f0e3"}.fa-bolt,.fa-zap{--fa:"\f0e7"}.fa-sitemap{--fa:"\f0e8"}.fa-umbrella{--fa:"\f0e9"}.fa-file-clipboard,.fa-paste{--fa:"\f0ea"}.fa-lightbulb{--fa:"\f0eb"}.fa-arrow-right-arrow-left,.fa-exchange{--fa:"\f0ec"}.fa-cloud-arrow-down,.fa-cloud-download,.fa-cloud-download-alt{--fa:"\f0ed"}.fa-cloud-arrow-up,.fa-cloud-upload,.fa-cloud-upload-alt{--fa:"\f0ee"}.fa-user-doctor,.fa-user-md{--fa:"\f0f0"}.fa-stethoscope{--fa:"\f0f1"}.fa-suitcase{--fa:"\f0f2"}.fa-bell{--fa:"\f0f3"}.fa-coffee,.fa-mug-saucer{--fa:"\f0f4"}.fa-hospital,.fa-hospital-alt,.fa-hospital-wide{--fa:"\f0f8"}.fa-ambulance,.fa-truck-medical{--fa:"\f0f9"}.fa-medkit,.fa-suitcase-medical{--fa:"\f0fa"}.fa-fighter-jet,.fa-jet-fighter{--fa:"\f0fb"}.fa-beer,.fa-beer-mug-empty{--fa:"\f0fc"}.fa-h-square,.fa-square-h{--fa:"\f0fd"}.fa-plus-square,.fa-square-plus{--fa:"\f0fe"}.fa-angle-double-left,.fa-angles-left{--fa:"\f100"}.fa-angle-double-right,.fa-angles-right{--fa:"\f101"}.fa-angle-double-up,.fa-angles-up{--fa:"\f102"}.fa-angle-double-down,.fa-angles-down{--fa:"\f103"}.fa-angle-left{--fa:"\f104"}.fa-angle-right{--fa:"\f105"}.fa-angle-up{--fa:"\f106"}.fa-angle-down{--fa:"\f107"}.fa-laptop{--fa:"\f109"}.fa-tablet-button{--fa:"\f10a"}.fa-mobile-button{--fa:"\f10b"}.fa-quote-left,.fa-quote-left-alt{--fa:"\f10d"}.fa-quote-right,.fa-quote-right-alt{--fa:"\f10e"}.fa-spinner{--fa:"\f110"}.fa-circle{--fa:"\f111"}.fa-face-smile,.fa-smile{--fa:"\f118"}.fa-face-frown,.fa-frown{--fa:"\f119"}.fa-face-meh,.fa-meh{--fa:"\f11a"}.fa-gamepad{--fa:"\f11b"}.fa-keyboard{--fa:"\f11c"}.fa-flag-checkered{--fa:"\f11e"}.fa-terminal{--fa:"\f120"}.fa-code{--fa:"\f121"}.fa-mail-reply-all,.fa-reply-all{--fa:"\f122"}.fa-location-arrow{--fa:"\f124"}.fa-crop{--fa:"\f125"}.fa-code-branch{--fa:"\f126"}.fa-chain-broken,.fa-chain-slash,.fa-link-slash,.fa-unlink{--fa:"\f127"}.fa-info{--fa:"\f129"}.fa-superscript{--fa:"\f12b"}.fa-subscript{--fa:"\f12c"}.fa-eraser{--fa:"\f12d"}.fa-puzzle-piece{--fa:"\f12e"}.fa-microphone{--fa:"\f130"}.fa-microphone-slash{--fa:"\f131"}.fa-shield,.fa-shield-blank{--fa:"\f132"}.fa-calendar{--fa:"\f133"}.fa-fire-extinguisher{--fa:"\f134"}.fa-rocket{--fa:"\f135"}.fa-chevron-circle-left,.fa-circle-chevron-left{--fa:"\f137"}.fa-chevron-circle-right,.fa-circle-chevron-right{--fa:"\f138"}.fa-chevron-circle-up,.fa-circle-chevron-up{--fa:"\f139"}.fa-chevron-circle-down,.fa-circle-chevron-down{--fa:"\f13a"}.fa-anchor{--fa:"\f13d"}.fa-unlock-alt,.fa-unlock-keyhole{--fa:"\f13e"}.fa-bullseye{--fa:"\f140"}.fa-ellipsis,.fa-ellipsis-h{--fa:"\f141"}.fa-ellipsis-v,.fa-ellipsis-vertical{--fa:"\f142"}.fa-rss-square,.fa-square-rss{--fa:"\f143"}.fa-circle-play,.fa-play-circle{--fa:"\f144"}.fa-ticket{--fa:"\f145"}.fa-minus-square,.fa-square-minus{--fa:"\f146"}.fa-arrow-turn-up,.fa-level-up{--fa:"\f148"}.fa-arrow-turn-down,.fa-level-down{--fa:"\f149"}.fa-check-square,.fa-square-check{--fa:"\f14a"}.fa-pen-square,.fa-pencil-square,.fa-square-pen{--fa:"\f14b"}.fa-external-link-square,.fa-square-arrow-up-right{--fa:"\f14c"}.fa-share-from-square,.fa-share-square{--fa:"\f14d"}.fa-compass{--fa:"\f14e"}.fa-caret-square-down,.fa-square-caret-down{--fa:"\f150"}.fa-caret-square-up,.fa-square-caret-up{--fa:"\f151"}.fa-caret-square-right,.fa-square-caret-right{--fa:"\f152"}.fa-eur,.fa-euro,.fa-euro-sign{--fa:"\f153"}.fa-gbp,.fa-pound-sign,.fa-sterling-sign{--fa:"\f154"}.fa-rupee,.fa-rupee-sign{--fa:"\f156"}.fa-cny,.fa-jpy,.fa-rmb,.fa-yen,.fa-yen-sign{--fa:"\f157"}.fa-rouble,.fa-rub,.fa-ruble,.fa-ruble-sign{--fa:"\f158"}.fa-krw,.fa-won,.fa-won-sign{--fa:"\f159"}.fa-file{--fa:"\f15b"}.fa-file-alt,.fa-file-lines,.fa-file-text{--fa:"\f15c"}.fa-arrow-down-a-z,.fa-sort-alpha-asc,.fa-sort-alpha-down{--fa:"\f15d"}.fa-arrow-up-a-z,.fa-sort-alpha-up{--fa:"\f15e"}.fa-arrow-down-wide-short,.fa-sort-amount-asc,.fa-sort-amount-down{--fa:"\f160"}.fa-arrow-up-wide-short,.fa-sort-amount-up{--fa:"\f161"}.fa-arrow-down-1-9,.fa-sort-numeric-asc,.fa-sort-numeric-down{--fa:"\f162"}.fa-arrow-up-1-9,.fa-sort-numeric-up{--fa:"\f163"}.fa-thumbs-up{--fa:"\f164"}.fa-thumbs-down{--fa:"\f165"}.fa-arrow-down-long,.fa-long-arrow-down{--fa:"\f175"}.fa-arrow-up-long,.fa-long-arrow-up{--fa:"\f176"}.fa-arrow-left-long,.fa-long-arrow-left{--fa:"\f177"}.fa-arrow-right-long,.fa-long-arrow-right{--fa:"\f178"}.fa-female,.fa-person-dress{--fa:"\f182"}.fa-male,.fa-person{--fa:"\f183"}.fa-sun{--fa:"\f185"}.fa-moon{--fa:"\f186"}.fa-archive,.fa-box-archive{--fa:"\f187"}.fa-bug{--fa:"\f188"}.fa-caret-square-left,.fa-square-caret-left{--fa:"\f191"}.fa-circle-dot,.fa-dot-circle{--fa:"\f192"}.fa-wheelchair{--fa:"\f193"}.fa-lira-sign{--fa:"\f195"}.fa-shuttle-space,.fa-space-shuttle{--fa:"\f197"}.fa-envelope-square,.fa-square-envelope{--fa:"\f199"}.fa-bank,.fa-building-columns,.fa-institution,.fa-museum,.fa-university{--fa:"\f19c"}.fa-graduation-cap,.fa-mortar-board{--fa:"\f19d"}.fa-language{--fa:"\f1ab"}.fa-fax{--fa:"\f1ac"}.fa-building{--fa:"\f1ad"}.fa-child{--fa:"\f1ae"}.fa-paw{--fa:"\f1b0"}.fa-cube{--fa:"\f1b2"}.fa-cubes{--fa:"\f1b3"}.fa-recycle{--fa:"\f1b8"}.fa-automobile,.fa-car{--fa:"\f1b9"}.fa-cab,.fa-taxi{--fa:"\f1ba"}.fa-tree{--fa:"\f1bb"}.fa-database{--fa:"\f1c0"}.fa-file-pdf{--fa:"\f1c1"}.fa-file-word{--fa:"\f1c2"}.fa-file-excel{--fa:"\f1c3"}.fa-file-powerpoint{--fa:"\f1c4"}.fa-file-image{--fa:"\f1c5"}.fa-file-archive,.fa-file-zipper{--fa:"\f1c6"}.fa-file-audio{--fa:"\f1c7"}.fa-file-video{--fa:"\f1c8"}.fa-file-code{--fa:"\f1c9"}.fa-life-ring{--fa:"\f1cd"}.fa-circle-notch{--fa:"\f1ce"}.fa-paper-plane{--fa:"\f1d8"}.fa-clock-rotate-left,.fa-history{--fa:"\f1da"}.fa-header,.fa-heading{--fa:"\f1dc"}.fa-paragraph{--fa:"\f1dd"}.fa-sliders,.fa-sliders-h{--fa:"\f1de"}.fa-share-alt,.fa-share-nodes{--fa:"\f1e0"}.fa-share-alt-square,.fa-square-share-nodes{--fa:"\f1e1"}.fa-bomb{--fa:"\f1e2"}.fa-futbol,.fa-futbol-ball,.fa-soccer-ball{--fa:"\f1e3"}.fa-teletype,.fa-tty{--fa:"\f1e4"}.fa-binoculars{--fa:"\f1e5"}.fa-plug{--fa:"\f1e6"}.fa-newspaper{--fa:"\f1ea"}.fa-wifi,.fa-wifi-3,.fa-wifi-strong{--fa:"\f1eb"}.fa-calculator{--fa:"\f1ec"}.fa-bell-slash{--fa:"\f1f6"}.fa-trash{--fa:"\f1f8"}.fa-copyright{--fa:"\f1f9"}.fa-eye-dropper,.fa-eye-dropper-empty,.fa-eyedropper{--fa:"\f1fb"}.fa-paint-brush,.fa-paintbrush{--fa:"\f1fc"}.fa-birthday-cake,.fa-cake,.fa-cake-candles{--fa:"\f1fd"}.fa-area-chart,.fa-chart-area{--fa:"\f1fe"}.fa-chart-pie,.fa-pie-chart{--fa:"\f200"}.fa-chart-line,.fa-line-chart{--fa:"\f201"}.fa-toggle-off{--fa:"\f204"}.fa-toggle-on{--fa:"\f205"}.fa-bicycle{--fa:"\f206"}.fa-bus{--fa:"\f207"}.fa-closed-captioning{--fa:"\f20a"}.fa-ils,.fa-shekel,.fa-shekel-sign,.fa-sheqel,.fa-sheqel-sign{--fa:"\f20b"}.fa-cart-plus{--fa:"\f217"}.fa-cart-arrow-down{--fa:"\f218"}.fa-diamond{--fa:"\f219"}.fa-ship{--fa:"\f21a"}.fa-user-secret{--fa:"\f21b"}.fa-motorcycle{--fa:"\f21c"}.fa-street-view{--fa:"\f21d"}.fa-heart-pulse,.fa-heartbeat{--fa:"\f21e"}.fa-venus{--fa:"\f221"}.fa-mars{--fa:"\f222"}.fa-mercury{--fa:"\f223"}.fa-mars-and-venus{--fa:"\f224"}.fa-transgender,.fa-transgender-alt{--fa:"\f225"}.fa-venus-double{--fa:"\f226"}.fa-mars-double{--fa:"\f227"}.fa-venus-mars{--fa:"\f228"}.fa-mars-stroke{--fa:"\f229"}.fa-mars-stroke-up,.fa-mars-stroke-v{--fa:"\f22a"}.fa-mars-stroke-h,.fa-mars-stroke-right{--fa:"\f22b"}.fa-neuter{--fa:"\f22c"}.fa-genderless{--fa:"\f22d"}.fa-server{--fa:"\f233"}.fa-user-plus{--fa:"\f234"}.fa-user-times,.fa-user-xmark{--fa:"\f235"}.fa-bed{--fa:"\f236"}.fa-train{--fa:"\f238"}.fa-subway,.fa-train-subway{--fa:"\f239"}.fa-battery,.fa-battery-5,.fa-battery-full{--fa:"\f240"}.fa-battery-4,.fa-battery-three-quarters{--fa:"\f241"}.fa-battery-3,.fa-battery-half{--fa:"\f242"}.fa-battery-2,.fa-battery-quarter{--fa:"\f243"}.fa-battery-0,.fa-battery-empty{--fa:"\f244"}.fa-arrow-pointer,.fa-mouse-pointer{--fa:"\f245"}.fa-i-cursor{--fa:"\f246"}.fa-object-group{--fa:"\f247"}.fa-object-ungroup{--fa:"\f248"}.fa-note-sticky,.fa-sticky-note{--fa:"\f249"}.fa-clone{--fa:"\f24d"}.fa-balance-scale,.fa-scale-balanced{--fa:"\f24e"}.fa-hourglass-1,.fa-hourglass-start{--fa:"\f251"}.fa-hourglass-2,.fa-hourglass-half{--fa:"\f252"}.fa-hourglass-3,.fa-hourglass-end{--fa:"\f253"}.fa-hourglass,.fa-hourglass-empty{--fa:"\f254"}.fa-hand-back-fist,.fa-hand-rock{--fa:"\f255"}.fa-hand,.fa-hand-paper{--fa:"\f256"}.fa-hand-scissors{--fa:"\f257"}.fa-hand-lizard{--fa:"\f258"}.fa-hand-spock{--fa:"\f259"}.fa-hand-pointer{--fa:"\f25a"}.fa-hand-peace{--fa:"\f25b"}.fa-trademark{--fa:"\f25c"}.fa-registered{--fa:"\f25d"}.fa-television,.fa-tv,.fa-tv-alt{--fa:"\f26c"}.fa-calendar-plus{--fa:"\f271"}.fa-calendar-minus{--fa:"\f272"}.fa-calendar-times,.fa-calendar-xmark{--fa:"\f273"}.fa-calendar-check{--fa:"\f274"}.fa-industry{--fa:"\f275"}.fa-map-pin{--fa:"\f276"}.fa-map-signs,.fa-signs-post{--fa:"\f277"}.fa-map{--fa:"\f279"}.fa-comment-alt,.fa-message{--fa:"\f27a"}.fa-circle-pause,.fa-pause-circle{--fa:"\f28b"}.fa-circle-stop,.fa-stop-circle{--fa:"\f28d"}.fa-bag-shopping,.fa-shopping-bag{--fa:"\f290"}.fa-basket-shopping,.fa-shopping-basket{--fa:"\f291"}.fa-universal-access{--fa:"\f29a"}.fa-blind,.fa-person-walking-with-cane{--fa:"\f29d"}.fa-audio-description{--fa:"\f29e"}.fa-phone-volume,.fa-volume-control-phone{--fa:"\f2a0"}.fa-braille{--fa:"\f2a1"}.fa-assistive-listening-systems,.fa-ear-listen{--fa:"\f2a2"}.fa-american-sign-language-interpreting,.fa-asl-interpreting,.fa-hands-american-sign-language-interpreting,.fa-hands-asl-interpreting{--fa:"\f2a3"}.fa-deaf,.fa-deafness,.fa-ear-deaf,.fa-hard-of-hearing{--fa:"\f2a4"}.fa-hands,.fa-sign-language,.fa-signing{--fa:"\f2a7"}.fa-eye-low-vision,.fa-low-vision{--fa:"\f2a8"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"\f2b4"}.fa-handshake,.fa-handshake-alt,.fa-handshake-simple{--fa:"\f2b5"}.fa-envelope-open{--fa:"\f2b6"}.fa-address-book,.fa-contact-book{--fa:"\f2b9"}.fa-address-card,.fa-contact-card,.fa-vcard{--fa:"\f2bb"}.fa-circle-user,.fa-user-circle{--fa:"\f2bd"}.fa-id-badge{--fa:"\f2c1"}.fa-drivers-license,.fa-id-card{--fa:"\f2c2"}.fa-temperature-4,.fa-temperature-full,.fa-thermometer-4,.fa-thermometer-full{--fa:"\f2c7"}.fa-temperature-3,.fa-temperature-three-quarters,.fa-thermometer-3,.fa-thermometer-three-quarters{--fa:"\f2c8"}.fa-temperature-2,.fa-temperature-half,.fa-thermometer-2,.fa-thermometer-half{--fa:"\f2c9"}.fa-temperature-1,.fa-temperature-quarter,.fa-thermometer-1,.fa-thermometer-quarter{--fa:"\f2ca"}.fa-temperature-0,.fa-temperature-empty,.fa-thermometer-0,.fa-thermometer-empty{--fa:"\f2cb"}.fa-shower{--fa:"\f2cc"}.fa-bath,.fa-bathtub{--fa:"\f2cd"}.fa-podcast{--fa:"\f2ce"}.fa-window-maximize{--fa:"\f2d0"}.fa-window-minimize{--fa:"\f2d1"}.fa-window-restore{--fa:"\f2d2"}.fa-square-xmark,.fa-times-square,.fa-xmark-square{--fa:"\f2d3"}.fa-microchip{--fa:"\f2db"}.fa-snowflake{--fa:"\f2dc"}.fa-spoon,.fa-utensil-spoon{--fa:"\f2e5"}.fa-cutlery,.fa-utensils{--fa:"\f2e7"}.fa-rotate-back,.fa-rotate-backward,.fa-rotate-left,.fa-undo-alt{--fa:"\f2ea"}.fa-trash-alt,.fa-trash-can{--fa:"\f2ed"}.fa-rotate,.fa-sync-alt{--fa:"\f2f1"}.fa-stopwatch{--fa:"\f2f2"}.fa-right-from-bracket,.fa-sign-out-alt{--fa:"\f2f5"}.fa-right-to-bracket,.fa-sign-in-alt{--fa:"\f2f6"}.fa-redo-alt,.fa-rotate-forward,.fa-rotate-right{--fa:"\f2f9"}.fa-poo{--fa:"\f2fe"}.fa-images{--fa:"\f302"}.fa-pencil,.fa-pencil-alt{--fa:"\f303"}.fa-pen{--fa:"\f304"}.fa-pen-alt,.fa-pen-clip{--fa:"\f305"}.fa-octagon{--fa:"\f306"}.fa-down-long,.fa-long-arrow-alt-down{--fa:"\f309"}.fa-left-long,.fa-long-arrow-alt-left{--fa:"\f30a"}.fa-long-arrow-alt-right,.fa-right-long{--fa:"\f30b"}.fa-long-arrow-alt-up,.fa-up-long{--fa:"\f30c"}.fa-hexagon{--fa:"\f312"}.fa-file-edit,.fa-file-pen{--fa:"\f31c"}.fa-expand-arrows-alt,.fa-maximize{--fa:"\f31e"}.fa-clipboard{--fa:"\f328"}.fa-arrows-alt-h,.fa-left-right{--fa:"\f337"}.fa-arrows-alt-v,.fa-up-down{--fa:"\f338"}.fa-alarm-clock{--fa:"\f34e"}.fa-arrow-alt-circle-down,.fa-circle-down{--fa:"\f358"}.fa-arrow-alt-circle-left,.fa-circle-left{--fa:"\f359"}.fa-arrow-alt-circle-right,.fa-circle-right{--fa:"\f35a"}.fa-arrow-alt-circle-up,.fa-circle-up{--fa:"\f35b"}.fa-external-link-alt,.fa-up-right-from-square{--fa:"\f35d"}.fa-external-link-square-alt,.fa-square-up-right{--fa:"\f360"}.fa-exchange-alt,.fa-right-left{--fa:"\f362"}.fa-repeat{--fa:"\f363"}.fa-code-commit{--fa:"\f386"}.fa-code-merge{--fa:"\f387"}.fa-desktop,.fa-desktop-alt{--fa:"\f390"}.fa-gem{--fa:"\f3a5"}.fa-level-down-alt,.fa-turn-down{--fa:"\f3be"}.fa-level-up-alt,.fa-turn-up{--fa:"\f3bf"}.fa-lock-open{--fa:"\f3c1"}.fa-location-dot,.fa-map-marker-alt{--fa:"\f3c5"}.fa-microphone-alt,.fa-microphone-lines{--fa:"\f3c9"}.fa-mobile-alt,.fa-mobile-screen-button{--fa:"\f3cd"}.fa-mobile,.fa-mobile-android,.fa-mobile-phone{--fa:"\f3ce"}.fa-mobile-android-alt,.fa-mobile-screen{--fa:"\f3cf"}.fa-money-bill-1,.fa-money-bill-alt{--fa:"\f3d1"}.fa-phone-slash{--fa:"\f3dd"}.fa-image-portrait,.fa-portrait{--fa:"\f3e0"}.fa-mail-reply,.fa-reply{--fa:"\f3e5"}.fa-shield-alt,.fa-shield-halved{--fa:"\f3ed"}.fa-tablet-alt,.fa-tablet-screen-button{--fa:"\f3fa"}.fa-tablet,.fa-tablet-android{--fa:"\f3fb"}.fa-ticket-alt,.fa-ticket-simple{--fa:"\f3ff"}.fa-rectangle-times,.fa-rectangle-xmark,.fa-times-rectangle,.fa-window-close{--fa:"\f410"}.fa-compress-alt,.fa-down-left-and-up-right-to-center{--fa:"\f422"}.fa-expand-alt,.fa-up-right-and-down-left-from-center{--fa:"\f424"}.fa-baseball-bat-ball{--fa:"\f432"}.fa-baseball,.fa-baseball-ball{--fa:"\f433"}.fa-basketball,.fa-basketball-ball{--fa:"\f434"}.fa-bowling-ball{--fa:"\f436"}.fa-chess{--fa:"\f439"}.fa-chess-bishop{--fa:"\f43a"}.fa-chess-board{--fa:"\f43c"}.fa-chess-king{--fa:"\f43f"}.fa-chess-knight{--fa:"\f441"}.fa-chess-pawn{--fa:"\f443"}.fa-chess-queen{--fa:"\f445"}.fa-chess-rook{--fa:"\f447"}.fa-dumbbell{--fa:"\f44b"}.fa-football,.fa-football-ball{--fa:"\f44e"}.fa-golf-ball,.fa-golf-ball-tee{--fa:"\f450"}.fa-hockey-puck{--fa:"\f453"}.fa-broom-ball,.fa-quidditch,.fa-quidditch-broom-ball{--fa:"\f458"}.fa-square-full{--fa:"\f45c"}.fa-ping-pong-paddle-ball,.fa-table-tennis,.fa-table-tennis-paddle-ball{--fa:"\f45d"}.fa-volleyball,.fa-volleyball-ball{--fa:"\f45f"}.fa-allergies,.fa-hand-dots{--fa:"\f461"}.fa-band-aid,.fa-bandage{--fa:"\f462"}.fa-box{--fa:"\f466"}.fa-boxes,.fa-boxes-alt,.fa-boxes-stacked{--fa:"\f468"}.fa-briefcase-medical{--fa:"\f469"}.fa-burn,.fa-fire-flame-simple{--fa:"\f46a"}.fa-capsules{--fa:"\f46b"}.fa-clipboard-check{--fa:"\f46c"}.fa-clipboard-list{--fa:"\f46d"}.fa-diagnoses,.fa-person-dots-from-line{--fa:"\f470"}.fa-dna{--fa:"\f471"}.fa-dolly,.fa-dolly-box{--fa:"\f472"}.fa-cart-flatbed,.fa-dolly-flatbed{--fa:"\f474"}.fa-file-medical{--fa:"\f477"}.fa-file-medical-alt,.fa-file-waveform{--fa:"\f478"}.fa-first-aid,.fa-kit-medical{--fa:"\f479"}.fa-circle-h,.fa-hospital-symbol{--fa:"\f47e"}.fa-id-card-alt,.fa-id-card-clip{--fa:"\f47f"}.fa-notes-medical{--fa:"\f481"}.fa-pallet{--fa:"\f482"}.fa-pills{--fa:"\f484"}.fa-prescription-bottle{--fa:"\f485"}.fa-prescription-bottle-alt,.fa-prescription-bottle-medical{--fa:"\f486"}.fa-bed-pulse,.fa-procedures{--fa:"\f487"}.fa-shipping-fast,.fa-truck-fast{--fa:"\f48b"}.fa-smoking{--fa:"\f48d"}.fa-syringe{--fa:"\f48e"}.fa-tablets{--fa:"\f490"}.fa-thermometer{--fa:"\f491"}.fa-vial{--fa:"\f492"}.fa-vials{--fa:"\f493"}.fa-warehouse{--fa:"\f494"}.fa-weight,.fa-weight-scale{--fa:"\f496"}.fa-x-ray{--fa:"\f497"}.fa-box-open{--fa:"\f49e"}.fa-comment-dots,.fa-commenting{--fa:"\f4ad"}.fa-comment-slash{--fa:"\f4b3"}.fa-couch{--fa:"\f4b8"}.fa-circle-dollar-to-slot,.fa-donate{--fa:"\f4b9"}.fa-dove{--fa:"\f4ba"}.fa-hand-holding{--fa:"\f4bd"}.fa-hand-holding-heart{--fa:"\f4be"}.fa-hand-holding-dollar,.fa-hand-holding-usd{--fa:"\f4c0"}.fa-hand-holding-droplet,.fa-hand-holding-water{--fa:"\f4c1"}.fa-hands-holding{--fa:"\f4c2"}.fa-hands-helping,.fa-handshake-angle{--fa:"\f4c4"}.fa-parachute-box{--fa:"\f4cd"}.fa-people-carry,.fa-people-carry-box{--fa:"\f4ce"}.fa-piggy-bank{--fa:"\f4d3"}.fa-ribbon{--fa:"\f4d6"}.fa-route{--fa:"\f4d7"}.fa-seedling,.fa-sprout{--fa:"\f4d8"}.fa-sign,.fa-sign-hanging{--fa:"\f4d9"}.fa-face-smile-wink,.fa-smile-wink{--fa:"\f4da"}.fa-tape{--fa:"\f4db"}.fa-truck-loading,.fa-truck-ramp-box{--fa:"\f4de"}.fa-truck-moving{--fa:"\f4df"}.fa-video-slash{--fa:"\f4e2"}.fa-wine-glass{--fa:"\f4e3"}.fa-user-astronaut{--fa:"\f4fb"}.fa-user-check{--fa:"\f4fc"}.fa-user-clock{--fa:"\f4fd"}.fa-user-cog,.fa-user-gear{--fa:"\f4fe"}.fa-user-edit,.fa-user-pen{--fa:"\f4ff"}.fa-user-friends,.fa-user-group{--fa:"\f500"}.fa-user-graduate{--fa:"\f501"}.fa-user-lock{--fa:"\f502"}.fa-user-minus{--fa:"\f503"}.fa-user-ninja{--fa:"\f504"}.fa-user-shield{--fa:"\f505"}.fa-user-alt-slash,.fa-user-large-slash,.fa-user-slash{--fa:"\f506"}.fa-user-tag{--fa:"\f507"}.fa-user-tie{--fa:"\f508"}.fa-users-cog,.fa-users-gear{--fa:"\f509"}.fa-balance-scale-left,.fa-scale-unbalanced{--fa:"\f515"}.fa-balance-scale-right,.fa-scale-unbalanced-flip{--fa:"\f516"}.fa-blender{--fa:"\f517"}.fa-book-open{--fa:"\f518"}.fa-broadcast-tower,.fa-tower-broadcast{--fa:"\f519"}.fa-broom{--fa:"\f51a"}.fa-blackboard,.fa-chalkboard{--fa:"\f51b"}.fa-chalkboard-teacher,.fa-chalkboard-user{--fa:"\f51c"}.fa-church{--fa:"\f51d"}.fa-coins{--fa:"\f51e"}.fa-compact-disc{--fa:"\f51f"}.fa-crow{--fa:"\f520"}.fa-crown{--fa:"\f521"}.fa-dice{--fa:"\f522"}.fa-dice-five{--fa:"\f523"}.fa-dice-four{--fa:"\f524"}.fa-dice-one{--fa:"\f525"}.fa-dice-six{--fa:"\f526"}.fa-dice-three{--fa:"\f527"}.fa-dice-two{--fa:"\f528"}.fa-divide{--fa:"\f529"}.fa-door-closed{--fa:"\f52a"}.fa-door-open{--fa:"\f52b"}.fa-feather{--fa:"\f52d"}.fa-frog{--fa:"\f52e"}.fa-gas-pump{--fa:"\f52f"}.fa-glasses{--fa:"\f530"}.fa-greater-than-equal{--fa:"\f532"}.fa-helicopter{--fa:"\f533"}.fa-infinity{--fa:"\f534"}.fa-kiwi-bird{--fa:"\f535"}.fa-less-than-equal{--fa:"\f537"}.fa-memory{--fa:"\f538"}.fa-microphone-alt-slash,.fa-microphone-lines-slash{--fa:"\f539"}.fa-money-bill-wave{--fa:"\f53a"}.fa-money-bill-1-wave,.fa-money-bill-wave-alt{--fa:"\f53b"}.fa-money-check{--fa:"\f53c"}.fa-money-check-alt,.fa-money-check-dollar{--fa:"\f53d"}.fa-not-equal{--fa:"\f53e"}.fa-palette{--fa:"\f53f"}.fa-parking,.fa-square-parking{--fa:"\f540"}.fa-diagram-project,.fa-project-diagram{--fa:"\f542"}.fa-receipt{--fa:"\f543"}.fa-robot{--fa:"\f544"}.fa-ruler{--fa:"\f545"}.fa-ruler-combined{--fa:"\f546"}.fa-ruler-horizontal{--fa:"\f547"}.fa-ruler-vertical{--fa:"\f548"}.fa-school{--fa:"\f549"}.fa-screwdriver{--fa:"\f54a"}.fa-shoe-prints{--fa:"\f54b"}.fa-skull{--fa:"\f54c"}.fa-ban-smoking,.fa-smoking-ban{--fa:"\f54d"}.fa-store{--fa:"\f54e"}.fa-shop,.fa-store-alt{--fa:"\f54f"}.fa-bars-staggered,.fa-reorder,.fa-stream{--fa:"\f550"}.fa-stroopwafel{--fa:"\f551"}.fa-toolbox{--fa:"\f552"}.fa-shirt,.fa-t-shirt,.fa-tshirt{--fa:"\f553"}.fa-person-walking,.fa-walking{--fa:"\f554"}.fa-wallet{--fa:"\f555"}.fa-angry,.fa-face-angry{--fa:"\f556"}.fa-archway{--fa:"\f557"}.fa-atlas,.fa-book-atlas{--fa:"\f558"}.fa-award{--fa:"\f559"}.fa-backspace,.fa-delete-left{--fa:"\f55a"}.fa-bezier-curve{--fa:"\f55b"}.fa-bong{--fa:"\f55c"}.fa-brush{--fa:"\f55d"}.fa-bus-alt,.fa-bus-simple{--fa:"\f55e"}.fa-cannabis{--fa:"\f55f"}.fa-check-double{--fa:"\f560"}.fa-cocktail,.fa-martini-glass-citrus{--fa:"\f561"}.fa-bell-concierge,.fa-concierge-bell{--fa:"\f562"}.fa-cookie{--fa:"\f563"}.fa-cookie-bite{--fa:"\f564"}.fa-crop-alt,.fa-crop-simple{--fa:"\f565"}.fa-digital-tachograph,.fa-tachograph-digital{--fa:"\f566"}.fa-dizzy,.fa-face-dizzy{--fa:"\f567"}.fa-compass-drafting,.fa-drafting-compass{--fa:"\f568"}.fa-drum{--fa:"\f569"}.fa-drum-steelpan{--fa:"\f56a"}.fa-feather-alt,.fa-feather-pointed{--fa:"\f56b"}.fa-file-contract{--fa:"\f56c"}.fa-file-arrow-down,.fa-file-download{--fa:"\f56d"}.fa-arrow-right-from-file,.fa-file-export{--fa:"\f56e"}.fa-arrow-right-to-file,.fa-file-import{--fa:"\f56f"}.fa-file-invoice{--fa:"\f570"}.fa-file-invoice-dollar{--fa:"\f571"}.fa-file-prescription{--fa:"\f572"}.fa-file-signature{--fa:"\f573"}.fa-file-arrow-up,.fa-file-upload{--fa:"\f574"}.fa-fill{--fa:"\f575"}.fa-fill-drip{--fa:"\f576"}.fa-fingerprint{--fa:"\f577"}.fa-fish{--fa:"\f578"}.fa-face-flushed,.fa-flushed{--fa:"\f579"}.fa-face-frown-open,.fa-frown-open{--fa:"\f57a"}.fa-glass-martini-alt,.fa-martini-glass{--fa:"\f57b"}.fa-earth-africa,.fa-globe-africa{--fa:"\f57c"}.fa-earth,.fa-earth-america,.fa-earth-americas,.fa-globe-americas{--fa:"\f57d"}.fa-earth-asia,.fa-globe-asia{--fa:"\f57e"}.fa-face-grimace,.fa-grimace{--fa:"\f57f"}.fa-face-grin,.fa-grin{--fa:"\f580"}.fa-face-grin-wide,.fa-grin-alt{--fa:"\f581"}.fa-face-grin-beam,.fa-grin-beam{--fa:"\f582"}.fa-face-grin-beam-sweat,.fa-grin-beam-sweat{--fa:"\f583"}.fa-face-grin-hearts,.fa-grin-hearts{--fa:"\f584"}.fa-face-grin-squint,.fa-grin-squint{--fa:"\f585"}.fa-face-grin-squint-tears,.fa-grin-squint-tears{--fa:"\f586"}.fa-face-grin-stars,.fa-grin-stars{--fa:"\f587"}.fa-face-grin-tears,.fa-grin-tears{--fa:"\f588"}.fa-face-grin-tongue,.fa-grin-tongue{--fa:"\f589"}.fa-face-grin-tongue-squint,.fa-grin-tongue-squint{--fa:"\f58a"}.fa-face-grin-tongue-wink,.fa-grin-tongue-wink{--fa:"\f58b"}.fa-face-grin-wink,.fa-grin-wink{--fa:"\f58c"}.fa-grid-horizontal,.fa-grip,.fa-grip-horizontal{--fa:"\f58d"}.fa-grid-vertical,.fa-grip-vertical{--fa:"\f58e"}.fa-headset{--fa:"\f590"}.fa-highlighter{--fa:"\f591"}.fa-hot-tub,.fa-hot-tub-person{--fa:"\f593"}.fa-hotel{--fa:"\f594"}.fa-joint{--fa:"\f595"}.fa-face-kiss,.fa-kiss{--fa:"\f596"}.fa-face-kiss-beam,.fa-kiss-beam{--fa:"\f597"}.fa-face-kiss-wink-heart,.fa-kiss-wink-heart{--fa:"\f598"}.fa-face-laugh,.fa-laugh{--fa:"\f599"}.fa-face-laugh-beam,.fa-laugh-beam{--fa:"\f59a"}.fa-face-laugh-squint,.fa-laugh-squint{--fa:"\f59b"}.fa-face-laugh-wink,.fa-laugh-wink{--fa:"\f59c"}.fa-cart-flatbed-suitcase,.fa-luggage-cart{--fa:"\f59d"}.fa-map-location,.fa-map-marked{--fa:"\f59f"}.fa-map-location-dot,.fa-map-marked-alt{--fa:"\f5a0"}.fa-marker{--fa:"\f5a1"}.fa-medal{--fa:"\f5a2"}.fa-face-meh-blank,.fa-meh-blank{--fa:"\f5a4"}.fa-face-rolling-eyes,.fa-meh-rolling-eyes{--fa:"\f5a5"}.fa-monument{--fa:"\f5a6"}.fa-mortar-pestle{--fa:"\f5a7"}.fa-paint-roller{--fa:"\f5aa"}.fa-passport{--fa:"\f5ab"}.fa-pen-fancy{--fa:"\f5ac"}.fa-pen-nib{--fa:"\f5ad"}.fa-pen-ruler,.fa-pencil-ruler{--fa:"\f5ae"}.fa-plane-arrival{--fa:"\f5af"}.fa-plane-departure{--fa:"\f5b0"}.fa-prescription{--fa:"\f5b1"}.fa-face-sad-cry,.fa-sad-cry{--fa:"\f5b3"}.fa-face-sad-tear,.fa-sad-tear{--fa:"\f5b4"}.fa-shuttle-van,.fa-van-shuttle{--fa:"\f5b6"}.fa-signature{--fa:"\f5b7"}.fa-face-smile-beam,.fa-smile-beam{--fa:"\f5b8"}.fa-solar-panel{--fa:"\f5ba"}.fa-spa{--fa:"\f5bb"}.fa-splotch{--fa:"\f5bc"}.fa-spray-can{--fa:"\f5bd"}.fa-stamp{--fa:"\f5bf"}.fa-star-half-alt,.fa-star-half-stroke{--fa:"\f5c0"}.fa-suitcase-rolling{--fa:"\f5c1"}.fa-face-surprise,.fa-surprise{--fa:"\f5c2"}.fa-swatchbook{--fa:"\f5c3"}.fa-person-swimming,.fa-swimmer{--fa:"\f5c4"}.fa-ladder-water,.fa-swimming-pool,.fa-water-ladder{--fa:"\f5c5"}.fa-droplet-slash,.fa-tint-slash{--fa:"\f5c7"}.fa-face-tired,.fa-tired{--fa:"\f5c8"}.fa-tooth{--fa:"\f5c9"}.fa-umbrella-beach{--fa:"\f5ca"}.fa-weight-hanging{--fa:"\f5cd"}.fa-wine-glass-alt,.fa-wine-glass-empty{--fa:"\f5ce"}.fa-air-freshener,.fa-spray-can-sparkles{--fa:"\f5d0"}.fa-apple-alt,.fa-apple-whole{--fa:"\f5d1"}.fa-atom{--fa:"\f5d2"}.fa-bone{--fa:"\f5d7"}.fa-book-open-reader,.fa-book-reader{--fa:"\f5da"}.fa-brain{--fa:"\f5dc"}.fa-car-alt,.fa-car-rear{--fa:"\f5de"}.fa-battery-car,.fa-car-battery{--fa:"\f5df"}.fa-car-burst,.fa-car-crash{--fa:"\f5e1"}.fa-car-side{--fa:"\f5e4"}.fa-charging-station{--fa:"\f5e7"}.fa-diamond-turn-right,.fa-directions{--fa:"\f5eb"}.fa-draw-polygon,.fa-vector-polygon{--fa:"\f5ee"}.fa-laptop-code{--fa:"\f5fc"}.fa-layer-group{--fa:"\f5fd"}.fa-location,.fa-location-crosshairs{--fa:"\f601"}.fa-lungs{--fa:"\f604"}.fa-microscope{--fa:"\f610"}.fa-oil-can{--fa:"\f613"}.fa-poop{--fa:"\f619"}.fa-shapes,.fa-triangle-circle-square{--fa:"\f61f"}.fa-star-of-life{--fa:"\f621"}.fa-dashboard,.fa-gauge,.fa-gauge-med,.fa-tachometer-alt-average{--fa:"\f624"}.fa-gauge-high,.fa-tachometer-alt,.fa-tachometer-alt-fast{--fa:"\f625"}.fa-gauge-simple,.fa-gauge-simple-med,.fa-tachometer-average{--fa:"\f629"}.fa-gauge-simple-high,.fa-tachometer,.fa-tachometer-fast{--fa:"\f62a"}.fa-teeth{--fa:"\f62e"}.fa-teeth-open{--fa:"\f62f"}.fa-masks-theater,.fa-theater-masks{--fa:"\f630"}.fa-traffic-light{--fa:"\f637"}.fa-truck-monster{--fa:"\f63b"}.fa-truck-pickup{--fa:"\f63c"}.fa-ad,.fa-rectangle-ad{--fa:"\f641"}.fa-ankh{--fa:"\f644"}.fa-bible,.fa-book-bible{--fa:"\f647"}.fa-briefcase-clock,.fa-business-time{--fa:"\f64a"}.fa-city{--fa:"\f64f"}.fa-comment-dollar{--fa:"\f651"}.fa-comments-dollar{--fa:"\f653"}.fa-cross{--fa:"\f654"}.fa-dharmachakra{--fa:"\f655"}.fa-envelope-open-text{--fa:"\f658"}.fa-folder-minus{--fa:"\f65d"}.fa-folder-plus{--fa:"\f65e"}.fa-filter-circle-dollar,.fa-funnel-dollar{--fa:"\f662"}.fa-gopuram{--fa:"\f664"}.fa-hamsa{--fa:"\f665"}.fa-bahai,.fa-haykal{--fa:"\f666"}.fa-jedi{--fa:"\f669"}.fa-book-journal-whills,.fa-journal-whills{--fa:"\f66a"}.fa-kaaba{--fa:"\f66b"}.fa-khanda{--fa:"\f66d"}.fa-landmark{--fa:"\f66f"}.fa-envelopes-bulk,.fa-mail-bulk{--fa:"\f674"}.fa-menorah{--fa:"\f676"}.fa-mosque{--fa:"\f678"}.fa-om{--fa:"\f679"}.fa-pastafarianism,.fa-spaghetti-monster-flying{--fa:"\f67b"}.fa-peace{--fa:"\f67c"}.fa-place-of-worship{--fa:"\f67f"}.fa-poll,.fa-square-poll-vertical{--fa:"\f681"}.fa-poll-h,.fa-square-poll-horizontal{--fa:"\f682"}.fa-person-praying,.fa-pray{--fa:"\f683"}.fa-hands-praying,.fa-praying-hands{--fa:"\f684"}.fa-book-quran,.fa-quran{--fa:"\f687"}.fa-magnifying-glass-dollar,.fa-search-dollar{--fa:"\f688"}.fa-magnifying-glass-location,.fa-search-location{--fa:"\f689"}.fa-socks{--fa:"\f696"}.fa-square-root-alt,.fa-square-root-variable{--fa:"\f698"}.fa-star-and-crescent{--fa:"\f699"}.fa-star-of-david{--fa:"\f69a"}.fa-synagogue{--fa:"\f69b"}.fa-scroll-torah,.fa-torah{--fa:"\f6a0"}.fa-torii-gate{--fa:"\f6a1"}.fa-vihara{--fa:"\f6a7"}.fa-volume-mute,.fa-volume-times,.fa-volume-xmark{--fa:"\f6a9"}.fa-yin-yang{--fa:"\f6ad"}.fa-blender-phone{--fa:"\f6b6"}.fa-book-dead,.fa-book-skull{--fa:"\f6b7"}.fa-campground{--fa:"\f6bb"}.fa-cat{--fa:"\f6be"}.fa-chair{--fa:"\f6c0"}.fa-cloud-moon{--fa:"\f6c3"}.fa-cloud-sun{--fa:"\f6c4"}.fa-cow{--fa:"\f6c8"}.fa-dice-d20{--fa:"\f6cf"}.fa-dice-d6{--fa:"\f6d1"}.fa-dog{--fa:"\f6d3"}.fa-dragon{--fa:"\f6d5"}.fa-drumstick-bite{--fa:"\f6d7"}.fa-dungeon{--fa:"\f6d9"}.fa-file-csv{--fa:"\f6dd"}.fa-fist-raised,.fa-hand-fist{--fa:"\f6de"}.fa-ghost{--fa:"\f6e2"}.fa-hammer{--fa:"\f6e3"}.fa-hanukiah{--fa:"\f6e6"}.fa-hat-wizard{--fa:"\f6e8"}.fa-hiking,.fa-person-hiking{--fa:"\f6ec"}.fa-hippo{--fa:"\f6ed"}.fa-horse{--fa:"\f6f0"}.fa-house-chimney-crack,.fa-house-damage{--fa:"\f6f1"}.fa-hryvnia,.fa-hryvnia-sign{--fa:"\f6f2"}.fa-mask{--fa:"\f6fa"}.fa-mountain{--fa:"\f6fc"}.fa-network-wired{--fa:"\f6ff"}.fa-otter{--fa:"\f700"}.fa-ring{--fa:"\f70b"}.fa-person-running,.fa-running{--fa:"\f70c"}.fa-scroll{--fa:"\f70e"}.fa-skull-crossbones{--fa:"\f714"}.fa-slash{--fa:"\f715"}.fa-spider{--fa:"\f717"}.fa-toilet-paper,.fa-toilet-paper-alt,.fa-toilet-paper-blank{--fa:"\f71e"}.fa-tractor{--fa:"\f722"}.fa-user-injured{--fa:"\f728"}.fa-vr-cardboard{--fa:"\f729"}.fa-wand-sparkles{--fa:"\f72b"}.fa-wind{--fa:"\f72e"}.fa-wine-bottle{--fa:"\f72f"}.fa-cloud-meatball{--fa:"\f73b"}.fa-cloud-moon-rain{--fa:"\f73c"}.fa-cloud-rain{--fa:"\f73d"}.fa-cloud-showers-heavy{--fa:"\f740"}.fa-cloud-sun-rain{--fa:"\f743"}.fa-democrat{--fa:"\f747"}.fa-flag-usa{--fa:"\f74d"}.fa-hurricane{--fa:"\f751"}.fa-landmark-alt,.fa-landmark-dome{--fa:"\f752"}.fa-meteor{--fa:"\f753"}.fa-person-booth{--fa:"\f756"}.fa-poo-bolt,.fa-poo-storm{--fa:"\f75a"}.fa-rainbow{--fa:"\f75b"}.fa-republican{--fa:"\f75e"}.fa-smog{--fa:"\f75f"}.fa-temperature-high{--fa:"\f769"}.fa-temperature-low{--fa:"\f76b"}.fa-cloud-bolt,.fa-thunderstorm{--fa:"\f76c"}.fa-tornado{--fa:"\f76f"}.fa-volcano{--fa:"\f770"}.fa-check-to-slot,.fa-vote-yea{--fa:"\f772"}.fa-water{--fa:"\f773"}.fa-baby{--fa:"\f77c"}.fa-baby-carriage,.fa-carriage-baby{--fa:"\f77d"}.fa-biohazard{--fa:"\f780"}.fa-blog{--fa:"\f781"}.fa-calendar-day{--fa:"\f783"}.fa-calendar-week{--fa:"\f784"}.fa-candy-cane{--fa:"\f786"}.fa-carrot{--fa:"\f787"}.fa-cash-register{--fa:"\f788"}.fa-compress-arrows-alt,.fa-minimize{--fa:"\f78c"}.fa-dumpster{--fa:"\f793"}.fa-dumpster-fire{--fa:"\f794"}.fa-ethernet{--fa:"\f796"}.fa-gifts{--fa:"\f79c"}.fa-champagne-glasses,.fa-glass-cheers{--fa:"\f79f"}.fa-glass-whiskey,.fa-whiskey-glass{--fa:"\f7a0"}.fa-earth-europe,.fa-globe-europe{--fa:"\f7a2"}.fa-grip-lines{--fa:"\f7a4"}.fa-grip-lines-vertical{--fa:"\f7a5"}.fa-guitar{--fa:"\f7a6"}.fa-heart-broken,.fa-heart-crack{--fa:"\f7a9"}.fa-holly-berry{--fa:"\f7aa"}.fa-horse-head{--fa:"\f7ab"}.fa-icicles{--fa:"\f7ad"}.fa-igloo{--fa:"\f7ae"}.fa-mitten{--fa:"\f7b5"}.fa-mug-hot{--fa:"\f7b6"}.fa-radiation{--fa:"\f7b9"}.fa-circle-radiation,.fa-radiation-alt{--fa:"\f7ba"}.fa-restroom{--fa:"\f7bd"}.fa-satellite{--fa:"\f7bf"}.fa-satellite-dish{--fa:"\f7c0"}.fa-sd-card{--fa:"\f7c2"}.fa-sim-card{--fa:"\f7c4"}.fa-person-skating,.fa-skating{--fa:"\f7c5"}.fa-person-skiing,.fa-skiing{--fa:"\f7c9"}.fa-person-skiing-nordic,.fa-skiing-nordic{--fa:"\f7ca"}.fa-sleigh{--fa:"\f7cc"}.fa-comment-sms,.fa-sms{--fa:"\f7cd"}.fa-person-snowboarding,.fa-snowboarding{--fa:"\f7ce"}.fa-snowman{--fa:"\f7d0"}.fa-snowplow{--fa:"\f7d2"}.fa-tenge,.fa-tenge-sign{--fa:"\f7d7"}.fa-toilet{--fa:"\f7d8"}.fa-screwdriver-wrench,.fa-tools{--fa:"\f7d9"}.fa-cable-car,.fa-tram{--fa:"\f7da"}.fa-fire-alt,.fa-fire-flame-curved{--fa:"\f7e4"}.fa-bacon{--fa:"\f7e5"}.fa-book-medical{--fa:"\f7e6"}.fa-bread-slice{--fa:"\f7ec"}.fa-cheese{--fa:"\f7ef"}.fa-clinic-medical,.fa-house-chimney-medical{--fa:"\f7f2"}.fa-clipboard-user{--fa:"\f7f3"}.fa-comment-medical{--fa:"\f7f5"}.fa-crutch{--fa:"\f7f7"}.fa-disease{--fa:"\f7fa"}.fa-egg{--fa:"\f7fb"}.fa-folder-tree{--fa:"\f802"}.fa-burger,.fa-hamburger{--fa:"\f805"}.fa-hand-middle-finger{--fa:"\f806"}.fa-hard-hat,.fa-hat-hard,.fa-helmet-safety{--fa:"\f807"}.fa-hospital-user{--fa:"\f80d"}.fa-hotdog{--fa:"\f80f"}.fa-ice-cream{--fa:"\f810"}.fa-laptop-medical{--fa:"\f812"}.fa-pager{--fa:"\f815"}.fa-pepper-hot{--fa:"\f816"}.fa-pizza-slice{--fa:"\f818"}.fa-sack-dollar{--fa:"\f81d"}.fa-book-tanakh,.fa-tanakh{--fa:"\f827"}.fa-bars-progress,.fa-tasks-alt{--fa:"\f828"}.fa-trash-arrow-up,.fa-trash-restore{--fa:"\f829"}.fa-trash-can-arrow-up,.fa-trash-restore-alt{--fa:"\f82a"}.fa-user-nurse{--fa:"\f82f"}.fa-wave-square{--fa:"\f83e"}.fa-biking,.fa-person-biking{--fa:"\f84a"}.fa-border-all{--fa:"\f84c"}.fa-border-none{--fa:"\f850"}.fa-border-style,.fa-border-top-left{--fa:"\f853"}.fa-digging,.fa-person-digging{--fa:"\f85e"}.fa-fan{--fa:"\f863"}.fa-heart-music-camera-bolt,.fa-icons{--fa:"\f86d"}.fa-phone-alt,.fa-phone-flip{--fa:"\f879"}.fa-phone-square-alt,.fa-square-phone-flip{--fa:"\f87b"}.fa-photo-film,.fa-photo-video{--fa:"\f87c"}.fa-remove-format,.fa-text-slash{--fa:"\f87d"}.fa-arrow-down-z-a,.fa-sort-alpha-desc,.fa-sort-alpha-down-alt{--fa:"\f881"}.fa-arrow-up-z-a,.fa-sort-alpha-up-alt{--fa:"\f882"}.fa-arrow-down-short-wide,.fa-sort-amount-desc,.fa-sort-amount-down-alt{--fa:"\f884"}.fa-arrow-up-short-wide,.fa-sort-amount-up-alt{--fa:"\f885"}.fa-arrow-down-9-1,.fa-sort-numeric-desc,.fa-sort-numeric-down-alt{--fa:"\f886"}.fa-arrow-up-9-1,.fa-sort-numeric-up-alt{--fa:"\f887"}.fa-spell-check{--fa:"\f891"}.fa-voicemail{--fa:"\f897"}.fa-hat-cowboy{--fa:"\f8c0"}.fa-hat-cowboy-side{--fa:"\f8c1"}.fa-computer-mouse,.fa-mouse{--fa:"\f8cc"}.fa-radio{--fa:"\f8d7"}.fa-record-vinyl{--fa:"\f8d9"}.fa-walkie-talkie{--fa:"\f8ef"}.fa-caravan{--fa:"\f8ff"} +/* solid.min.css */ +:host,:root{--fa-family-classic:"Font Awesome 7 Free";--fa-font-solid:normal 900 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2)}.fas{--fa-style:900}.fa-classic,.fas{--fa-family:var(--fa-family-classic)}.fa-solid{--fa-style:900} +/* brands.min.css */ +:host,:root{--fa-family-brands:"Font Awesome 7 Brands";--fa-font-brands:normal 400 1em/1 var(--fa-family-brands)}@font-face{font-family:"Font Awesome 7 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2)}.fa-brands,.fa-classic.fa-brands,.fab{--fa-family:var(--fa-family-brands);--fa-style:400}.fa-firefox-browser{--fa:"\e007"}.fa-ideal{--fa:"\e013"}.fa-microblog{--fa:"\e01a"}.fa-pied-piper-square,.fa-square-pied-piper{--fa:"\e01e"}.fa-unity{--fa:"\e049"}.fa-dailymotion{--fa:"\e052"}.fa-instagram-square,.fa-square-instagram{--fa:"\e055"}.fa-mixer{--fa:"\e056"}.fa-shopify{--fa:"\e057"}.fa-deezer{--fa:"\e077"}.fa-edge-legacy{--fa:"\e078"}.fa-google-pay{--fa:"\e079"}.fa-rust{--fa:"\e07a"}.fa-tiktok{--fa:"\e07b"}.fa-unsplash{--fa:"\e07c"}.fa-cloudflare{--fa:"\e07d"}.fa-guilded{--fa:"\e07e"}.fa-hive{--fa:"\e07f"}.fa-42-group,.fa-innosoft{--fa:"\e080"}.fa-instalod{--fa:"\e081"}.fa-octopus-deploy{--fa:"\e082"}.fa-perbyte{--fa:"\e083"}.fa-uncharted{--fa:"\e084"}.fa-watchman-monitoring{--fa:"\e087"}.fa-wodu{--fa:"\e088"}.fa-wirsindhandwerk,.fa-wsh{--fa:"\e2d0"}.fa-bots{--fa:"\e340"}.fa-cmplid{--fa:"\e360"}.fa-bilibili{--fa:"\e3d9"}.fa-golang{--fa:"\e40f"}.fa-pix{--fa:"\e43a"}.fa-sitrox{--fa:"\e44a"}.fa-hashnode{--fa:"\e499"}.fa-meta{--fa:"\e49b"}.fa-padlet{--fa:"\e4a0"}.fa-nfc-directional{--fa:"\e530"}.fa-nfc-symbol{--fa:"\e531"}.fa-screenpal{--fa:"\e570"}.fa-space-awesome{--fa:"\e5ac"}.fa-square-font-awesome{--fa:"\e5ad"}.fa-gitlab-square,.fa-square-gitlab{--fa:"\e5ae"}.fa-odysee{--fa:"\e5c6"}.fa-stubber{--fa:"\e5c7"}.fa-debian{--fa:"\e60b"}.fa-shoelace{--fa:"\e60c"}.fa-threads{--fa:"\e618"}.fa-square-threads{--fa:"\e619"}.fa-square-x-twitter{--fa:"\e61a"}.fa-x-twitter{--fa:"\e61b"}.fa-opensuse{--fa:"\e62b"}.fa-letterboxd{--fa:"\e62d"}.fa-square-letterboxd{--fa:"\e62e"}.fa-mintbit{--fa:"\e62f"}.fa-google-scholar{--fa:"\e63b"}.fa-brave{--fa:"\e63c"}.fa-brave-reverse{--fa:"\e63d"}.fa-pixiv{--fa:"\e640"}.fa-upwork{--fa:"\e641"}.fa-webflow{--fa:"\e65c"}.fa-signal-messenger{--fa:"\e663"}.fa-bluesky{--fa:"\e671"}.fa-jxl{--fa:"\e67b"}.fa-square-upwork{--fa:"\e67c"}.fa-web-awesome{--fa:"\e682"}.fa-square-web-awesome{--fa:"\e683"}.fa-square-web-awesome-stroke{--fa:"\e684"}.fa-dart-lang{--fa:"\e693"}.fa-flutter{--fa:"\e694"}.fa-files-pinwheel{--fa:"\e69f"}.fa-css{--fa:"\e6a2"}.fa-square-bluesky{--fa:"\e6a3"}.fa-openai{--fa:"\e7cf"}.fa-square-linkedin{--fa:"\e7d0"}.fa-cash-app{--fa:"\e7d4"}.fa-disqus{--fa:"\e7d5"}.fa-11ty,.fa-eleventy{--fa:"\e7d6"}.fa-kakao-talk{--fa:"\e7d7"}.fa-linktree{--fa:"\e7d8"}.fa-notion{--fa:"\e7d9"}.fa-pandora{--fa:"\e7da"}.fa-pixelfed{--fa:"\e7db"}.fa-tidal{--fa:"\e7dc"}.fa-vsco{--fa:"\e7dd"}.fa-w3c{--fa:"\e7de"}.fa-lumon{--fa:"\e7e2"}.fa-lumon-drop{--fa:"\e7e3"}.fa-square-figma{--fa:"\e7e4"}.fa-tex{--fa:"\e7ff"}.fa-duolingo{--fa:"\e812"}.fa-square-twitter,.fa-twitter-square{--fa:"\f081"}.fa-facebook-square,.fa-square-facebook{--fa:"\f082"}.fa-linkedin{--fa:"\f08c"}.fa-github-square,.fa-square-github{--fa:"\f092"}.fa-twitter{--fa:"\f099"}.fa-facebook{--fa:"\f09a"}.fa-github{--fa:"\f09b"}.fa-pinterest{--fa:"\f0d2"}.fa-pinterest-square,.fa-square-pinterest{--fa:"\f0d3"}.fa-google-plus-square,.fa-square-google-plus{--fa:"\f0d4"}.fa-google-plus-g{--fa:"\f0d5"}.fa-linkedin-in{--fa:"\f0e1"}.fa-github-alt{--fa:"\f113"}.fa-maxcdn{--fa:"\f136"}.fa-html5{--fa:"\f13b"}.fa-css3{--fa:"\f13c"}.fa-btc{--fa:"\f15a"}.fa-youtube{--fa:"\f167"}.fa-xing{--fa:"\f168"}.fa-square-xing,.fa-xing-square{--fa:"\f169"}.fa-dropbox{--fa:"\f16b"}.fa-stack-overflow{--fa:"\f16c"}.fa-instagram{--fa:"\f16d"}.fa-flickr{--fa:"\f16e"}.fa-adn{--fa:"\f170"}.fa-bitbucket{--fa:"\f171"}.fa-tumblr{--fa:"\f173"}.fa-square-tumblr,.fa-tumblr-square{--fa:"\f174"}.fa-apple{--fa:"\f179"}.fa-windows{--fa:"\f17a"}.fa-android{--fa:"\f17b"}.fa-linux{--fa:"\f17c"}.fa-dribbble{--fa:"\f17d"}.fa-skype{--fa:"\f17e"}.fa-foursquare{--fa:"\f180"}.fa-trello{--fa:"\f181"}.fa-gratipay{--fa:"\f184"}.fa-vk{--fa:"\f189"}.fa-weibo{--fa:"\f18a"}.fa-renren{--fa:"\f18b"}.fa-pagelines{--fa:"\f18c"}.fa-stack-exchange{--fa:"\f18d"}.fa-square-vimeo,.fa-vimeo-square{--fa:"\f194"}.fa-slack,.fa-slack-hash{--fa:"\f198"}.fa-wordpress{--fa:"\f19a"}.fa-openid{--fa:"\f19b"}.fa-yahoo{--fa:"\f19e"}.fa-google{--fa:"\f1a0"}.fa-reddit{--fa:"\f1a1"}.fa-reddit-square,.fa-square-reddit{--fa:"\f1a2"}.fa-stumbleupon-circle{--fa:"\f1a3"}.fa-stumbleupon{--fa:"\f1a4"}.fa-delicious{--fa:"\f1a5"}.fa-digg{--fa:"\f1a6"}.fa-pied-piper-pp{--fa:"\f1a7"}.fa-pied-piper-alt{--fa:"\f1a8"}.fa-drupal{--fa:"\f1a9"}.fa-joomla{--fa:"\f1aa"}.fa-behance{--fa:"\f1b4"}.fa-behance-square,.fa-square-behance{--fa:"\f1b5"}.fa-steam{--fa:"\f1b6"}.fa-square-steam,.fa-steam-square{--fa:"\f1b7"}.fa-spotify{--fa:"\f1bc"}.fa-deviantart{--fa:"\f1bd"}.fa-soundcloud{--fa:"\f1be"}.fa-vine{--fa:"\f1ca"}.fa-codepen{--fa:"\f1cb"}.fa-jsfiddle{--fa:"\f1cc"}.fa-rebel{--fa:"\f1d0"}.fa-empire{--fa:"\f1d1"}.fa-git-square,.fa-square-git{--fa:"\f1d2"}.fa-git{--fa:"\f1d3"}.fa-hacker-news{--fa:"\f1d4"}.fa-tencent-weibo{--fa:"\f1d5"}.fa-qq{--fa:"\f1d6"}.fa-weixin{--fa:"\f1d7"}.fa-slideshare{--fa:"\f1e7"}.fa-twitch{--fa:"\f1e8"}.fa-yelp{--fa:"\f1e9"}.fa-paypal{--fa:"\f1ed"}.fa-google-wallet{--fa:"\f1ee"}.fa-cc-visa{--fa:"\f1f0"}.fa-cc-mastercard{--fa:"\f1f1"}.fa-cc-discover{--fa:"\f1f2"}.fa-cc-amex{--fa:"\f1f3"}.fa-cc-paypal{--fa:"\f1f4"}.fa-cc-stripe{--fa:"\f1f5"}.fa-lastfm{--fa:"\f202"}.fa-lastfm-square,.fa-square-lastfm{--fa:"\f203"}.fa-ioxhost{--fa:"\f208"}.fa-angellist{--fa:"\f209"}.fa-buysellads{--fa:"\f20d"}.fa-connectdevelop{--fa:"\f20e"}.fa-dashcube{--fa:"\f210"}.fa-forumbee{--fa:"\f211"}.fa-leanpub{--fa:"\f212"}.fa-sellsy{--fa:"\f213"}.fa-shirtsinbulk{--fa:"\f214"}.fa-simplybuilt{--fa:"\f215"}.fa-skyatlas{--fa:"\f216"}.fa-pinterest-p{--fa:"\f231"}.fa-whatsapp{--fa:"\f232"}.fa-viacoin{--fa:"\f237"}.fa-medium,.fa-medium-m{--fa:"\f23a"}.fa-y-combinator{--fa:"\f23b"}.fa-optin-monster{--fa:"\f23c"}.fa-opencart{--fa:"\f23d"}.fa-expeditedssl{--fa:"\f23e"}.fa-cc-jcb{--fa:"\f24b"}.fa-cc-diners-club{--fa:"\f24c"}.fa-creative-commons{--fa:"\f25e"}.fa-gg{--fa:"\f260"}.fa-gg-circle{--fa:"\f261"}.fa-odnoklassniki{--fa:"\f263"}.fa-odnoklassniki-square,.fa-square-odnoklassniki{--fa:"\f264"}.fa-get-pocket{--fa:"\f265"}.fa-wikipedia-w{--fa:"\f266"}.fa-safari{--fa:"\f267"}.fa-chrome{--fa:"\f268"}.fa-firefox{--fa:"\f269"}.fa-opera{--fa:"\f26a"}.fa-internet-explorer{--fa:"\f26b"}.fa-contao{--fa:"\f26d"}.fa-500px{--fa:"\f26e"}.fa-amazon{--fa:"\f270"}.fa-houzz{--fa:"\f27c"}.fa-vimeo-v{--fa:"\f27d"}.fa-black-tie{--fa:"\f27e"}.fa-fonticons{--fa:"\f280"}.fa-reddit-alien{--fa:"\f281"}.fa-edge{--fa:"\f282"}.fa-codiepie{--fa:"\f284"}.fa-modx{--fa:"\f285"}.fa-fort-awesome{--fa:"\f286"}.fa-usb{--fa:"\f287"}.fa-product-hunt{--fa:"\f288"}.fa-mixcloud{--fa:"\f289"}.fa-scribd{--fa:"\f28a"}.fa-bluetooth{--fa:"\f293"}.fa-bluetooth-b{--fa:"\f294"}.fa-gitlab{--fa:"\f296"}.fa-wpbeginner{--fa:"\f297"}.fa-wpforms{--fa:"\f298"}.fa-envira{--fa:"\f299"}.fa-glide{--fa:"\f2a5"}.fa-glide-g{--fa:"\f2a6"}.fa-viadeo{--fa:"\f2a9"}.fa-square-viadeo,.fa-viadeo-square{--fa:"\f2aa"}.fa-snapchat,.fa-snapchat-ghost{--fa:"\f2ab"}.fa-snapchat-square,.fa-square-snapchat{--fa:"\f2ad"}.fa-pied-piper{--fa:"\f2ae"}.fa-first-order{--fa:"\f2b0"}.fa-yoast{--fa:"\f2b1"}.fa-themeisle{--fa:"\f2b2"}.fa-google-plus{--fa:"\f2b3"}.fa-font-awesome,.fa-font-awesome-flag,.fa-font-awesome-logo-full{--fa:"\f2b4"}.fa-linode{--fa:"\f2b8"}.fa-quora{--fa:"\f2c4"}.fa-free-code-camp{--fa:"\f2c5"}.fa-telegram,.fa-telegram-plane{--fa:"\f2c6"}.fa-bandcamp{--fa:"\f2d5"}.fa-grav{--fa:"\f2d6"}.fa-etsy{--fa:"\f2d7"}.fa-imdb{--fa:"\f2d8"}.fa-ravelry{--fa:"\f2d9"}.fa-sellcast{--fa:"\f2da"}.fa-superpowers{--fa:"\f2dd"}.fa-wpexplorer{--fa:"\f2de"}.fa-meetup{--fa:"\f2e0"}.fa-font-awesome-alt,.fa-square-font-awesome-stroke{--fa:"\f35c"}.fa-accessible-icon{--fa:"\f368"}.fa-accusoft{--fa:"\f369"}.fa-adversal{--fa:"\f36a"}.fa-affiliatetheme{--fa:"\f36b"}.fa-algolia{--fa:"\f36c"}.fa-amilia{--fa:"\f36d"}.fa-angrycreative{--fa:"\f36e"}.fa-app-store{--fa:"\f36f"}.fa-app-store-ios{--fa:"\f370"}.fa-apper{--fa:"\f371"}.fa-asymmetrik{--fa:"\f372"}.fa-audible{--fa:"\f373"}.fa-avianex{--fa:"\f374"}.fa-aws{--fa:"\f375"}.fa-bimobject{--fa:"\f378"}.fa-bitcoin{--fa:"\f379"}.fa-bity{--fa:"\f37a"}.fa-blackberry{--fa:"\f37b"}.fa-blogger{--fa:"\f37c"}.fa-blogger-b{--fa:"\f37d"}.fa-buromobelexperte{--fa:"\f37f"}.fa-centercode{--fa:"\f380"}.fa-cloudscale{--fa:"\f383"}.fa-cloudsmith{--fa:"\f384"}.fa-cloudversify{--fa:"\f385"}.fa-cpanel{--fa:"\f388"}.fa-css3-alt{--fa:"\f38b"}.fa-cuttlefish{--fa:"\f38c"}.fa-d-and-d{--fa:"\f38d"}.fa-deploydog{--fa:"\f38e"}.fa-deskpro{--fa:"\f38f"}.fa-digital-ocean{--fa:"\f391"}.fa-discord{--fa:"\f392"}.fa-discourse{--fa:"\f393"}.fa-dochub{--fa:"\f394"}.fa-docker{--fa:"\f395"}.fa-draft2digital{--fa:"\f396"}.fa-dribbble-square,.fa-square-dribbble{--fa:"\f397"}.fa-dyalog{--fa:"\f399"}.fa-earlybirds{--fa:"\f39a"}.fa-erlang{--fa:"\f39d"}.fa-facebook-f{--fa:"\f39e"}.fa-facebook-messenger{--fa:"\f39f"}.fa-firstdraft{--fa:"\f3a1"}.fa-fonticons-fi{--fa:"\f3a2"}.fa-fort-awesome-alt{--fa:"\f3a3"}.fa-freebsd{--fa:"\f3a4"}.fa-gitkraken{--fa:"\f3a6"}.fa-gofore{--fa:"\f3a7"}.fa-goodreads{--fa:"\f3a8"}.fa-goodreads-g{--fa:"\f3a9"}.fa-google-drive{--fa:"\f3aa"}.fa-google-play{--fa:"\f3ab"}.fa-gripfire{--fa:"\f3ac"}.fa-grunt{--fa:"\f3ad"}.fa-gulp{--fa:"\f3ae"}.fa-hacker-news-square,.fa-square-hacker-news{--fa:"\f3af"}.fa-hire-a-helper{--fa:"\f3b0"}.fa-hotjar{--fa:"\f3b1"}.fa-hubspot{--fa:"\f3b2"}.fa-itunes{--fa:"\f3b4"}.fa-itunes-note{--fa:"\f3b5"}.fa-jenkins{--fa:"\f3b6"}.fa-joget{--fa:"\f3b7"}.fa-js{--fa:"\f3b8"}.fa-js-square,.fa-square-js{--fa:"\f3b9"}.fa-keycdn{--fa:"\f3ba"}.fa-kickstarter,.fa-square-kickstarter{--fa:"\f3bb"}.fa-kickstarter-k{--fa:"\f3bc"}.fa-laravel{--fa:"\f3bd"}.fa-line{--fa:"\f3c0"}.fa-lyft{--fa:"\f3c3"}.fa-magento{--fa:"\f3c4"}.fa-medapps{--fa:"\f3c6"}.fa-medrt{--fa:"\f3c8"}.fa-microsoft{--fa:"\f3ca"}.fa-mix{--fa:"\f3cb"}.fa-mizuni{--fa:"\f3cc"}.fa-monero{--fa:"\f3d0"}.fa-napster{--fa:"\f3d2"}.fa-node-js{--fa:"\f3d3"}.fa-npm{--fa:"\f3d4"}.fa-ns8{--fa:"\f3d5"}.fa-nutritionix{--fa:"\f3d6"}.fa-page4{--fa:"\f3d7"}.fa-palfed{--fa:"\f3d8"}.fa-patreon{--fa:"\f3d9"}.fa-periscope{--fa:"\f3da"}.fa-phabricator{--fa:"\f3db"}.fa-phoenix-framework{--fa:"\f3dc"}.fa-playstation{--fa:"\f3df"}.fa-pushed{--fa:"\f3e1"}.fa-python{--fa:"\f3e2"}.fa-red-river{--fa:"\f3e3"}.fa-rendact,.fa-wpressr{--fa:"\f3e4"}.fa-replyd{--fa:"\f3e6"}.fa-resolving{--fa:"\f3e7"}.fa-rocketchat{--fa:"\f3e8"}.fa-rockrms{--fa:"\f3e9"}.fa-schlix{--fa:"\f3ea"}.fa-searchengin{--fa:"\f3eb"}.fa-servicestack{--fa:"\f3ec"}.fa-sistrix{--fa:"\f3ee"}.fa-speakap{--fa:"\f3f3"}.fa-staylinked{--fa:"\f3f5"}.fa-steam-symbol{--fa:"\f3f6"}.fa-sticker-mule{--fa:"\f3f7"}.fa-studiovinari{--fa:"\f3f8"}.fa-supple{--fa:"\f3f9"}.fa-uber{--fa:"\f402"}.fa-uikit{--fa:"\f403"}.fa-uniregistry{--fa:"\f404"}.fa-untappd{--fa:"\f405"}.fa-ussunnah{--fa:"\f407"}.fa-vaadin{--fa:"\f408"}.fa-viber{--fa:"\f409"}.fa-vimeo{--fa:"\f40a"}.fa-vnv{--fa:"\f40b"}.fa-square-whatsapp,.fa-whatsapp-square{--fa:"\f40c"}.fa-whmcs{--fa:"\f40d"}.fa-wordpress-simple{--fa:"\f411"}.fa-xbox{--fa:"\f412"}.fa-yandex{--fa:"\f413"}.fa-yandex-international{--fa:"\f414"}.fa-apple-pay{--fa:"\f415"}.fa-cc-apple-pay{--fa:"\f416"}.fa-fly{--fa:"\f417"}.fa-node{--fa:"\f419"}.fa-osi{--fa:"\f41a"}.fa-react{--fa:"\f41b"}.fa-autoprefixer{--fa:"\f41c"}.fa-less{--fa:"\f41d"}.fa-sass{--fa:"\f41e"}.fa-vuejs{--fa:"\f41f"}.fa-angular{--fa:"\f420"}.fa-aviato{--fa:"\f421"}.fa-ember{--fa:"\f423"}.fa-gitter{--fa:"\f426"}.fa-hooli{--fa:"\f427"}.fa-strava{--fa:"\f428"}.fa-stripe{--fa:"\f429"}.fa-stripe-s{--fa:"\f42a"}.fa-typo3{--fa:"\f42b"}.fa-amazon-pay{--fa:"\f42c"}.fa-cc-amazon-pay{--fa:"\f42d"}.fa-ethereum{--fa:"\f42e"}.fa-korvue{--fa:"\f42f"}.fa-elementor{--fa:"\f430"}.fa-square-youtube,.fa-youtube-square{--fa:"\f431"}.fa-flipboard{--fa:"\f44d"}.fa-hips{--fa:"\f452"}.fa-php{--fa:"\f457"}.fa-quinscape{--fa:"\f459"}.fa-readme{--fa:"\f4d5"}.fa-java{--fa:"\f4e4"}.fa-pied-piper-hat{--fa:"\f4e5"}.fa-creative-commons-by{--fa:"\f4e7"}.fa-creative-commons-nc{--fa:"\f4e8"}.fa-creative-commons-nc-eu{--fa:"\f4e9"}.fa-creative-commons-nc-jp{--fa:"\f4ea"}.fa-creative-commons-nd{--fa:"\f4eb"}.fa-creative-commons-pd{--fa:"\f4ec"}.fa-creative-commons-pd-alt{--fa:"\f4ed"}.fa-creative-commons-remix{--fa:"\f4ee"}.fa-creative-commons-sa{--fa:"\f4ef"}.fa-creative-commons-sampling{--fa:"\f4f0"}.fa-creative-commons-sampling-plus{--fa:"\f4f1"}.fa-creative-commons-share{--fa:"\f4f2"}.fa-creative-commons-zero{--fa:"\f4f3"}.fa-ebay{--fa:"\f4f4"}.fa-keybase{--fa:"\f4f5"}.fa-mastodon{--fa:"\f4f6"}.fa-r-project{--fa:"\f4f7"}.fa-researchgate{--fa:"\f4f8"}.fa-teamspeak{--fa:"\f4f9"}.fa-first-order-alt{--fa:"\f50a"}.fa-fulcrum{--fa:"\f50b"}.fa-galactic-republic{--fa:"\f50c"}.fa-galactic-senate{--fa:"\f50d"}.fa-jedi-order{--fa:"\f50e"}.fa-mandalorian{--fa:"\f50f"}.fa-old-republic{--fa:"\f510"}.fa-phoenix-squadron{--fa:"\f511"}.fa-sith{--fa:"\f512"}.fa-trade-federation{--fa:"\f513"}.fa-wolf-pack-battalion{--fa:"\f514"}.fa-hornbill{--fa:"\f592"}.fa-mailchimp{--fa:"\f59e"}.fa-megaport{--fa:"\f5a3"}.fa-nimblr{--fa:"\f5a8"}.fa-rev{--fa:"\f5b2"}.fa-shopware{--fa:"\f5b5"}.fa-squarespace{--fa:"\f5be"}.fa-themeco{--fa:"\f5c6"}.fa-weebly{--fa:"\f5cc"}.fa-wix{--fa:"\f5cf"}.fa-ello{--fa:"\f5f1"}.fa-hackerrank{--fa:"\f5f7"}.fa-kaggle{--fa:"\f5fa"}.fa-markdown{--fa:"\f60f"}.fa-neos{--fa:"\f612"}.fa-zhihu{--fa:"\f63f"}.fa-alipay{--fa:"\f642"}.fa-the-red-yeti{--fa:"\f69d"}.fa-critical-role{--fa:"\f6c9"}.fa-d-and-d-beyond{--fa:"\f6ca"}.fa-dev{--fa:"\f6cc"}.fa-fantasy-flight-games{--fa:"\f6dc"}.fa-wizards-of-the-coast{--fa:"\f730"}.fa-think-peaks{--fa:"\f731"}.fa-reacteurope{--fa:"\f75d"}.fa-artstation{--fa:"\f77a"}.fa-atlassian{--fa:"\f77b"}.fa-canadian-maple-leaf{--fa:"\f785"}.fa-centos{--fa:"\f789"}.fa-confluence{--fa:"\f78d"}.fa-dhl{--fa:"\f790"}.fa-diaspora{--fa:"\f791"}.fa-fedex{--fa:"\f797"}.fa-fedora{--fa:"\f798"}.fa-figma{--fa:"\f799"}.fa-intercom{--fa:"\f7af"}.fa-invision{--fa:"\f7b0"}.fa-jira{--fa:"\f7b1"}.fa-mendeley{--fa:"\f7b3"}.fa-raspberry-pi{--fa:"\f7bb"}.fa-redhat{--fa:"\f7bc"}.fa-sketch{--fa:"\f7c6"}.fa-sourcetree{--fa:"\f7d3"}.fa-suse{--fa:"\f7d6"}.fa-ubuntu{--fa:"\f7df"}.fa-ups{--fa:"\f7e0"}.fa-usps{--fa:"\f7e1"}.fa-yarn{--fa:"\f7e3"}.fa-airbnb{--fa:"\f834"}.fa-battle-net{--fa:"\f835"}.fa-bootstrap{--fa:"\f836"}.fa-buffer{--fa:"\f837"}.fa-chromecast{--fa:"\f838"}.fa-evernote{--fa:"\f839"}.fa-itch-io{--fa:"\f83a"}.fa-salesforce{--fa:"\f83b"}.fa-speaker-deck{--fa:"\f83c"}.fa-symfony{--fa:"\f83d"}.fa-waze{--fa:"\f83f"}.fa-yammer{--fa:"\f840"}.fa-git-alt{--fa:"\f841"}.fa-stackpath{--fa:"\f842"}.fa-cotton-bureau{--fa:"\f89e"}.fa-buy-n-large{--fa:"\f8a6"}.fa-mdb{--fa:"\f8ca"}.fa-orcid{--fa:"\f8d2"}.fa-swift{--fa:"\f8e1"}.fa-umbraco{--fa:"\f8e8"} \ No newline at end of file diff --git a/web/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 b/web/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..d0b8f69 Binary files /dev/null and b/web/public/vendor/fontawesome/webfonts/fa-brands-400.woff2 differ diff --git a/web/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 b/web/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..c20c7f4 Binary files /dev/null and b/web/public/vendor/fontawesome/webfonts/fa-solid-900.woff2 differ diff --git a/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2 b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2 new file mode 100644 index 0000000..dcfb140 Binary files /dev/null and b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2 differ diff --git a/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2 b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2 new file mode 100644 index 0000000..a3aca77 Binary files /dev/null and b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2 differ diff --git a/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2 b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2 new file mode 100644 index 0000000..e3c55aa Binary files /dev/null and b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2 differ diff --git a/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2 b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2 new file mode 100644 index 0000000..d224faa Binary files /dev/null and b/web/public/vendor/fonts/jetbrains-mono-v24-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2 differ diff --git a/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2 b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2 new file mode 100644 index 0000000..3eeabdd Binary files /dev/null and b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-500.woff2 differ diff --git a/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2 b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2 new file mode 100644 index 0000000..d6e4d1e Binary files /dev/null and b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-600.woff2 differ diff --git a/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2 b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2 new file mode 100644 index 0000000..954c272 Binary files /dev/null and b/web/public/vendor/fonts/plus-jakarta-sans-v12-latin-regular.woff2 differ diff --git a/worker.php b/worker.php new file mode 100644 index 0000000..d27f100 --- /dev/null +++ b/worker.php @@ -0,0 +1,37 @@ +ensureIndexes(); +} catch (Exception $e) { + error_log("Failed to ensure MongoDB indexes: " . $e->getMessage()); +} + +$requestCount = 0; +$maxRequests = Config::getInstance()->get(ConfigKey::WORKER_REQUESTS); + +do { + $running = \frankenphp_handle_request(function () { + + MongoDBClient::getInstance()->reset(); + URL::clear(); + + if (URL::isApi()) { + ApiRouter::getInstance()->run(); + } else { + FrontendRouter::getInstance()->run(); + } + }); + + gc_collect_cycles(); + + $requestCount++; +} while ($running && $requestCount < $maxRequests); \ No newline at end of file