all
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 2m13s

This commit is contained in:
Sam Kintop
2026-04-30 09:44:02 -05:00
parent 0a30837e3d
commit bf3870ccca
111 changed files with 8383 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
vendor/
.git/
.github/
Dockerfile

66
.github/workflows/publish.yml vendored Normal file
View File

@@ -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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*.log
*.cache
.idea
/vendor/
config.json

39
Dockerfile Normal file
View File

@@ -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"]

19
LICENSE Normal file
View File

@@ -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.

5
build.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
\Aternos\Mclogs\Frontend\Assets\AssetLoader::getInstance()->writeCache();

28
composer.json Normal file
View File

@@ -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/"
}
}
}

413
composer.lock generated Normal file
View File

@@ -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"
}

32
dev/compose.yaml Normal file
View File

@@ -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:

7
dev/dev.ini Normal file
View File

@@ -0,0 +1,7 @@
opcache.enable=1
opcache.enable_cli=1
opcache.validate_timestamps=1
opcache.revalidate_freq=0
opcache.jit=off

28
docker/Caddyfile Normal file
View File

@@ -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
}
}

View File

@@ -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:

7
docker/mclogs.ini Normal file
View File

@@ -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

36
example.config.json Normal file
View File

@@ -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
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\LogContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\CodexLogResponse;
use Aternos\Mclogs\Log;
class AnalyseLogAction extends ApiAction
{
public function runApi(): ApiResponse
{
$data = new LogContentParser()->getContent();
if ($data instanceof ApiError) {
return $data;
}
$content = $data['content'];
$log = new Log()->setContent($content);
return new CodexLogResponse($log->getCodexLog());
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\ContentParser;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Router\Action;
abstract class ApiAction extends Action
{
abstract protected function runApi(): ApiResponse;
protected function getAllowedOrigin(): string
{
return '*';
}
protected function shouldAllowCredentials(): bool
{
return false;
}
public function run(): bool
{
header('Access-Control-Allow-Origin: ' . $this->getAllowedOrigin());
header('Access-Control-Allow-Headers: *');
if ($this->shouldAllowCredentials()) {
header('Access-Control-Allow-Credentials: true');
}
header("Accept-Encoding: " . implode(",", ContentParser::getSupportedEncodings()));
$response = $this->runApi();
$response->output();
return true;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\ContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\MultiResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Storage\MongoDBClient;
class BulkDeleteLogsAction extends ApiAction
{
public const int MAX_IDS = 256;
/**
* @return ApiResponse
*/
protected function runApi(): ApiResponse
{
$data = new ContentParser()->getContent();
if ($data instanceof ApiError) {
return $data;
}
if (count($data) === 0) {
return new ApiError(400, "No logs provided.");
}
if (count($data) > static::MAX_IDS) {
return new ApiError(400, "Too many logs. Maximum is " . static::MAX_IDS . ".");
}
$ids = [];
foreach ($data as $log) {
if (!is_array($log)) {
return new ApiError(400, "Each entry must be an object with 'id' and 'token' fields.");
}
if (!isset($log["id"]) || !is_string($log["id"]) ||
!preg_match("/^" . Id::PATTERN . "$/", $log["id"])) {
return new ApiError(400, "Each log must have a valid 'id' field.");
}
if (!isset($log["token"]) || !is_string($log["token"])) {
return new ApiError(400, "Each log must have a valid 'token' field.");
}
$ids[] = $log["id"];
}
$logs = Log::findAll($ids, false);
$deleteIds = [];
$response = new MultiResponse();
foreach ($data as $log) {
$id = $log["id"];
$token = $log["token"];
$log = $logs[$id] ?? null;
if (!$log) {
$response->addResponse($id, new ApiError(404, "Log not found."));
continue;
}
$logToken = $log->getToken();
if (!$logToken || !$logToken->matches($token)) {
$response->addResponse($id, new ApiError(403, "Invalid token."));
continue;
}
$deleteIds[] = $id;
$response->addResponse($id, new ApiResponse());
}
MongoDBClient::getInstance()->deleteLogs($deleteIds);
return $response;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\LogContentParser;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LogResponse;
use Aternos\Mclogs\Data\MetadataEntry;
use Aternos\Mclogs\Log;
class CreateLogAction extends ApiAction
{
protected bool $includeCookie = false;
protected bool $includeToken = true;
public function runApi(): ApiResponse
{
$data = new LogContentParser()->getContent();
if ($data instanceof ApiError) {
return $data;
}
$content = $data['content'];
$metadata = [];
if (isset($data['metadata']) && is_array($data['metadata'])) {
$metadata = MetadataEntry::allFromArray($data['metadata']);
}
$source = null;
if (isset($data['source']) && is_string($data['source'])) {
$source = $data['source'];
}
$log = Log::create($content, $metadata, $source);
if ($this->includeCookie) {
$log->setTokenCookie();
}
return new LogResponse($log, $this->includeToken);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class DeleteLogAction extends ApiAction
{
protected function getRequestToken(): ?string
{
$authorizationHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
if (!$authorizationHeader) {
return null;
}
$parts = explode(" ", $authorizationHeader);
return $parts[1] ?? null;
}
/**
* @return ApiResponse
*/
protected function runApi(): ApiResponse
{
$requestToken = $this->getRequestToken();
if (!$requestToken) {
return new ApiError(400, "Missing token.");
}
$id = new Id(URL::getLastPathPart());
$log = Log::find($id);
if (!$log) {
return new ApiError(404, "Log not found.");
}
$token = $log->getToken();
if (!$token || !$token->matches($requestToken)) {
return new ApiError(403, "Invalid token.");
}
$deleted = $log->delete();
if (!$deleted) {
return new ApiError(500, "Failed to delete log.");
}
$this->handleDeletedLog($log);
return new ApiResponse();
}
protected function handleDeletedLog(Log $log): void
{
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Router\Action;
class EmptyAction extends Action
{
public function run(): bool
{
return true;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
class EndpointNotFoundAction extends ApiAction
{
protected function runApi(): ApiResponse
{
return new ApiError(404, "Could not find endpoint.");
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\FiltersResponse;
class GetFiltersAction extends ApiAction
{
protected function runApi(): ApiResponse
{
return new FiltersResponse();
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LimitsResponse;
class GetLimitsAction extends ApiAction
{
protected function runApi(): ApiResponse
{
return new LimitsResponse();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\LogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class LogInfoAction extends ApiAction
{
/**
* @return ApiResponse
*/
protected function runApi(): ApiResponse
{
$id = new Id(URL::getLastPathPart());
$log = Log::find($id);
if (!$log) {
return new ApiError(404, "Log not found.");
}
return new LogResponse($log);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\CodexLogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class LogInsightsAction extends ApiAction
{
/**
* @return ApiResponse
*/
protected function runApi(): ApiResponse
{
$id = new Id(URL::getLastPathPart());
$log = Log::find($id);
if (!$log) {
return new ApiError(404, "Log not found.");
}
$codexLog = $log->getCodexLog();
$codexLog->setIncludeEntries(false);
return new CodexLogResponse($codexLog);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
class RateLimitErrorAction extends ApiAction
{
protected function runApi(): ApiResponse
{
return new ApiError(
429,
"Unfortunately you have exceeded the rate limit for the current time period. Please try again later."
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Aternos\Mclogs\Api\Action;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\RawLogResponse;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class RawLogAction extends ApiAction
{
/**
* @return ApiResponse
*/
protected function runApi(): ApiResponse
{
$id = new Id(URL::getLastPathPart());
$log = Log::find($id);
if (!$log) {
return new ApiError(404, "Log not found.");
}
return new RawLogResponse($log);
}
}

29
src/Api/ApiRouter.php Normal file
View File

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

85
src/Api/ContentParser.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace Aternos\Mclogs\Api;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
/**
* Utility class for reading log content from the http request
*/
class ContentParser
{
protected const int MAX_ENCODING_STEPS = 5;
/**
* Get all supported content encodings
* @return string[]
*/
public static function getSupportedEncodings(): array
{
return ["deflate", "gzip", "x-gzip"];
}
/**
* Get the content from the http request
*
* @return array|ApiError An array with the content or an ApiError on failure
*/
public function getContent(): array|ApiError
{
$limit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES) * 2;
$body = file_get_contents('php://input', length: $limit + 1);
if ($body === false) {
return new ApiError(500, "Failed to read request body.");
}
if (strlen($body) > $limit) {
return new ApiError(413, "Request body exceeds maximum allowed size.");
}
$encodingHeader = $_SERVER['HTTP_CONTENT_ENCODING'] ?? '';
if ($encodingHeader) {
$encodingSteps = explode(',', $encodingHeader);
if (count($encodingSteps) > static::MAX_ENCODING_STEPS) {
return new ApiError(400, "Too many Content-Encoding steps.");
}
foreach (array_reverse($encodingSteps) as $step) {
switch (trim(strtolower($step))) {
case "deflate":
$body = @gzinflate($body, $limit);
break;
case "x-gzip":
case "gzip":
$body = @gzdecode($body, $limit);
break;
default:
return new ApiError(415, "Unsupported Content-Encoding: " . htmlspecialchars($step));
}
if ($body === false) {
return new ApiError(400, "Failed to decode request body with encoding: " . htmlspecialchars($step));
}
}
}
$contentTypeHeader = $_SERVER['CONTENT_TYPE'] ?? '';
if ($pos = strpos($contentTypeHeader, ';')) {
$contentTypeHeader = substr($contentTypeHeader, 0, $pos);
}
switch ($contentTypeHeader) {
case "application/x-www-form-urlencoded":
parse_str($body, $data);
break;
case "application/json":
$data = @json_decode($body, true);
if (!is_array($data)) {
return new ApiError(400, "Failed to parse JSON body.");
}
break;
default:
return new ApiError(415, "Unsupported Content-Type: " . htmlspecialchars($contentTypeHeader));
}
return $data;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Aternos\Mclogs\Api;
use Aternos\Mclogs\Api\Response\ApiError;
class LogContentParser extends ContentParser
{
/**
* @inheritDoc
*/
public function getContent(): array|ApiError
{
$data = parent::getContent();
if ($data instanceof ApiError) {
return $data;
}
if (!isset($data['content'])) {
return new ApiError(400, "Required field 'content' not found.");
}
if (empty($data['content'])) {
return new ApiError(400, "Required field 'content' is empty.");
}
if (!is_string($data['content'])) {
return new ApiError(400, "Field 'content' must be a string.");
}
return $data;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Aternos\Mclogs\Api\Response;
class ApiError extends ApiResponse
{
protected bool $success = false;
public function __construct(
int $httpCode,
protected string $message,
)
{
$this->setHttpCode($httpCode);
}
public function jsonSerialize(): array
{
$data = parent::jsonSerialize();
$data['error'] = $this->message;
return $data;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Aternos\Mclogs\Api\Response;
class ApiResponse implements \JsonSerializable
{
protected int $httpCode = 200;
protected bool $success = true;
public function jsonSerialize(): array
{
return [
'success' => $this->success,
];
}
/**
* @param int $httpCode
* @return $this
*/
public function setHttpCode(int $httpCode): static
{
$this->httpCode = $httpCode;
return $this;
}
/**
* @return int
*/
public function getHttpCode(): int
{
return $this->httpCode;
}
/**
* @param bool $success
* @return $this
*/
public function setSuccess(bool $success): static
{
$this->success = $success;
return $this;
}
/**
* @return bool
*/
public function isSuccess(): bool
{
return $this->success;
}
/**
* @return $this
*/
public function output(): static
{
header('Content-Type: application/json');
http_response_code($this->httpCode);
echo json_encode($this, JSON_UNESCAPED_SLASHES);
return $this;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Aternos\Mclogs\Api\Response;
use Aternos\Codex\Log\LogInterface;
class CodexLogResponse extends ApiResponse
{
public function __construct(protected LogInterface $codexLog)
{
}
public function jsonSerialize(): array
{
return array_merge(parent::jsonSerialize(), $this->codexLog->jsonSerialize());
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Aternos\Mclogs\Api\Response;
use Aternos\Mclogs\Filter\Filter;
class FiltersResponse extends ApiResponse
{
public function jsonSerialize(): array
{
return Filter::getAll();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Aternos\Mclogs\Api\Response;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
class LimitsResponse extends ApiResponse
{
public function jsonSerialize(): array
{
$config = Config::getInstance();
$data = parent::jsonSerialize();
$data['storageTime'] = $config->get(ConfigKey::STORAGE_TTL);
$data['maxLength'] = $config->get(ConfigKey::STORAGE_LIMIT_BYTES);
$data['maxLines'] = $config->get(ConfigKey::STORAGE_LIMIT_LINES);
return $data;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Aternos\Mclogs\Api\Response;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class LogResponse extends ApiResponse
{
public function __construct(
protected Log $log,
protected bool $withToken = false,
protected bool $withInsights = false,
protected bool $withRaw = false,
protected bool $withParsed = false)
{
$this->loadFromGet();
}
public function loadFromGet(): static
{
$url = URL::getCurrent();
$query = $url->getQuery();
if (empty($query)) {
return $this;
}
parse_str($url->getQuery(), $get);
$this->withInsights = isset($get['insights']);
$this->withRaw = isset($get['raw']);
$this->withParsed = isset($get['parsed']);
return $this;
}
public function jsonSerialize(): array
{
$data = parent::jsonSerialize();
$data['id'] = $this->log->getId();
$data['source'] = $this->log->getSource();
$data['created'] = $this->log->getCreated()?->toDateTime()->getTimestamp();
$data['expires'] = $this->log->getExpires()?->toDateTime()->getTimestamp();
$data['size'] = $this->log->getSize();
$data['lines'] = $this->log->getLinesCount();
$data['errors'] = $this->log->getErrorsCount();
$data['url'] = $this->log->getUrl()->toString();
$data['raw'] = $this->log->getRawURL()->toString();
if ($this->withToken) {
$data['token'] = $this->log->getToken();
}
$data['metadata'] = $this->log->getMetadata();
if ($this->withInsights || $this->withRaw || $this->withParsed) {
$data['content'] = [];
}
if ($this->withInsights) {
$data['content']['insights'] = $this->log->getCodexLog()->setIncludeEntries(false);
}
if ($this->withRaw) {
$data['content']['raw'] = $this->log->getContent();
}
if ($this->withParsed) {
$data['content']['parsed'] = $this->log->getCodexLog()->getEntries();
}
return $data;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Aternos\Mclogs\Api\Response;
class MultiResponse extends ApiResponse
{
protected int $httpCode = 207;
/**
* @var ApiResponse[]
*/
protected array $responses = [];
/**
* @param string $id
* @param ApiResponse $response
* @return $this
*/
public function addResponse(string $id, ApiResponse $response): static
{
$this->responses[$id] = $response;
return $this;
}
public function jsonSerialize(): array
{
$response = parent::jsonSerialize();
$results = [];
foreach ($this->responses as $id => $apiResponse) {
$result = $apiResponse->jsonSerialize();
$result["id"] = $id;
$result["status"] = $apiResponse->getHttpCode();
$results[] = $result;
}
$response["results"] = $results;
return $response;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Aternos\Mclogs\Api\Response;
use Aternos\Mclogs\Log;
class RawLogResponse extends ApiResponse
{
public function __construct(
protected Log $log)
{
}
public function output(): static
{
header('Content-Type: text/plain');
echo $this->log->getContent();
return $this;
}
}

43
src/Cache/CacheEntry.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace Aternos\Mclogs\Cache;
use Aternos\Mclogs\Storage\MongoDBClient;
use MongoDB\BSON\UTCDateTime;
class CacheEntry
{
public function __construct(protected string $key)
{
}
/**
* @return string|null
*/
public function get(): ?string
{
$result = MongoDBClient::getInstance()->getCacheCollection()->findOne([
"_id" => $this->key
]);
return $result?->data;
}
/**
* @param string $data
* @param int $ttl
* @return $this
*/
public function set(string $data, int $ttl = 24 * 60 * 60): static
{
MongoDBClient::getInstance()->getCacheCollection()->updateOne(
["_id" => $this->key],
['$set' => [
'data' => $data,
'expires' => new UTCDateTime((time() + $ttl) * 1000)
]],
['upsert' => true]
);
return $this;
}
}

88
src/Config/Config.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
namespace Aternos\Mclogs\Config;
use Aternos\Mclogs\Util\Singleton;
use Aternos\Mclogs\Util\URL;
class Config
{
use Singleton;
protected array $jsonData = [];
protected function __construct()
{
$configPath = __DIR__ . "/../../config.json";
if (file_exists($configPath)) {
$jsonContent = file_get_contents($configPath);
$data = @json_decode($jsonContent, true);
if (is_array($data)) {
$this->jsonData = $data;
}
}
}
/**
* Get config value by checking environment variable, then config file, then default value
*
* @param ConfigKey $key
* @return mixed
*/
public function get(ConfigKey $key): mixed
{
$env = getenv($key->getEnvironmentVariable());
if ($env !== false) {
if ($env === "true") {
return true;
} else if ($env === "false") {
return false;
}
return $env;
}
$json = $this->getJsonValue($key->getJSONPath());
if ($json !== null) {
return $json;
}
return $key->getDefaultValue();
}
/**
* @return string
*/
public function getName(): string
{
return $this->get(ConfigKey::FRONTEND_NAME) ?? URL::getBase()->getHost();
}
/**
* Recursively get a value from the json data by path
*
* @param array $path
* @param array|null $data
* @return mixed
*/
protected function getJsonValue(array $path, ?array $data = null): mixed
{
if ($data === null) {
$data = $this->jsonData;
}
$nextKey = array_shift($path);
if (!isset($data[$nextKey])) {
return null;
}
$nextData = $data[$nextKey];
if (count($path) === 0) {
return $nextData;
}
if (!is_array($nextData)) {
return null;
}
return $this->getJsonValue($path, $nextData);
}
}

80
src/Config/ConfigKey.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
namespace Aternos\Mclogs\Config;
enum ConfigKey
{
case STORAGE_TTL;
case STORAGE_LIMIT_BYTES;
case STORAGE_LIMIT_LINES;
case MONGODB_URL;
case MONGODB_DATABASE;
case ID_LENGTH;
case LEGAL_ABUSE;
case LEGAL_IMPRINT;
case LEGAL_PRIVACY;
case FRONTEND_NAME;
case FRONTEND_ANALYTICS;
case FRONTEND_ASSETS_INTEGRITY;
case FRONTEND_COLOR_BACKGROUND;
case FRONTEND_COLOR_TEXT;
case FRONTEND_COLOR_ACCENT;
case FRONTEND_COLOR_ERROR;
case WORKER_REQUESTS;
/**
* Get the default value for the config key
*
* @return string|int|null
*/
public function getDefaultValue(): string|int|null
{
return match ($this) {
ConfigKey::STORAGE_TTL => 90 * 24 * 60 * 60,
ConfigKey::STORAGE_LIMIT_BYTES => 10 * 1024 * 1024,
ConfigKey::STORAGE_LIMIT_LINES => 25000,
ConfigKey::MONGODB_URL => 'mongodb://mongo:27017',
ConfigKey::MONGODB_DATABASE => 'mclogs',
ConfigKey::ID_LENGTH => 7,
ConfigKey::FRONTEND_ANALYTICS => false,
ConfigKey::FRONTEND_ASSETS_INTEGRITY => true,
ConfigKey::FRONTEND_COLOR_BACKGROUND => "#1a1a1a",
ConfigKey::FRONTEND_COLOR_TEXT => "#e8e8e8",
ConfigKey::FRONTEND_COLOR_ACCENT => "#5cb85c",
ConfigKey::FRONTEND_COLOR_ERROR => "#f62451",
ConfigKey::WORKER_REQUESTS => 500,
default => null
};
}
/**
* Get environment variable name
*
* @return string
*/
public function getEnvironmentVariable(): string
{
return "MCLOGS_" . $this->name;
}
/**
* @return array
*/
public function getJSONPath(): array
{
$parts = explode("_", $this->name);
return array_map(fn($part) => strtolower($part), $parts);
}
}

138
src/Data/Deobfuscator.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Aternos\Mclogs\Data;
use Aternos\Codex\Analysis\Information;
use Aternos\Codex\Log\AnalysableLog;
use Aternos\Codex\Log\LogInterface;
use Aternos\Codex\Minecraft\Analysis\Information\Vanilla\VanillaVersionInformation;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\Fabric\FabricLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaClientLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaCrashReportLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaNetworkProtocolErrorReportLog;
use Aternos\Codex\Minecraft\Log\Minecraft\Vanilla\VanillaServerLog;
use Aternos\Mclogs\Cache\CacheEntry;
use Aternos\Sherlock\MapLocator\FabricMavenMapLocator;
use Aternos\Sherlock\MapLocator\LauncherMetaMapLocator;
use Aternos\Sherlock\Maps\GZURLYarnMap;
use Aternos\Sherlock\Maps\ObfuscationMap;
use Aternos\Sherlock\Maps\URLVanillaObfuscationMap;
use Aternos\Sherlock\Maps\VanillaObfuscationMap;
use Aternos\Sherlock\Maps\YarnMap;
use Aternos\Sherlock\ObfuscatedString;
use Exception;
class Deobfuscator
{
public function __construct(protected LogInterface $codexLog)
{
}
public function deobfuscate(): ?string
{
if (!$this->codexLog instanceof AnalysableLog) {
return null;
}
if (!$this->codexLog instanceof VanillaLog) {
return null;
}
$analysis = $this->codexLog->analyse();
/**
* @var ?Information $version
*/
$version = $analysis->getFilteredInsights(VanillaVersionInformation::class)[0] ?? null;
if (!$version) {
return null;
}
$version = $version->getValue();
try {
$map = $this->getObfuscationMap($version);
} catch (Exception) {
$map = null;
}
if ($map === null) {
return null;
}
$obfuscatedContent = new ObfuscatedString($this->codexLog->getLogFile()->getContent(), $map);
if ($content = $obfuscatedContent->getMappedContent()) {
return $content;
}
return null;
}
/**
* Get the obfuscation map matching this log
*
* @param $version
* @return ObfuscationMap|null
* @throws Exception
*/
protected function getObfuscationMap($version): ?ObfuscationMap
{
if (in_array(get_class($this->codexLog), [
VanillaServerLog::class,
VanillaClientLog::class,
VanillaCrashReportLog::class,
VanillaNetworkProtocolErrorReportLog::class
])) {
$urlCache = new CacheEntry("sherlock:vanilla:$version:client");
$mapURL = $urlCache->get();
if (!$mapURL) {
$mapURL = new LauncherMetaMapLocator($version, "client")->findMappingURL();
if (!$mapURL) {
return null;
}
$urlCache->set($mapURL, 30 * 24 * 60 * 60);
}
try {
$mapCache = new CacheEntry("sherlock:$mapURL");
if ($mapContent = $mapCache->get()) {
$map = new VanillaObfuscationMap($mapContent);
} else {
$map = new URLVanillaObfuscationMap($mapURL);
$mapCache->set($map->getContent());
}
} catch (Exception) {
}
return $map ?? null;
}
if ($this->codexLog instanceof FabricLog) {
$urlCache = new CacheEntry("sherlock:yarn:$version:server");
$mapURL = $urlCache->get();
if (!$mapURL) {
$mapURL = new FabricMavenMapLocator($version)->findMappingURL();
if (!$mapURL) {
return null;
}
$urlCache->set($mapURL, 24 * 60 * 60);
}
try {
$mapCache = new CacheEntry("sherlock:$mapURL");
if ($mapContent = $mapCache->get()) {
$map = new YarnMap($mapContent);
} else {
$map = new GZURLYarnMap($mapURL);
$mapCache->set($map->getContent());
}
} catch (Exception) {
}
return $map ?? null;
}
return null;
}
}

198
src/Data/MetadataEntry.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
namespace Aternos\Mclogs\Data;
use MongoDB\BSON\Serializable;
use MongoDB\Model\BSONDocument;
class MetadataEntry implements \JsonSerializable, Serializable
{
public const int MAX_ENTRIES = 100;
protected const int MAX_KEY_LENGTH = 64;
protected const int MAX_LABEL_LENGTH = 128;
protected const int MAX_VALUE_LENGTH = 1024;
protected ?string $key = null;
protected mixed $value = null;
protected ?string $label = null;
protected bool $visible = true;
/**
* @param iterable|null $dataArray
* @return MetadataEntry[]
*/
public static function allFromArray(?iterable $dataArray): array
{
if ($dataArray === null) {
return [];
}
$entries = [];
foreach ($dataArray as $data) {
if (is_array($data)) {
$entry = static::fromArray($data);
} else if (is_object($data)) {
$entry = static::fromObject($data);
} else {
continue;
}
if ($entry !== null) {
$entries[] = $entry;
}
if (count($entries) >= static::MAX_ENTRIES) {
break;
}
}
return $entries;
}
/**
* @param array $data
* @return MetadataEntry|null
*/
public static function fromArray(array $data): ?MetadataEntry
{
$entry = new MetadataEntry()->setFromArray($data);
if (!$entry->isValid()) {
return null;
}
return $entry;
}
/**
* @param object $data
* @return MetadataEntry|null
*/
public static function fromObject(object $data): ?MetadataEntry
{
if ($data instanceof BSONDocument) {
$arrayData = $data->getArrayCopy();
} else {
$arrayData = get_object_vars($data);
}
return static::fromArray($arrayData);
}
public function jsonSerialize(): array
{
return [
"key" => $this->key,
"value" => $this->value,
"label" => $this->label,
"visible" => $this->visible,
];
}
public function bsonSerialize(): array
{
return $this->jsonSerialize();
}
public function getKey(): ?string
{
return $this->key;
}
public function setKey(?string $key): static
{
if (is_string($key) && strlen($key) > static::MAX_KEY_LENGTH) {
$key = substr($key, 0, static::MAX_KEY_LENGTH);
}
$this->key = $key;
return $this;
}
public function getValue(): mixed
{
return $this->value;
}
/**
* @param mixed $value
* @return $this
*/
public function setValue(mixed $value): static
{
if (is_string($value)) {
if (strlen($value) > static::MAX_VALUE_LENGTH) {
$value = substr($value, 0, static::MAX_VALUE_LENGTH);
}
$this->value = $value;
return $this;
}
if (is_int($value) || is_float($value) || is_bool($value) || is_null($value)) {
$this->value = $value;
return $this;
}
$encodedValue = @json_encode($value);
if ($encodedValue === false) {
$this->value = null;
return $this;
}
if (strlen($encodedValue) > static::MAX_VALUE_LENGTH) {
$encodedValue = substr($encodedValue, 0, static::MAX_VALUE_LENGTH);
}
$this->value = $encodedValue;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function getDisplayLabel(): ?string
{
return $this->label ?? $this->key;
}
public function getDisplayValue(): string
{
return $this->value;
}
public function setLabel(?string $label): static
{
if (is_string($label) && strlen($label) > static::MAX_LABEL_LENGTH) {
$label = substr($label, 0, static::MAX_LABEL_LENGTH);
}
$this->label = $label;
return $this;
}
public function isVisible(): bool
{
return $this->visible;
}
public function setVisible(bool $visible): static
{
$this->visible = $visible;
return $this;
}
public function isValid(): bool
{
return $this->key !== null && $this->value !== null;
}
/**
* @param array $data
* @return $this
*/
public function setFromArray(array $data): static
{
if (isset($data['key']) && is_string($data['key'])) {
$this->setKey($data['key']);
}
if (isset($data['value'])) {
$this->setValue($data['value']);
}
if (isset($data['label']) && is_string($data['label'])) {
$this->setLabel($data['label']);
}
if (isset($data['visible']) && is_bool($data['visible'])) {
$this->setVisible($data['visible']);
}
return $this;
}
}

42
src/Data/Token.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace Aternos\Mclogs\Data;
use Random\RandomException;
class Token implements \JsonSerializable
{
public function __construct(protected ?string $value = null)
{
if ($this->value === null) {
$this->generate();
}
}
/**
* @param string $token
* @return bool
*/
public function matches(string $token): bool
{
return hash_equals($this->value, $token);
}
public function jsonSerialize(): string
{
return $this->value;
}
/**
* @throws RandomException
*/
protected function generate(): void
{
$this->value = bin2hex(random_bytes(32));
}
public function get(): ?string
{
return $this->value;
}
}

16
src/Detective.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Aternos\Mclogs;
use Aternos\Codex\Minecraft\Log\Minecraft\MinecraftLog;
class Detective extends \Aternos\Codex\Detective\Detective
{
protected string $defaultLogClass = MinecraftLog::class;
public function __construct()
{
$this->addDetective(new \Aternos\Codex\Minecraft\Detective\Detective())
->addDetective(new \Aternos\Codex\Hytale\Detective\Detective());
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Aternos\Mclogs\Filter;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;
class AccessTokenFilter extends RegexFilter
{
/**
* @inheritDoc
*/
protected function getPatterns(): array
{
return [
new PatternWithReplacement('\(Session ID is token:[^:]+\:[^)]+\)', '(Session ID is token:****************:****************)'),
new PatternWithReplacement('--accessToken [^ ]+', '--accessToken ****************:****************'),
new PatternWithReplacement('"authToken":"[^"]+"', '"authToken":"****************"'),
new PatternWithReplacement('"refreshToken":"[^"]+"', '"refreshToken":"****************"'),
];
}
}

75
src/Filter/Filter.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
namespace Aternos\Mclogs\Filter;
abstract class Filter implements \JsonSerializable
{
/**
* @var Filter[]|null
*/
protected static ?array $filter = null;
/**
* Get all filters
*
* @return Filter[]
*/
public static function getAll(): array
{
if (static::$filter !== null) {
return static::$filter;
}
return static::$filter = [
new TrimFilter(),
new LimitBytesFilter(),
new LimitLinesFilter(),
new IPv4Filter(),
new IPv6Filter(),
new UsernameFilter(),
new AccessTokenFilter(),
];
}
/**
* Filter the $data string with all filters and return it
*
* @param string $data
* @return string
*/
public static function filterAll(string $data): string
{
foreach (static::getAll() as $filter) {
$data = $filter->filter($data);
}
return $data;
}
/**
* @return FilterType
*/
abstract public function getType(): FilterType;
/**
* @return array
*/
abstract public function getData(): mixed;
/**
* @return array
*/
public function jsonSerialize(): array
{
return [
"type" => $this->getType()->value,
"data" => $this->getData(),
];
}
/**
* Filter the $data string and return it
*
* @param string $data
* @return string
*/
abstract public function filter(string $data): string;
}

11
src/Filter/FilterType.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace Aternos\Mclogs\Filter;
enum FilterType: string
{
case TRIM = 'trim';
case LIMIT_BYTES = 'limit-bytes';
case LIMIT_LINES = 'limit-lines';
case REGEX = 'regex';
}

32
src/Filter/IPv4Filter.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace Aternos\Mclogs\Filter;
use Aternos\Mclogs\Filter\Pattern\Pattern;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;
class IPv4Filter extends RegexFilter
{
/**
* @inheritDoc
*/
protected function getPatterns(): array
{
return [
new PatternWithReplacement('(?<!version:? )(?<!([0-9]|-|\w))([0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])', '**.**.**.**'),
];
}
/**
* @inheritDoc
*/
protected function getExemptions(): array
{
return [
new Pattern('^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$'),
new Pattern('^0\.0\.0\.0$'),
new Pattern('^1\.[01]\.[01]\.1$'),
new Pattern('^8\.8\.[84]\.[84]$'),
];
}
}

30
src/Filter/IPv6Filter.php Normal file
View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace Aternos\Mclogs\Filter;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
class LimitBytesFilter extends Filter
{
/**
* Filter the $data string and return it
*
* Cuts the length down to maxLength
*
* @param string $data
* @return string
*/
public function filter(string $data): string
{
$lengthLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES);
return mb_strcut($data, 0, $lengthLimit);
}
/**
* @return FilterType
*/
public function getType(): FilterType
{
return FilterType::LIMIT_BYTES;
}
/**
* @return array
*/
public function getData(): array
{
return [
"limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_BYTES)
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Aternos\Mclogs\Filter;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
class LimitLinesFilter extends Filter
{
/**
* Filter the $data string and return it
*
* Cuts the lines down to maxLines
*
* @param string $data
* @return string
*/
public function filter(string $data): string
{
$linesLimit = Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES);
return implode("\n", array_slice(explode("\n", $data), 0, $linesLimit));
}
/**
* @return FilterType
*/
public function getType(): FilterType
{
return FilterType::LIMIT_LINES;
}
/**
* @return array
*/
public function getData(): array
{
return [
"limit" => Config::getInstance()->get(ConfigKey::STORAGE_LIMIT_LINES)
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Aternos\Mclogs\Filter\Pattern;
/**
* https://www.php.net/manual/en/reference.pcre.pattern.modifiers.php
*/
enum Modifier: string implements \JsonSerializable
{
case CASELESS = 'i';
public function jsonSerialize(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Aternos\Mclogs\Filter\Pattern;
class Pattern implements \JsonSerializable
{
protected const string DELIMITER = '/';
/**
* @param string $pattern
* @param Modifier[] $modifiers
*/
public function __construct(
protected string $pattern,
protected array $modifiers = [Modifier::CASELESS]
)
{
}
/**
* Get the full regex pattern with delimiters and modifiers
*
* @return string
*/
public function get(): string
{
$modifiersString = '';
foreach ($this->modifiers as $modifier) {
$modifiersString .= $modifier->value;
}
return static::DELIMITER . $this->pattern . static::DELIMITER . $modifiersString;
}
public function getPattern(): string
{
return $this->pattern;
}
public function getModifiers(): array
{
return $this->modifiers;
}
public function jsonSerialize(): array
{
return [
'pattern' => $this->getPattern(),
'modifiers' => $this->getModifiers()
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Aternos\Mclogs\Filter\Pattern;
class PatternWithReplacement extends Pattern
{
public function __construct(string $pattern, protected string $replacement, array $modifiers = [Modifier::CASELESS])
{
parent::__construct($pattern, $modifiers);
}
public function getReplacement(): string
{
return $this->replacement;
}
public function jsonSerialize(): array
{
return array_merge(
parent::jsonSerialize(),
[
'replacement' => $this->getReplacement()
]
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Aternos\Mclogs\Filter;
use Aternos\Mclogs\Filter\Pattern\Pattern;
use Aternos\Mclogs\Filter\Pattern\PatternWithReplacement;
abstract class RegexFilter extends Filter
{
/**
* @return PatternWithReplacement[]
*/
abstract protected function getPatterns(): array;
/**
* @return Pattern[]
*/
protected function getExemptions(): array
{
return [];
}
/**
* @inheritDoc
*/
public function getType(): FilterType
{
return FilterType::REGEX;
}
/**
* @inheritDoc
*/
public function getData(): array
{
return [
"patterns" => $this->getPatterns(),
"exemptions" => $this->getExemptions(),
];
}
/**
* @inheritDoc
*/
public function filter(string $data): string
{
foreach ($this->getPatterns() as $pattern) {
$data = preg_replace_callback($pattern->get(), function ($matches) use ($pattern) {
foreach ($this->getExemptions() as $exemptionPattern) {
if (preg_match($exemptionPattern->get(), $matches[0])) {
return $matches[0];
}
}
return $pattern->getReplacement();
}, $data);
}
return $data;
}
}

29
src/Filter/TrimFilter.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace Aternos\Mclogs\Filter;
class TrimFilter extends Filter
{
/**
* Filter the $data string and return it
*
* Trim pre and after whitespace
*
* @param string $data
* @return string
*/
public function filter(string $data): string
{
return trim($data);
}
public function getType(): FilterType
{
return FilterType::TRIM;
}
public function getData(): object
{
return new \stdClass();
}
}

View File

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

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Router\Action;
class ApiDocsAction extends Action
{
public function run(): bool
{
require __DIR__ . "/../../../web/frontend/api-docs.php";
return true;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Util\URL;
class CreateLogAction extends \Aternos\Mclogs\Api\Action\CreateLogAction
{
protected bool $includeCookie = true;
protected bool $includeToken = false;
protected function getAllowedOrigin(): string
{
return URL::getBase()->toString();
}
protected function shouldAllowCredentials(): bool
{
return true;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Frontend\Cookie\TokenCookie;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Util\URL;
class DeleteLogAction extends \Aternos\Mclogs\Api\Action\DeleteLogAction
{
protected function getAllowedOrigin(): string
{
return URL::getBase()->toString();
}
protected function shouldAllowCredentials(): bool
{
return true;
}
protected function getRequestToken(): ?string
{
return new TokenCookie()->getValue();
}
protected function handleDeletedLog(Log $log): void
{
new TokenCookie()->setLog($log)->delete();
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Router\Action;
class FaviconAction extends Action
{
public function run(): bool
{
header('Content-Type: image/svg+xml');
require __DIR__ . "/../../../web/frontend/parts/favicon.php";
return true;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Router\Action;
class NotFoundAction extends Action
{
public function run(): bool
{
http_response_code(404);
require __DIR__ . "/../../../web/frontend/404.php";
return true;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Router\Action;
class StartAction extends Action
{
public function run(): bool
{
require __DIR__ . "/../../../web/frontend/start.php";
return true;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Aternos\Mclogs\Frontend\Action;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Router\Action;
use Aternos\Mclogs\Util\URL;
class ViewLogAction extends Action
{
public function run(): bool
{
$id = new Id(URL::getLastPathPart());
$log = Log::find($id);
if (!$log) {
return false;
}
$log->renew();
require __DIR__ . "/../../../web/frontend/log.php";
return true;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Aternos\Mclogs\Frontend\Assets;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
class Asset implements \JsonSerializable
{
protected const string HASH_ALGORITHM = 'sha384';
/**
* @param object $data
* @return static|null
*/
public static function fromObject(object $data): ?static
{
if (!isset($data->type) || !isset($data->path) || !isset($data->hash)) {
return null;
}
$type = AssetType::tryFrom($data->type);
if ($type === null) {
return null;
}
return new static($type, $data->path, $data->hash);
}
public function __construct(
protected AssetType $type,
protected string $path,
protected ?string $hash = null)
{
$this->path = ltrim($this->path, '/');
}
public function getPath(): string
{
return $this->path;
}
public function getPathWithVersion(): string
{
return $this->path . '?v=' . rawurlencode(substr($this->getHash(), 0, 16));
}
protected function getAbsoluteBasePath(): string
{
return __DIR__ . "/../../../web/public/";
}
protected function getAbsolutePath(): string
{
return $this->getAbsoluteBasePath() . $this->path;
}
protected function buildHash(): string
{
return hash_file(static::HASH_ALGORITHM, $this->getAbsolutePath());
}
protected function getHash(): string
{
if ($this->hash === null) {
return $this->buildHash();
}
return $this->hash;
}
protected function getBase64Hash(): string
{
return base64_encode(hex2bin($this->getHash()));
}
public function jsonSerialize(): array
{
return [
'type' => $this->getType()->value,
'path' => $this->getPath(),
'hash' => $this->getHash(),
];
}
public function getType(): AssetType
{
return $this->type;
}
public function getHTML(): string
{
return match ($this->type) {
AssetType::CSS => '<link rel="stylesheet" href="/' . $this->getPathWithVersion() . '"' . $this->getIntegrityAttribute() . ' />',
AssetType::JS => '<script src="/' . $this->getPathWithVersion() . '"' . $this->getIntegrityAttribute() . '></script>'
};
}
protected function getIntegrityAttribute(): string
{
if (!Config::getInstance()->get(ConfigKey::FRONTEND_ASSETS_INTEGRITY)) {
return '';
}
return ' integrity="' . static::HASH_ALGORITHM . '-' . $this->getBase64Hash() . '"';
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Aternos\Mclogs\Frontend\Assets;
use Aternos\Mclogs\Util\Singleton;
class AssetLoader
{
use Singleton;
protected const string CACHE_PATH = __DIR__ . "/../../../assets.cache";
/**
* @var Asset[]
*/
protected array $cachedAssets = [];
protected function __construct()
{
$this->loadCache();
}
/**
* @param AssetType $assetType
* @param string $path
* @return string
*/
public function getHTML(AssetType $assetType, string $path): string
{
return $this->getAsset($assetType, $path)->getHTML();
}
/**
* @param AssetType $assetType
* @param string $path
* @return Asset
*/
protected function getAsset(AssetType $assetType, string $path): Asset
{
$cachedAsset = $this->findCachedAsset($assetType, $path);
if ($cachedAsset !== null) {
return $cachedAsset;
}
return new Asset($assetType, $path);
}
/**
* @param AssetType $assetType
* @param string $path
* @return Asset|null
*/
protected function findCachedAsset(AssetType $assetType, string $path): ?Asset
{
foreach ($this->cachedAssets as $asset) {
if ($asset->getPath() === $path && $asset->getType() === $assetType) {
return $asset;
}
}
return null;
}
protected function loadCache(): void
{
if (!file_exists(self::CACHE_PATH)) {
return;
}
$content = file_get_contents(self::CACHE_PATH);
if ($content === false) {
return;
}
$data = json_decode($content);
if (!is_array($data)) {
return;
}
foreach ($data as $assetData) {
if (!is_object($assetData)) {
continue;
}
$asset = Asset::fromObject($assetData);
if ($asset === null) {
continue;
}
$this->cachedAssets[] = $asset;
}
}
public function writeCache(): void
{
$assets = [
new Asset(AssetType::CSS, "css/mclogs.css"),
new Asset(AssetType::JS, "js/start.js"),
new Asset(AssetType::JS, "js/log.js"),
new Asset(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css")
];
file_put_contents(static::CACHE_PATH, json_encode($assets));
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Aternos\Mclogs\Frontend\Assets;
enum AssetType: string
{
case CSS = "css";
case JS = "js";
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Aternos\Mclogs\Frontend\Cookie;
use Aternos\Mclogs\Util\URL;
abstract class Cookie
{
protected ?string $value = null;
/**
* @return string
*/
abstract protected function getKey(): string;
/**
* @return string
*/
protected function getDomain(): string
{
return "";
}
/**
* @return int|null
*/
protected function getMaxAge(): ?int
{
return null;
}
/**
* @return string
*/
protected function getPath(): string
{
return "/";
}
/**
* @return bool
*/
protected function isSecure(): bool
{
return URL::getCurrent()->getScheme() === "https";
}
/**
* @return bool
*/
protected function isHttpOnly(): bool
{
return true;
}
/**
* @return string
*/
protected function getSameSite(): string
{
return "Lax";
}
public function __construct()
{
$this->value = $_COOKIE[$this->getKey()] ?? null;
}
/**
* @param string $value
* @return bool
*/
public function set(string $value): bool
{
$options = [
'expires' => $this->getMaxAge() !== null ? time() + $this->getMaxAge() : 0,
'path' => $this->getPath(),
'domain' => $this->getDomain(),
'secure' => $this->isSecure(),
'httponly' => $this->isHttpOnly(),
'samesite' => $this->getSameSite()
];
$result = setcookie(
$this->getKey(),
$value,
$options
);
if ($result) {
$this->value = $value;
}
return $result;
}
/**
* @return bool
*/
public function delete(): bool
{
$options = [
'expires' => time() - 3600,
'path' => $this->getPath(),
'domain' => $this->getDomain(),
'secure' => $this->isSecure(),
'httponly' => $this->isHttpOnly(),
'samesite' => $this->getSameSite()
];
$result = setcookie(
$this->getKey(),
'',
$options
);
if ($result) {
$this->value = null;
}
return $result;
}
/**
* @return string|null
*/
public function getValue(): ?string
{
return $this->value;
}
/**
* @return bool
*/
public function exists(): bool
{
return $this->getValue() !== null;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Aternos\Mclogs\Frontend\Cookie;
class SettingsCookie extends Cookie
{
/**
* @inheritDoc
*/
protected function getKey(): string
{
return "MCLOGS_SETTINGS";
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Aternos\Mclogs\Frontend\Cookie;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Log;
class TokenCookie extends Cookie
{
/**
* @param Log $log
* @return $this
*/
public function setLog(Log $log): static
{
$this->log = $log;
return $this;
}
/**
* @inheritDoc
*/
protected function getKey(): string
{
return "MCLOGS_LOG_TOKEN";
}
/**
* @param Log|null $log
*/
public function __construct(protected ?Log $log = null)
{
parent::__construct();
}
/**
* @return string
*/
protected function getPath(): string
{
if (!$this->log) {
return "/";
}
return "/" . $this->log->getId()->get();
}
protected function getMaxAge(): ?int
{
return Config::getInstance()->get(ConfigKey::STORAGE_TTL);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Aternos\Mclogs\Frontend;
use Aternos\Mclogs\Router\Router;
use Aternos\Mclogs\Id;
use Aternos\Mclogs\Router\Method;
class FrontendRouter extends Router
{
protected function __construct()
{
parent::__construct();
$this->register(Method::GET, "#^/$#", new Action\StartAction())
->register(Method::GET, "#^/" . Id::PATTERN . "$#", new Action\ViewLogAction())
->register(Method::POST, "#^/new$#", new Action\CreateLogAction())
->register(Method::DELETE, "#^/" . Id::PATTERN . "$#", new Action\DeleteLogAction())
->register(Method::GET, "#^/favicon\.svg$#", new Action\FaviconAction())
->setDefaultAction(new Action\NotFoundAction());
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Aternos\Mclogs\Frontend\Settings;
enum Setting: string
{
case FULL_WIDTH = "fullWidth";
case NO_WRAP = "noWrap";
case FLOATING_SCROLLBAR = "floatingScrollbar";
case OVERFLOW = "overflow";
/**
* @return string
*/
function getLabel(): string
{
return match ($this) {
Setting::FULL_WIDTH => "Full Width",
Setting::NO_WRAP => "No Wrap",
Setting::FLOATING_SCROLLBAR => "Floating Scrollbar",
Setting::OVERFLOW => "Overflow"
};
}
/**
* @return string|null
*/
function getBodyClass(): ?string
{
return match ($this) {
Setting::FULL_WIDTH => "setting-full-width",
Setting::NO_WRAP => "setting-no-wrap",
Setting::FLOATING_SCROLLBAR => "setting-floating-scrollbar",
Setting::OVERFLOW => "setting-overflow",
default => null
};
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Aternos\Mclogs\Frontend\Settings;
use Aternos\Mclogs\Frontend\Cookie\SettingsCookie;
class Settings
{
/**
* @var array<string, mixed>
*/
protected array $data = [];
public function __construct()
{
$rawData = new SettingsCookie()->getValue();
if ($rawData) {
$parsedData = json_decode($rawData, true);
if (is_array($parsedData)) {
$this->data = $parsedData;
}
}
}
/**
* @param Setting $key
* @return bool
*/
public function get(Setting $key): bool
{
$value = $this->data[$key->value] ?? false;
if (is_bool($value)) {
return $value;
}
return false;
}
/**
* @return string[]
*/
public function getBodyClasses(): array
{
$classes = [];
foreach (Setting::cases() as $setting) {
if ($this->get($setting)) {
$bodyClass = $setting->getBodyClass();
if ($bodyClass) {
$classes[] = $bodyClass;
}
}
}
return $classes;
}
/**
* @return string
*/
public function getBodyClassesString(): string
{
$classes = $this->getBodyClasses();
if (empty($classes)) {
return "";
}
return " " . implode(" ", $this->getBodyClasses());
}
}

60
src/Id.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace Aternos\Mclogs;
use Aternos\Mclogs\Config\ConfigKey;
class Id implements \JsonSerializable
{
public const string PATTERN = '[a-zA-Z0-9]+';
protected const string CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
/**
* @param string|null $id
*/
public function __construct(protected ?string $id = null)
{
if ($this->id === null) {
$this->generate();
}
}
/**
* Generates a new id
*
* @return string
*/
protected function generate(): string
{
$config = \Aternos\Mclogs\Config\Config::getInstance();
$idLength = $config->get(ConfigKey::ID_LENGTH);
$newId = "";
for ($i = 0; $i < $idLength; $i++) {
$newId .= static::CHARACTERS[rand(0, strlen(static::CHARACTERS) - 1)];
}
return $this->id = $newId;
}
/**
* @return string
*/
public function get(): string
{
return $this->id;
}
/**
* @return string
*/
public function __toString(): string
{
return $this->id;
}
public function jsonSerialize(): string
{
return $this->id;
}
}

516
src/Log.php Normal file
View File

@@ -0,0 +1,516 @@
<?php
namespace Aternos\Mclogs;
use Aternos\Codex\Analysis\Analysis;
use Aternos\Codex\Log\AnalysableLogInterface;
use Aternos\Codex\Log\File\StringLogFile;
use Aternos\Codex\Log\Level;
use Aternos\Codex\Log\LogInterface;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Data\Deobfuscator;
use Aternos\Mclogs\Data\MetadataEntry;
use Aternos\Mclogs\Data\Token;
use Aternos\Mclogs\Filter\Filter;
use Aternos\Mclogs\Frontend\Cookie\TokenCookie;
use Aternos\Mclogs\Printer\Printer;
use Aternos\Mclogs\Storage\MongoDBClient;
use Aternos\Mclogs\Util\URL;
use MongoDB\BSON\UTCDateTime;
use Uri\Rfc3986\Uri;
class Log
{
protected const int SOURCE_MAX_LENGTH = 64;
protected ?string $source = null;
protected ?UTCDateTime $expires = null;
protected ?UTCDateTime $created = null;
protected ?Token $token = null;
/**
* @var MetadataEntry[]
*/
protected array $metadata = [];
protected ?LogInterface $log = null;
protected ?Printer $printer = null;
/**
* Find a log by its id
*
* @param Id $id
* @param bool $includeContent
* @return static|null
*/
public static function find(Id $id, bool $includeContent = true): ?static
{
$data = MongoDBClient::getInstance()->findLog($id, $includeContent);
if ($data === null) {
return null;
}
return static::fromObject($id, $data);
}
/**
* @param (string|Id)[] $ids
* @param bool $includeContent
* @return array<string, Log>
*/
public static function findAll(array $ids, bool $includeContent = true): array
{
$ids = array_map(fn($id) => (string)$id, $ids);
$objects = MongoDBClient::getInstance()->findLogs($ids, $includeContent);
$logs = [];
foreach ($objects as $data) {
$id = new Id($data->_id);
$logs[$id->get()] = static::fromObject($id, $data);
}
return $logs;
}
/**
* @param Id $id
* @param object $data
* @return static
*/
protected static function fromObject(Id $id, object $data): static
{
return new static($id)
->setContent($data->data ?? "")
->setToken(isset($data->token) ? new Token($data->token) : null)
->setMetadata(MetadataEntry::allFromArray($data->metadata ?? []))
->setSource($data->source ?? null)
->setCreated($data->created ?? null)
->setExpires($data->expires ?? null);
}
/**
* Create and save a new log
*
* @param string $content
* @param MetadataEntry[] $metadata
* @param string|null $source
* @return static
*/
public static function create(string $content, array $metadata = [], ?string $source = null): static
{
return new static()
->setMetadata($metadata)
->setSource($source)
->setToken(new Token())
->save($content);
}
/**
* @param Id|null $id
*/
public function __construct(protected ?Id $id = null)
{
}
/**
* @param Token|null $token
* @return $this
*/
public function setToken(?Token $token): static
{
$this->token = $token;
return $this;
}
/**
* @param MetadataEntry[] $metadata
* @return $this
*/
public function setMetadata(array $metadata): static
{
$this->metadata = $metadata;
return $this;
}
/**
* @param MetadataEntry $metadataEntry
* @return $this
*/
public function addMetadata(MetadataEntry $metadataEntry): static
{
$this->metadata[] = $metadataEntry;
return $this;
}
/**
* @param string|null $source
* @return $this
*/
public function setSource(?string $source): static
{
if (is_string($source) && strlen($source) > static::SOURCE_MAX_LENGTH) {
$source = substr($source, 0, static::SOURCE_MAX_LENGTH);
}
$this->source = $source;
return $this;
}
/**
* @return string|null
*/
public function getSource(): ?string
{
return $this->source;
}
/**
* @param UTCDateTime|null $created
* @return $this
*/
public function setCreated(?UTCDateTime $created): static
{
$this->created = $created;
return $this;
}
/**
* @param UTCDateTime|null $expires
* @return $this
*/
public function setExpires(?UTCDateTime $expires): static
{
$this->expires = $expires;
return $this;
}
/**
* @return UTCDateTime|null
*/
public function getCreated(): ?UTCDateTime
{
return $this->created;
}
/**
* @return UTCDateTime|null
*/
public function getExpires(): ?UTCDateTime
{
return $this->expires;
}
/**
* @param string $content
* @return $this
*/
public function setContent(string $content): static
{
$this->processAndDeobfuscate($content);
return $this;
}
public function getContent(): string
{
return $this->log->getLogFile()->getContent();
}
protected function processAndDeobfuscate(string $data): void
{
$this->process($data);
$deobfuscator = new Deobfuscator($this->getCodexLog());
if ($deobfuscatedData = $deobfuscator->deobfuscate()) {
$this->process($deobfuscatedData);
}
}
protected function process($data): void
{
$this->log = new Detective()->setLogFile(new StringLogFile($data))->detect();
$this->log->parse();
if ($this->log instanceof AnalysableLogInterface) {
$this->log->analyse();
}
}
/**
* Get the codex log object
*
* @return LogInterface
*/
public function getCodexLog(): LogInterface
{
return $this->log;
}
/**
* Get the log analysis
*
* @return Analysis|null
*/
public function getAnalysis(): ?Analysis
{
$log = $this->getCodexLog();
if ($log instanceof AnalysableLogInterface) {
return $log->analyse();
}
return null;
}
/**
* @return Printer
*/
public function getPrinter(): Printer
{
if ($this->printer === null) {
$this->printer = new Printer()->setLog($this->log)->setId($this->id);
}
return $this->printer;
}
/**
* Get the amount of lines in this log
*
* @return int
*/
public function getLinesCount(): int
{
$codexLog = $this->getCodexLog();
$lines = 0;
foreach ($codexLog as $entry) {
$lines += count($entry);
}
return $lines;
}
/**
* @return string
*/
public function getLinesString(): string
{
$lineCount = $this->getLinesCount();
return $lineCount . ($lineCount === 1 ? " line" : " lines");
}
/**
* @return int
*/
public function getSize(): int
{
return strlen($this->getContent());
}
/**
* Get the amount of error entries in the log
*
* @return int
*/
public function getErrorsCount(): int
{
$errorCount = 0;
foreach ($this->log as $entry) {
if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {
$errorCount++;
}
}
return $errorCount;
}
/**
* @return bool
*/
public function hasErrors(): bool
{
return $this->getErrorsCount() > 0;
}
/**
* @return string
*/
public function getErrorsString(): string
{
$errorCount = $this->getErrorsCount();
return $errorCount . ($errorCount === 1 ? " error" : " errors");
}
protected function generateId(): Id
{
do {
$this->id = new Id();
} while (MongoDBClient::getInstance()->hasLog($this->id));
return $this->id;
}
/**
* Save the log to the database
*
* @return $this
*/
public function save(string $content): static
{
if ($this->id === null) {
$this->generateId();
}
$content = Filter::filterAll($content);
MongoDBClient::getInstance()->getLogsCollection()->insertOne([
"_id" => $this->id->get(),
"data" => $content,
"token" => $this->token?->get(),
"source" => $this->source,
"metadata" => $this->metadata,
"expires" => $this->expires = $this->getExpiryTimestamp(),
"created" => $this->created = new UTCDateTime()
]);
return $this->setContent($content);
}
/**
* @return UTCDateTime
*/
protected function getExpiryTimestamp(): UTCDateTime
{
$ttl = \Aternos\Mclogs\Config\Config::getInstance()->get(ConfigKey::STORAGE_TTL);
$expires = time() + $ttl;
return new UTCDateTime($expires * 1000);
}
/**
* Renew the expiry timestamp to expand the ttl
*
* @return bool
*/
public function renew(): bool
{
$expires = $this->getExpiryTimestamp();
$result = MongoDBClient::getInstance()->setLogExpires($this->id, $expires);
if ($result) {
$this->expires = $expires;
}
return $result;
}
/**
* @return Uri
*/
public function getURL(): Uri
{
return URL::getBase()->withPath("/" . $this->id->get());
}
/**
*
* @return string
*/
public function getDisplayURL(): string
{
$url = $this->getURL();
return $url->getHost() . $url->getPath();
}
/**
* @return Uri
*/
public function getRawURL(): Uri
{
return URL::getApi()->withPath("/1/raw/" . $this->id->get());
}
/**
* @return Id|null
*/
public function getId(): ?Id
{
return $this->id;
}
/**
* @return Token|null
*/
public function getToken(): ?Token
{
return $this->token;
}
/**
* @return bool
*/
public function delete(): bool
{
return MongoDBClient::getInstance()->deleteLog($this->id->get());
}
/**
* @return MetadataEntry[]
*/
public function getMetadata(): array
{
return $this->metadata;
}
/**
* @return MetadataEntry[]
*/
public function getVisibleMetadata(): array
{
return array_filter($this->metadata, function (MetadataEntry $entry) {
return $entry->isVisible();
});
}
/**
* @return bool
*/
public function setTokenCookie(): bool
{
if (!$this->getToken()) {
return false;
}
return new TokenCookie($this)->set($this->getToken()->get());
}
/**
* @return bool
*/
public function hasValidTokenCookie(): bool
{
$tokenCookie = new TokenCookie();
$cookieValue = $tokenCookie->getValue();
if ($cookieValue === null || !$this->getToken()) {
return false;
}
return $this->getToken()->matches($cookieValue);
}
/**
* @return string
*/
public function getPageTitle(): string
{
return $this->getCodexLog()?->getTitle() . " [#" . $this->getId()?->get() . "]";
}
/**
* @return string
*/
public function getPageDescription(): string
{
$description = $this->getLinesString();
if ($this->hasErrors()) {
$description .= " | " . $this->getErrorsString();
}
$problems = $this->getAnalysis()->getProblems();
if (count($problems) > 0) {
$problemString = "problems";
if (count($problems) === 1) {
$problemString = "problem";
}
$description .= " | " . count($problems) . " " . $problemString . " automatically detected";
}
return $description;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Aternos\Mclogs\Printer;
/**
* Class FormatModification
*
* @package Printer
*/
class FormatModification extends \Aternos\Codex\Minecraft\Printer\FormatModification
{
/**
* @param string $format
* @return string
*/
protected function getClasses(string $format): string
{
return "format format-" . $format;
}
}

88
src/Printer/Printer.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
namespace Aternos\Mclogs\Printer;
use Aternos\Codex\Log\Entry;
use Aternos\Codex\Log\EntryInterface;
use Aternos\Codex\Log\Level;
use Aternos\Codex\Log\LineInterface;
use Aternos\Codex\Printer\ModifiableDefaultPrinter;
use Aternos\Mclogs\Id;
/**
* Class Printer
*
* @package Printer
*/
class Printer extends ModifiableDefaultPrinter
{
public function __construct()
{
$this->addModification(new FormatModification());
}
/**
* @var Id
*/
protected Id $id;
/**
* @param Id $id
* @return Printer
*/
public function setId(Id $id): static
{
$this->id = $id;
return $this;
}
/**
* @return string
*/
protected function printLog(): string
{
return '<div class="log-inner">' . parent::printLog() . '</div>';
}
/**
* @param EntryInterface|null $entry
* @return string
* @throws \Exception
*/
protected function printEntry(?EntryInterface $entry = null): string
{
$entry = $entry ?? $this->entry;
/** @var Entry $entry */
$return = '';
$first = true;
foreach ($entry as $line) {
$entryClass = "entry-no-error";
if ($entry->getLevel()->asInt() <= Level::ERROR->asInt()) {
$entryClass = "entry-error";
}
$return .= '<div class="entry ' . $entryClass . '">';
$return .= '<div class="line-number-container"><a href="/' . $this->id->get() . '#L' . $line->getNumber() . '" id="L' . $line->getNumber() . '" class="line-number">' . $line->getNumber() . '</a></div>';
$return .= '<div class="line-content"><span class="level level-' . $entry->getLevel()->asString() . ((!$first) ? " multiline" : "") . '">';
$lineString = $this->printLine($line);
if ($entry->getPrefix() !== null) {
$prefix = htmlentities($entry->getPrefix());
$lineString = str_replace($prefix, '<span class="level-prefix">' . $prefix . '</span>', $lineString);
}
$return .= $lineString;
$return .= '</span></div>';
$return .= '</div>';
$first = false;
}
return $return;
}
/**
* @param LineInterface $line
* @return string
*/
protected function printLine(LineInterface $line): string
{
return $this->runModifications(htmlentities($line->getText())) . PHP_EOL;
}
}

8
src/Router/Action.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
namespace Aternos\Mclogs\Router;
abstract class Action
{
abstract public function run(): bool;
}

17
src/Router/Method.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace Aternos\Mclogs\Router;
enum Method: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case DELETE = 'DELETE';
case OPTIONS = 'OPTIONS';
public static function getCurrent(): self
{
return self::tryFrom($_SERVER['REQUEST_METHOD']) ?? self::GET;
}
}

51
src/Router/Route.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace Aternos\Mclogs\Router;
class Route
{
public function __construct(
protected Method $method,
protected string $pattern,
protected Action $action
)
{
}
/**
* @param Method $method
* @param string $path
* @return bool
*/
public function matches(Method $method, string $path): bool
{
if ($this->getMethod() !== $method) {
return false;
}
return preg_match($this->getPattern(), $path) === 1;
}
/**
* @return Method
*/
public function getMethod(): Method
{
return $this->method;
}
/**
* @return string
*/
public function getPattern(): string
{
return $this->pattern;
}
/**
* @return Action
*/
public function getAction(): Action
{
return $this->action;
}
}

73
src/Router/Router.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
namespace Aternos\Mclogs\Router;
use Aternos\Mclogs\Util\Singleton;
use Aternos\Mclogs\Util\URL;
class Router
{
use Singleton;
/**
* @var Route[]
*/
protected array $routes = [];
protected ?Action $defaultAction = null;
/**
* @param Method $method
* @param string $pattern
* @param Action $action
* @return $this
*/
public function register(Method $method, string $pattern, Action $action): static
{
$this->routes[] = new Route($method, $pattern, $action);
return $this;
}
/**
* @param Action $defaultAction
* @return $this
*/
public function setDefaultAction(Action $defaultAction): static
{
$this->defaultAction = $defaultAction;
return $this;
}
/**
* @return $this
*/
public function run(): static
{
$route = $this->findRoute();
if (!$route) {
$this->defaultAction?->run();
return $this;
}
$result = $route->getAction()->run();
if (!$result) {
$this->defaultAction?->run();
}
return $this;
}
/**
* @return Route|null
*/
protected function findRoute(): ?Route
{
$path = URL::getCurrent()->getPath();
$method = Method::getCurrent();
foreach ($this->routes as $route) {
if ($route->matches($method, $path)) {
return $route;
}
}
return null;
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Aternos\Mclogs\Storage;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Util\Singleton;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;
use MongoDB\Collection;
use MongoDB\Database;
use Uri\Rfc3986\Uri;
class MongoDBClient
{
use Singleton;
protected ?Client $connection = null;
protected Database $database;
protected ?Collection $logs = null;
protected ?Collection $cache = null;
/**
* @return string
*/
protected function getConnectionURL(): string
{
$configUrl = Config::getInstance()->get(ConfigKey::MONGODB_URL);
$url = new Uri($configUrl);
$query = $url->getQuery();
$queryParams = [];
if ($query !== null) {
parse_str($query, $queryParams);
}
if (!isset($queryParams['serverSelectionTimeoutMS'])) {
$queryParams['serverSelectionTimeoutMS'] = 5_000;
}
if (!isset($queryParams['socketTimeoutMS'])) {
$queryParams['socketTimeoutMS'] = 60_000;
}
$newQuery = http_build_query($queryParams);
$newUrl = $url->withQuery($newQuery);
return $newUrl->toString();
}
/**
* Connect to MongoDB
*/
protected function connect(): void
{
if ($this->connection === null) {
$config = Config::getInstance();
$this->connection = new Client($this->getConnectionURL());
$this->database = $this->connection->getDatabase($config->get(ConfigKey::MONGODB_DATABASE));
}
}
/**
* Ensure indexes exist
*
* @return void
*/
public function ensureIndexes(): void
{
$logs = $this->getLogsCollection();
$logs->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);
$cache = $this->getCacheCollection();
$cache->createIndex(['expires' => 1], ['expireAfterSeconds' => 0]);
}
/**
* @return void
*/
public function reset(): void
{
$this->connection = null;
$this->logs = null;
$this->cache = null;
}
/**
* Get the collection for logs
*
* @return Collection
*/
public function getLogsCollection(): Collection
{
if ($this->logs === null) {
$this->connect();
$this->logs = $this->database->getCollection('logs');
}
return $this->logs;
}
/**
* @param string $id
* @param bool $includeContent
* @return object|null
*/
public function findLog(string $id, bool $includeContent = true): ?object
{
$options = [];
if (!$includeContent) {
$options['projection'] = ['data' => 0];
}
$collection = $this->getLogsCollection();
$result = $collection->findOne(['_id' => $id], $options);
if ($result === null) {
// Check for legacy ID without the first character
return $collection->findOne(['_id' => substr($id, 1)], $options);
}
return $result;
}
/**
* @param string[] $ids
* @param bool $includeContent
* @return object[]
*/
public function findLogs(array $ids, bool $includeContent = true): array
{
$options = [];
if (!$includeContent) {
$options['projection'] = ['data' => 0];
}
$collection = $this->getLogsCollection();
$results = $collection->find(['_id' => ['$in' => $ids]], $options)->toArray();
$foundIds = [];
foreach ($results as $result) {
$foundIds[] = (string)$result->_id;
}
$missingIds = array_diff($ids, $foundIds);
if (!empty($missingIds)) {
$legacyIds = [];
foreach ($missingIds as $id) {
$legacyIds[substr($id, 1)] = $id;
}
// Check for legacy IDs without the first character
$legacyResults = $collection->find(['_id' => ['$in' => array_keys($legacyIds)]], $options)->toArray();
foreach ($legacyResults as $result) {
// Map the legacy ID back to the original ID
$originalId = $legacyIds[(string)$result->_id];
$result->_id = $originalId;
// Add the found legacy results to the main results array
$results[] = $result;
}
}
return $results;
}
/**
* @param string $id
* @return bool
*/
public function deleteLog(string $id): bool
{
$collection = $this->getLogsCollection();
$result = $collection->deleteOne(['_id' => $id]);
if ($result->getDeletedCount() === 0) {
// Check for legacy ID without the first character
$result = $collection->deleteOne(['_id' => substr($id, 1)]);
return $result->getDeletedCount() === 1;
}
return true;
}
/**
* @param array $ids
* @return int Number of logs deleted
*/
public function deleteLogs(array $ids): int
{
$collection = $this->getLogsCollection();
$result = $collection->deleteMany(['_id' => ['$in' => $ids]]);
$deletedCount = $result->getDeletedCount();
if ($deletedCount === count($ids)) {
return $deletedCount;
}
// Check for legacy IDs without the first character
$legacyIds = [];
foreach ($ids as $id) {
$legacyIds[] = substr($id, 1);
}
$legacyResult = $collection->deleteMany(['_id' => ['$in' => $legacyIds]]);
return $deletedCount + $legacyResult->getDeletedCount();
}
/**
* @param string $id
* @return bool
*/
public function hasLog(string $id): bool
{
return $this->findLog($id) !== null;
}
/**
* @param string $id
* @param UTCDateTime $expires
* @return bool
*/
public function setLogExpires(string $id, UTCDateTime $expires): bool
{
$collection = $this->getLogsCollection();
$result = $collection->updateOne(
['_id' => $id],
['$set' => ['expires' => $expires]]
);
return $result->getModifiedCount() === 1;
}
/**
* Get the collection for caching
*
* @return Collection
*/
public function getCacheCollection(): Collection
{
if ($this->cache === null) {
$this->connect();
$this->cache = $this->database->getCollection('cache');
}
return $this->cache;
}
}

37
src/Util/Singleton.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace Aternos\Mclogs\Util;
trait Singleton
{
/**
* @var static[]
*/
protected static array $instances = [];
public static function getInstance(): static
{
$class = get_called_class();
if (!isset(static::$instances[$class])) {
static::$instances[$class] = new static;
}
return static::$instances[$class];
}
/**
* Prohibited for singleton
*/
protected function __clone()
{
}
/**
* Prohibited for singleton
*/
protected function __construct()
{
}
}

53
src/Util/TimeInterval.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
namespace Aternos\Mclogs\Util;
class TimeInterval
{
use Singleton;
protected const array UNITS = [
"year" => 365 * 24 * 60 * 60,
"month" => 30 * 24 * 60 * 60,
"week" => 7 * 24 * 60 * 60,
"day" => 24 * 60 * 60,
"hour" => 60 * 60,
"minute" => 60,
"second" => 1,
];
/**
* @param int $value
* @param string $unit
* @return string
*/
protected function formatUnit(int $value, string $unit): string
{
if ($value === 1) {
return $value . " " . $unit;
} else {
return $value . " " . $unit . "s";
}
}
/**
* @param int $duration
* @param string $separator
* @return string
*/
public function format(int $duration, string $separator = ", "): string
{
$parts = [];
while ($duration > 0) {
foreach (self::UNITS as $unit => $seconds) {
if ($duration >= $seconds) {
$value = intdiv($duration, $seconds);
$duration -= $value * $seconds;
$parts[] = $this->formatUnit($value, $unit);
break;
}
}
}
return implode($separator, $parts);
}
}

128
src/Util/URL.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
namespace Aternos\Mclogs\Util;
use Uri\Rfc3986\Uri;
class URL
{
protected const string API_SUBDOMAIN = "api.";
protected static ?Uri $base = null;
protected static ?Uri $api = null;
protected static ?Uri $current = null;
public static function clear(): void
{
static::$base = null;
static::$api = null;
static::$current = null;
}
/**
* @return string
*/
protected static function readProtocol(): string
{
if (isset($_SERVER['HTTP_FORWARDED'])) {
$forwarded = explode(';', $_SERVER['HTTP_FORWARDED']);
foreach ($forwarded as $part) {
$part = trim($part);
$partParts = explode('=', $part, 2);
if (count($partParts) === 2 && strtolower($partParts[0]) === 'proto') {
$protocol = $partParts[1];
$protocol = trim($protocol, '"\'');
return strtolower($protocol);
}
}
}
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$protoParts = explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO']);
return strtolower(trim($protoParts[0]));
}
if (isset($_SERVER['REQUEST_SCHEME'])) {
return strtolower($_SERVER['REQUEST_SCHEME']);
}
return 'http';
}
/**
* @return string
*/
protected static function getProtocol(): string
{
$protocol = static::readProtocol();
if ($protocol === 'https') {
return 'https';
}
return 'http';
}
/**
* Get base URL
*
* @return Uri
*/
public static function getBase(): Uri
{
if (static::$base) {
return static::$base;
}
$host = $_SERVER['HTTP_HOST'];
if (str_starts_with($host, static::API_SUBDOMAIN)) {
$host = substr($host, strlen(static::API_SUBDOMAIN));
}
return static::$base = new Uri(static::getProtocol() . "://" . $host);
}
/**
* Get API URL
*
* @return Uri
*/
public static function getApi(): Uri
{
if (static::$api) {
return static::$api;
}
$base = static::getBase();
return static::$api = $base->withHost(static::API_SUBDOMAIN . $base->getHost());
}
/**
* @return Uri
*/
public static function getCurrent(): Uri
{
if (static::$current) {
return static::$current;
}
$scheme = $_SERVER['REQUEST_SCHEME'];
$host = $_SERVER['HTTP_HOST'];
$requestUri = $_SERVER['REQUEST_URI'];
return static::$current = new Uri("$scheme://$host$requestUri");
}
/**
* @return bool
*/
public static function isApi(): bool
{
$currentHost = static::getCurrent()->getHost();
$apiHost = static::getApi()->getHost();
return $currentHost === $apiHost;
}
/**
* @return string
*/
public static function getLastPathPart(): string
{
$path = static::getCurrent()->getPath();
$parts = explode("/", $path);
do {
$part = trim(array_pop($parts));
} while ($part === "" && count($parts) > 0);
return $part;
}
}

22
web/frontend/404.php Normal file
View File

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

639
web/frontend/api-docs.php Normal file
View File

@@ -0,0 +1,639 @@
<?php
use Aternos\Mclogs\Api\Action\BulkDeleteLogsAction;
use Aternos\Mclogs\Api\Response\ApiError;
use Aternos\Mclogs\Api\Response\ApiResponse;
use Aternos\Mclogs\Api\Response\MultiResponse;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Util\URL;
$config = Config::getInstance();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<?php include __DIR__ . '/parts/head.php'; ?>
<title>API Documentation - <?= htmlspecialchars($config->getName()); ?></title>
<meta name="description" content="API documentation for <?= htmlspecialchars($config->getName()); ?> - Integrate log sharing directly into your server panel or hosting software." />
</head>
<body>
<?php include __DIR__ . '/parts/header.php'; ?>
<main>
<div class="api-docs-header">
<div class="api-docs-header-content">
<h1>API Documentation</h1>
<p>Integrate <strong><?= htmlspecialchars($config->getName()); ?></strong> directly into your server panel, your hosting software or anything else. This platform was built for high performance automation and can easily be integrated into any existing software via our HTTP API.</p>
</div>
</div>
<div class="api-docs-toc">
<h3>Quick Links</h3>
<nav class="api-docs-toc-nav">
<a href="#create-log">Create a log</a>
<a href="#get-log-info">Get log info and content</a>
<a href="#delete-log">Delete a log</a>
</nav>
</div>
<div class="api-docs-section" id="create-log">
<h2>Create a log</h2>
<div class="api-endpoint">
<span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/log")->toString()); ?></span> <span class="content-type">application/json</span>
</div>
<div class="api-note">
Posting content with the content type <span class="content-type">application/x-www-form-urlencoded</span> is still supported for backwards compatibility, but does not support setting metadata.
</div>
<table class="api-table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">content</td>
<td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
<td class="api-type">string</td>
<td class="api-description">
The raw log file content as string.
Limited to <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_BYTES) / 1024 / 1024, 2); ?> MiB and <?= number_format($config->get(ConfigKey::STORAGE_LIMIT_LINES)); ?> lines.
Will be truncated if possible and necessary, but truncating on the client side is recommended.
</td>
</tr>
<tr>
<td class="api-field">source</td>
<td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
<td class="api-type">string</td>
<td class="api-description">The name of the source, e.g. a domain or software name.</td>
</tr>
<tr>
<td class="api-field">metadata</td>
<td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
<td class="api-type">array</td>
<td class="api-description">An array of metadata entries.</td>
</tr>
</table>
<h3>Example body <span class="content-type">application/json</span></h3>
<pre class="api-code">{
"content": "[log file content...]",
"source": "example.org"
}</pre>
<h3>Metadata</h3>
<p>
You can send metadata alongside the log content to be displayed on the log page and/or be read by other applications through this API.
This is entirely optional, but can help to provide additional context, e.g. internal server IDs, software versions etc.
</p>
<p>
A metadata entry is an object with the following fields:
</p>
<table class="api-table">
<tr>
<th>Field</th>
<th>Required</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">key</td>
<td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
<td class="api-type">string</td>
<td class="api-description">The metadata key. Can be used to identify the entry in your code later.</td>
</tr>
<tr>
<td class="api-field">value</td>
<td class="api-required required"><i class="fa-solid fa-square-check"></i></td>
<td class="api-type">string|int|float|bool|null</td>
<td class="api-description">The metadata value.</td>
</tr>
<tr>
<td class="api-field">label</td>
<td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
<td class="api-type">string</td>
<td class="api-description">The display label. If not provided, the key will be used as label.</td>
</tr>
<tr>
<td class="api-field">visible</td>
<td class="api-required"><i class="fa-solid fa-square-xmark"></i></td>
<td class="api-type">bool</td>
<td class="api-description">Whether this metadata should be visible on the log page or is only available through the API. Default is true.</td>
</tr>
</table>
<h3>Example body with metadata <span class="content-type">application/json</span></h3>
<pre class="api-code">{
"content": "[log file content...]",
"source": "example.org",
"metadata": [
{
"key": "server_id",
"value": 12345,
"visible": false
},
{
"key": "software_version",
"value": "1.2.3",
"label": "Software Version",
"visible": true
}
]
}</pre>
<h3>Responses</h3>
<h4>Success <span class="content-type">application/json</span></h4>
<div class="api-note">
The token provided in this response can be used to delete this log later. Store or discard it securely, it will not be shown again.
</div>
<pre class="api-code">{
"success":true,
"id":"WnMMikq",
"source":null,
"created":1769597979,
"expires":1777373979,
"size":157369,
"lines":1201,
"errors":8,
"url": "<?= htmlspecialchars(URL::getBase()->withPath("/WnMMikq")->toString()); ?>",
"raw": "<?= htmlspecialchars(URL::getApi()->withPath("/1/raw/WnMMikq")->toString()); ?>",
"token":"78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771",
"metadata": [
{
"key": "server_id",
"value": 12345,
"visible": false
},
{
"key": "software_version",
"value": "1.2.3",
"label": "Software Version",
"visible": true
}
]
}</pre>
<h4>Error <span class="content-type">application/json</span></h4>
<pre class="api-code">
{
"success": false,
"error": "Required field 'content' not found."
}</pre>
</div>
<div class="api-docs-section" id="get-log-info">
<h2>Get log info and content</h2>
<div class="api-endpoint">
<span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>
</div>
<p>
This endpoint only returns the log info and metadata by default (same response as creating a log), you can also get the content in the same request by enabling it in different
formats using GET parameters. You can combine multiple parameters to get multiple content formats in one request, but keep in mind that this will
increase the response size.
</p>
<table class="api-table">
<tr>
<th>GET Parameter</th>
<th>Response field</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">raw</td>
<td class="api-type">content.raw</td>
<td class="api-description">Includes the raw log content as string in the response.</td>
</tr>
<tr>
<td class="api-field">parsed</td>
<td class="api-type">content.parsed</td>
<td class="api-description">Includes the parsed log content as array/objects in the response.</td>
</tr>
<tr>
<td class="api-field">insights</td>
<td class="api-type">content.insights</td>
<td class="api-description">Includes the automatically detected insights in the response.</td>
</tr>
</table>
<h3>Responses</h3>
<h4>Success <span class="content-type">application/json</span></h4>
<div class="api-note">
All content fields are only included if the corresponding GET parameter is provided.
If no content parameter is provided, the entire content object is omitted from the response.
</div>
<pre class="api-code">{
"success":true,
"id":"WnMMikq",
"source":null,
"created":1769597979,
"expires":1777373979,
"size":157369,
"lines":1201,
"errors":8,
"url": "<?= htmlspecialchars(URL::getBase()->withPath("/WnMMikq")->toString()); ?>",
"raw": "<?= htmlspecialchars(URL::getApi()->withPath("/1/raw/WnMMikq")->toString()); ?>",
"metadata": [
{
"key": "server_id",
"value": 12345,
"visible": false
},
{
"key": "software_version",
"value": "1.2.3",
"label": "Software Version",
"visible": true
}
],
"content": {
"raw": "[log file content...]",
"parsed": [ /* parsed log entries */ ],
"insights": { "problems": [ /* detected problems */ ], "information": [ /* detected information */ ] }
}
}</pre>
<h4>Error <span class="content-type">application/json</span></h4>
<pre class="api-code">
{
"success": false,
"error": "Log not found."
}</pre>
</div>
<div class="api-docs-section" id="delete-log">
<h2>Delete a log</h2>
<div class="api-note">
Deleting a log requires the token that was provided when creating the log.
</div>
<div class="api-endpoint">
<span class="api-method">DELETE</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/log/[id]</span>
</div>
<h3>Headers</h3>
<table class="api-table">
<tr>
<th>Header</th>
<th>Example</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">Authorization</td>
<td class="api-type">Authorization: Bearer 78351fafe495398163f...</td>
<td class="api-description">The type (always "Bearer") and the log token received when creating the log.</td>
</tr>
</table>
<h3>Responses</h3>
<h4>Success <span class="content-type">application/json</span></h4>
<pre class="api-code">{
"success": true
}</pre>
<h4>Error <span class="content-type">application/json</span></h4>
<pre class="api-code">
{
"success": false,
"error": "Invalid token."
}</pre>
</div>
<div class="api-docs-section" id="bulk-delete-log">
<h2>Bulk delete multiple logs</h2>
<div class="api-note">
This method allows deleting up to <?= BulkDeleteLogsAction::MAX_IDS; ?> at once.
Deleting logs requires the tokens that were provided when the logs were created.
</div>
<div class="api-endpoint">
<span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/bulk/log/delete</span>
</div>
<h3>Example body <span class="content-type">application/json</span></h3>
<pre class="api-code"><?= json_encode([
[
"id" => "6wexMDE",
"token" => "78351fafe495398163fff847f9a26dda440435dcf7b5f92e8e36308f3683d771"
],
[
"id" => "OahzhMG",
"token" => "6520dd42ec3d5fd0e83f28220974fb83d3bdc0746853f5022373f8e5b062651b"
],
], JSON_PRETTY_PRINT); ?></pre>
<h3>Responses</h3>
<h4>Success <span class="content-type">application/json</span></h4>
<div class="api-note">
The bulk delete request will return a successful result and status code <code>207</code>,
indicating that the request was processed.
Results for the individual operations are included in the response body.
</div>
<pre class="api-code"><?=json_encode(new MultiResponse()
->addResponse("6wexMDE", new ApiResponse())
->addResponse("OahzhMG", new ApiResponse()), JSON_PRETTY_PRINT); ?></pre>
<h4>Partial success <span class="content-type">application/json</span></h4>
<div class="api-note">
If a bulk delete request is valid, but not all logs can be deleted (e.g. due to invalid tokens or non-existing logs),
it will still overall be considered successful, but the response body will include error results for the logs that could not be deleted.
</div>
<pre class="api-code"><?=json_encode(new MultiResponse()
->addResponse("6wexMDE", new ApiResponse())
->addResponse("OahzhMG", new ApiError(404, "Log not found.")), JSON_PRETTY_PRINT); ?></pre>
<h4>Error <span class="content-type">application/json</span></h4>
<div class="api-note">
If a bulk delete request is malformed or invalid, the entire request will be
rejected with an error response and no logs will be deleted.
</div>
<pre class="api-code">
{
"success": false,
"error": "No logs provided."
}</pre>
</div>
<div class="api-docs-section" id="get-raw">
<h2>Get the raw log file content</h2>
<div class="api-note">
Only use this endpoint if you really only need the raw log content. For most use cases, getting the log info and content together from the log endpoint is recommended.
</div>
<div class="api-endpoint">
<span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/raw/[id]</span>
</div>
<table class="api-table">
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">[id]</td>
<td class="api-type">string</td>
<td class="api-description">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>
</tr>
</table>
<h3>Success <span class="content-type">text/plain</span></h3>
<pre class="api-code">
[18:25:33] [Server thread/INFO]: Starting minecraft server version 1.16.2
[18:25:33] [Server thread/INFO]: Loading properties
[18:25:34] [Server thread/INFO]: Default game type: SURVIVAL
...
</pre>
<h3>Error <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"success": false,
"error": "Log not found."
}</pre>
</div>
<div class="api-docs-section" id="get-insights">
<h2>Get insights</h2>
<div class="api-note">
This endpoint is mainly kept for backwards compatibility. For new applications, getting the insights together with the log info from the log endpoint is recommended.
</div>
<div class="api-endpoint">
<span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->toString()); ?>/1/insights/[id]</span>
</div>
<table class="api-table">
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">[id]</td>
<td class="api-type">string</td>
<td class="api-description">The log file id, received from the paste endpoint or from a URL (<?= htmlspecialchars(URL::getBase()->toString()); ?>/[id]).</td>
</tr>
</table>
<h3>Success <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"id": "name/type",
"name": "Software name, e.g. Vanilla",
"type": "Type name, e.g. Server Log",
"version": "Version, e.g. 1.12.2",
"title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
"analysis": {
"problems": [
{
"message": "A message explaining the problem.",
"counter": 1,
"entry": {
"level": 6,
"time": null,
"prefix": "The prefix of this entry, usually the part containing time and loglevel.",
"lines": [
{
"number": 1,
"content": "The full content of the line."
}
]
},
"solutions": [
{
"message": "A message explaining a possible solution."
}
]
}
],
"information": [
{
"message": "Label: value",
"counter": 1,
"label": "The label of this information, e.g. Minecraft version",
"value": "The value of this information, e.g. 1.12.2",
"entry": {
"level": 6,
"time": null,
"prefix": "The prefix of this entry, usually the part containing time and loglevel.",
"lines": [
{
"number": 6,
"content": "The full content of the line."
}
]
}
}
]
}
}</pre>
<h3>Error <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"success": false,
"error": "Log not found."
}</pre>
</div>
<div class="api-docs-section" id="analyse">
<h2>Analyse a log without saving it</h2>
<p>
If you only want to use the analysis features of this service without saving the log, you can use this endpoint.
Please do not save logs that you only want to analyse, as this wastes storage space and resources.
</p>
<div class="api-endpoint">
<span class="api-method">POST</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/analyse")->toString()); ?></span> <span class="content-type">application/x-www-form-urlencoded</span> <span class="content-type">application/json</span>
</div>
<table class="api-table">
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">content</td>
<td class="api-type">string</td>
<td class="api-description">The raw log file content as string. Maximum length is 10MiB and 25k lines, will be shortened if necessary.</td>
</tr>
</table>
<h3>Success <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"id": "name/type",
"name": "Software name, e.g. Vanilla",
"type": "Type name, e.g. Server Log",
"version": "Version, e.g. 1.12.2",
"title": "Combined title, e.g. Vanilla 1.12.2 Server Log",
"analysis": {
"problems": [
{
"message": "A message explaining the problem.",
"counter": 1,
"entry": {
"level": 6,
"time": null,
"prefix": "The prefix of this entry, usually the part containing time and loglevel.",
"lines": [
{
"number": 1,
"content": "The full content of the line."
}
]
},
"solutions": [
{
"message": "A message explaining a possible solution."
}
]
}
],
"information": [
{
"message": "Label: value",
"counter": 1,
"label": "The label of this information, e.g. Minecraft version",
"value": "The value of this information, e.g. 1.12.2",
"entry": {
"level": 6,
"time": null,
"prefix": "The prefix of this entry, usually the part containing time and loglevel.",
"lines": [
{
"number": 6,
"content": "The full content of the line."
}
]
}
}
]
}
}</pre>
<h3>Error <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"success": false,
"error": "Required field 'content' is empty."
}</pre>
</div>
<div class="api-docs-section" id="check-limits">
<h2>Check storage limits</h2>
<div class="api-endpoint">
<span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/limits")->toString()); ?></span>
</div>
<h3>Success <span class="content-type">application/json</span></h3>
<pre class="api-code">
{
"storageTime": 7776000,
"maxLength": 10485760,
"maxLines": 25000
}</pre>
<table class="api-table">
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">storageTime</td>
<td class="api-type">integer</td>
<td class="api-description">The duration in seconds that a log is stored for after the last view.</td>
</tr>
<tr>
<td class="api-field">maxLength</td>
<td class="api-type">integer</td>
<td class="api-description">Maximum file length in bytes. Logs over this limit will be truncated to this length.</td>
</tr>
<tr>
<td class="api-field">maxLines</td>
<td class="api-type">integer</td>
<td class="api-description">Maximum number of lines. Additional lines will be removed.</td>
</tr>
</table>
</div>
<div class="api-docs-section" id="check-limits">
<h2>Get filters</h2>
<p>
Filters modify the log content before storing it. They are applied automatically when creating a new log on the server side.
You can get a list of active filters from this endpoint if you want to apply the same filters on the client side before uploading a log.
</p>
<div class="api-endpoint">
<span class="api-method">GET</span> <span class="api-url"><?= htmlspecialchars(URL::getApi()->withPath("/1/filters")->toString()); ?></span>
</div>
<h3>Success <span class="content-type">application/json</span></h3>
<pre class="api-code">
<?=htmlspecialchars(json_encode(\Aternos\Mclogs\Filter\Filter::getAll(), JSON_PRETTY_PRINT)); ?></pre>
<h3>Filter types</h3>
<table class="api-table">
<tr>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="api-field">trim</td>
<td class="api-description">
Trim any whitespace characters from the beginning and end of the log content.
</td>
</tr>
<tr>
<td class="api-field">limit-bytes</td>
<td class="api-description">
Limit the log content to a maximum number of bytes (data.limit). Content exceeding this limit will be truncated.
</td>
</tr>
<tr>
<td class="api-field">limit-lines</td>
<td class="api-description">
Limit the log content to a maximum number of lines (data.limit). Additional lines will be removed.
</td>
</tr>
<tr>
<td class="api-field">regex</td>
<td class="api-description">
Apply regular expression replacements to the log content. Each pattern in data.patterns will be applied in order and replaced with the provided replacement, unless the matched string matches one of the exemption patterns in data.exemptions.
</td>
</tr>
</table>
<div class="api-note">
Make sure to handle any filter error, e.g. unknown filter types gracefully, as new filter types may be added in the future.
</div>
</div>
<div class="api-docs-notes">
<div class="api-docs-notes-content">
<h2>Notes</h2>
<p>The API has currently a rate limit of 60 requests per minute per IP address. This is set to ensure the operability of this service. If you have any use case that requires a higher limit, feel free to contact us.</p>
<div class="api-docs-notes-actions">
<a class="btn btn-small" href="mailto:matthias@aternos.org">
<i class="fa-solid fa-envelope"></i> Contact via mail
</a>
</div>
</div>
</div>
</main>
<?php include __DIR__ . '/parts/footer.php'; ?>
</body>
</html>

230
web/frontend/log.php Normal file
View File

@@ -0,0 +1,230 @@
<?php
use Aternos\Mclogs\Frontend\Assets\AssetLoader;
use Aternos\Mclogs\Frontend\Assets\AssetType;
use Aternos\Mclogs\Log;
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Frontend\Settings\Setting;
use Aternos\Mclogs\Frontend\Settings\Settings;
use Aternos\Mclogs\Util\TimeInterval;
/** @var Log $log */
$settings = new Settings();
?><!DOCTYPE html>
<html lang="en">
<head>
<?php include __DIR__ . '/parts/head.php'; ?>
<title><?=htmlspecialchars($log->getPageTitle()); ?></title>
<meta name="description" content="<?=htmlspecialchars($log->getPageDescription()); ?>" />
</head>
<body class="log-body<?=$settings->getBodyClassesString(); ?>">
<?php include __DIR__ . '/parts/header.php'; ?>
<main>
<div class="log-header">
<div class="log-header-inner">
<div class="left">
<div class="log-title">
<h1>
<i class="fas fa-file-lines"></i>
<?=htmlspecialchars($log->getCodexLog()->getTitle()); ?>
</h1>
<button class="log-url-btn" data-clipboard="<?=htmlspecialchars($log->getURL()->toString()); ?>" title="Copy log URL to clipboard">
<span class="log-url"><?=htmlspecialchars($log->getDisplayURL()); ?></span>
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
<div class="right">
<div class="details">
<div class="log-info-actions">
<?php if($log->hasErrors()): ?>
<div class="btn btn-danger btn-small" id="error-toggle">
<i class="fa fa-exclamation-circle"></i>
<?=htmlspecialchars($log->getErrorsString()); ?>
</div>
<?php endif; ?>
<div class="btn btn-dark btn-small" id="down-button">
<i class="fa fa-arrow-circle-down"></i>
<?=htmlspecialchars($log->getLinesString()); ?>
</div>
<a class="btn btn-dark btn-small" id="raw" target="_blank" title="Raw log" href="<?=$log->getRawURL()->toString(); ?>">
<i class="fa fa-arrow-up-right-from-square"></i>
Raw
</a>
</div>
</div>
</div>
</div>
<?php $information = $log->getAnalysis()->getInformation(); ?>
<?php if(count($log->getVisibleMetadata()) > 0 || count($information) > 0): ?>
<div class="log-info-rows">
<?php if(count($log->getVisibleMetadata()) > 0): ?>
<div class="log-info-row">
<div class="info-row-items">
<div class="info-row-header">
<i class="fa-solid fa-tags"></i>
<span>Metadata</span>
</div>
<?php foreach($log->getVisibleMetadata() as $metadata): ?>
<span class="info-item">
<span class="info-label"><?=htmlspecialchars($metadata->getDisplayLabel()); ?>:</span>
<span class="info-value"><?=htmlspecialchars($metadata->getDisplayValue()); ?></span>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if(count($information) > 0): ?>
<div class="log-info-row">
<div class="info-row-items">
<div class="info-row-header">
<i class="fa-solid fa-cube"></i>
<span>Detected</span>
</div>
<?php foreach($information as $info): ?>
<span class="info-item">
<span class="info-label"><?=htmlspecialchars($info->getLabel()); ?>:</span>
<span class="info-value"><?=htmlspecialchars($info->getValue()); ?></span>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php $problems = $log->getAnalysis()?->getProblems(); ?>
<?php if(count($problems) > 0): ?>
<div class="problems-panel-container">
<div class="problems-panel">
<div class="problems-header">
<span class="problems-count"><?=count($problems); ?></span>
<span class="problems-title"><?=count($problems) === 1 ? 'Problem' : 'Problems'; ?> detected</span>
</div>
<div class="problems-list">
<?php foreach($problems as $problem): ?>
<?php $number = $problem->getEntry()[0]->getNumber(); ?>
<div class="problem-item">
<a href="/<?=htmlspecialchars($log->getId()->get()) . "#L" . $number; ?>" class="problem-entry" onclick="updateLineNumber('#L<?=$number; ?>');">
<span class="problem-label">
<i class="fa-solid fa-triangle-exclamation"></i>
Problem
</span>
<span class="problem-text"><?=htmlspecialchars($problem->getMessage()); ?></span>
<span class="problem-line">Line <?=$number; ?></span>
</a>
<?php if(count($problem->getSolutions()) > 0): ?>
<div class="problem-solutions">
<span class="problem-solutions-label"><?=count($problem->getSolutions()) === 1 ? 'Solution:' : 'Solutions:'; ?></span>
<?php foreach($problem->getSolutions() as $solution): ?>
<div class="problem-solution">
<i class="fa-solid fa-lightbulb"></i>
<span><?=preg_replace("/'([^']+)'/", "'<strong>$1</strong>'", htmlspecialchars($solution->getMessage())); ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
</main>
<div class="log-container">
<div class="log">
<?php
echo $log->getPrinter()->print();
?>
</div>
</div>
<div class="log-footer">
<div class="log-bottom">
<div class="btn btn-small btn-dark" id="up-button" title="Scroll to top">
<i class="fa fa-arrow-circle-up"></i>
</div>
<div class="actions">
<?php if ($log->hasValidTokenCookie()): ?>
<div class="delete-wrapper popover-wrapper">
<button class="delete-trigger popover-trigger btn btn-small btn-danger" title="Delete log" popovertarget="delete-overlay">
<i class="fa-solid fa-trash"></i>
Delete
</button>
<div class="delete-overlay popover-content popover-danger" id="delete-overlay" popover>
<span class="delete-message">Delete this log permanently?</span>
<div class="popover-error">
</div>
<div class="delete-actions">
<button class="btn btn-small btn-white" popovertarget="delete-overlay">Cancel</button>
<button class="btn btn-small btn-danger delete-log-button">Delete</button>
</div>
</div>
</div>
<?php endif; ?>
<div class="settings-dropdown popover-wrapper">
<button class="settings-trigger popover-trigger btn btn-small btn-dark" title="Settings" popovertarget="settings-overlay">
<i class="fas fa-cog"></i>
Settings
</button>
<div class="settings-overlay popover-content" id="settings-overlay" popover>
<?php foreach(Setting::cases() as $setting): ?>
<label class="setting" for="setting-<?=$setting->value; ?>">
<span class="setting-label"><?=$setting->getLabel(); ?></span>
<input type="checkbox"
id="setting-<?=$setting->value; ?>"
class="setting-checkbox"
data-body-class="<?=$setting->getBodyClass() ?? ""; ?>"
data-key="<?=$setting->value; ?>"
<?=($settings->get($setting)) ? " checked" : ""; ?>/>
</label>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="log-details">
<?php
$source = $log->getSource();
$created = $log->getCreated()?->toDateTime()->getTimestamp();
?>
<?php if ($source || $created): ?>
<div class="meta-data">
<?php if ($source): ?>
<div class="source" title="Source">
<i class="fa-solid fa-arrow-up-from-bracket"></i>
<?=htmlspecialchars($source); ?>
</div>
<?php endif; ?>
<?php if ($created): ?>
<div class="created-time" title="Created">
<i class="fa-solid fa-clock"></i>
<span class="created" data-time="<?=htmlspecialchars($created); ?>">
</span>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="delete-notice">
This log will be saved for <?= htmlspecialchars(TimeInterval::getInstance()->format(Config::getInstance()->get(ConfigKey::STORAGE_TTL))); ?> from its last view.
</div>
<?php if ($abuseEmail = Config::getInstance()->get(ConfigKey::LEGAL_ABUSE)): ?>
<a href="mailto:<?=htmlspecialchars($abuseEmail); ?>?subject=Report%20<?=htmlspecialchars(rawurlencode(Config::getInstance()->getName())); ?>/<?=htmlspecialchars($log->getId()->get()); ?>" class="report-link">
<i class="fa-solid fa-flag"></i>
Report abuse
</a>
<?php endif; ?>
</div>
</div>
<?php include __DIR__ . '/parts/footer.php'; ?>
<div class="floating-scrollbar-container">
<div class="floating-scrollbar">
<div class="floating-scrollbar-content">
</div>
</div>
</div>
<?= AssetLoader::getInstance()->getHTML(AssetType::JS, "js/log.js"); ?>
</body>
</html>

View File

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

After

Width:  |  Height:  |  Size: 470 B

View File

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

View File

@@ -0,0 +1,44 @@
<?php
use Aternos\Mclogs\Config\Config;
use Aternos\Mclogs\Config\ConfigKey;
use Aternos\Mclogs\Frontend\Assets\AssetLoader;
use Aternos\Mclogs\Frontend\Assets\AssetType;
use Aternos\Mclogs\Util\URL;
?>
<meta charset="utf-8"/>
<base href="/"/>
<?= AssetLoader::getInstance()->getHTML(AssetType::CSS, "vendor/fontawesome/css/fontawesome.min.css"); ?>
<?= AssetLoader::getInstance()->getHTML(AssetType::CSS, "css/mclogs.css"); ?>
<style>
:root {
--bg: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_BACKGROUND)); ?>;
--text: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_TEXT)); ?>;
--accent: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ACCENT)); ?>;
--error: <?= htmlspecialchars(Config::getInstance()->get(ConfigKey::FRONTEND_COLOR_ERROR)); ?>;
}
</style>
<link rel="shortcut icon" href="img/favicon.ico" type="image/x-icon" sizes="any"/>
<link rel="shortcut icon" href="<?= htmlspecialchars(URL::getBase()->withPath("/favicon.svg")->toString()); ?>" type="image/svg+xml">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<?php if (Config::getInstance()->get(ConfigKey::FRONTEND_ANALYTICS)): ?>
<script>
let _paq = window._paq = window._paq || [];
_paq.push(['disableCookies']);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function () {
_paq.push(['setTrackerUrl', '/data']);
_paq.push(['setSiteId', '5']);
let d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true;
g.src = '/data.js';
s.parentNode.insertBefore(g, s);
})();
</script>
<?php endif; ?>

View File

@@ -0,0 +1,49 @@
<header>
<a href="<?=htmlspecialchars(\Aternos\Mclogs\Util\URL::getBase()->toString()); ?>" class="logo">
<svg class="logo-icon" width="41" height="42" viewBox="0 0 41 42" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect width="41" height="5" rx="2" fill="currentColor"/>
<rect y="9.25" width="33" height="5" rx="2" fill="currentColor"/>
<rect y="18.5" width="19" height="5" rx="2" fill="currentColor"/>
<rect y="27.75" width="33" height="5" rx="2" fill="currentColor"/>
<rect y="37" width="41" height="5" rx="2" fill="currentColor"/>
</svg>
<span class="logo-text"><?= htmlspecialchars(\Aternos\Mclogs\Config\Config::getInstance()->getName()); ?></span>
</a>
<div class="tagline">
<h1 class="tagline-main"><span class="title-verb">Paste</span> your logs.</h1>
<div class="tagline-sub">Built for Minecraft & Hytale</div>
</div>
<script>
const titles = ["Paste", "Share", "Analyse"];
let currentTitle = 0;
let speed = 30;
let pause = 3000;
const titleElement = document.querySelector('.title-verb');
setTimeout(nextTitle, pause);
function nextTitle() {
currentTitle++;
if (typeof (titles[currentTitle]) === "undefined") {
currentTitle = 0;
}
const title = titleElement.innerHTML;
for (let i = 0; i < title.length - 1; i++) {
setTimeout(function () {
titleElement.innerHTML = titleElement.innerHTML.substring(0, titleElement.innerHTML.length - 1);
}, i * speed);
}
const newTitle = titles[currentTitle];
for (let i = 1; i <= newTitle.length; i++) {
setTimeout(function () {
titleElement.innerHTML = newTitle.substring(0, titleElement.innerHTML.length + 1);
}, title.length * speed + i * speed);
}
setTimeout(nextTitle, title.length * speed + newTitle.length * speed + pause);
}
</script>
</header>

37
web/frontend/start.php Normal file
View File

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

2006
web/public/css/mclogs.css Normal file

File diff suppressed because it is too large Load Diff

BIN
web/public/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,7 @@
<svg width="41" height="42" viewBox="0 0 41 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="41" height="5" rx="2" fill="currentColor"/>
<rect y="9.25" width="33" height="5" rx="2" fill="currentColor"/>
<rect y="18.5" width="19" height="5" rx="2" fill="currentColor"/>
<rect y="27.75" width="33" height="5" rx="2" fill="currentColor"/>
<rect y="37" width="41" height="5" rx="2" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

23
web/public/img/logo.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg width="300" height="59" viewBox="0 0 300 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: dark) {
#text { fill: #F5F5F5; }
}
</style>
<g id="icon" fill="#5CB85C">
<path d="M0 2.69611C0 1.20709 1.20709 0 2.69611 0H53.9221C55.4112 0 56.6182 1.20709 56.6182 2.69611V4.04416C56.6182 5.53318 55.4112 6.74027 53.9221 6.74027H2.69611C1.20709 6.74027 0 5.53318 0 4.04416V2.69611Z"/>
<path d="M0 15.1656C0 13.6766 1.20709 12.4695 2.69611 12.4695H41.7897C43.2787 12.4695 44.4858 13.6766 44.4858 15.1656V16.5137C44.4858 18.0027 43.2787 19.2098 41.7897 19.2098H2.69611C1.20709 19.2098 0 18.0027 0 16.5137V15.1656Z"/>
<path d="M0 27.6351C0 26.1461 1.20709 24.939 2.69611 24.939H22.9169C24.4059 24.939 25.613 26.1461 25.613 27.6351V28.9831C25.613 30.4722 24.4059 31.6793 22.9169 31.6793H2.69611C1.20709 31.6793 0 30.4722 0 28.9831V27.6351Z"/>
<path d="M0 40.1046C0 38.6156 1.20709 37.4085 2.69611 37.4085H41.7897C43.2787 37.4085 44.4858 38.6156 44.4858 40.1046V41.4526C44.4858 42.9417 43.2787 44.1487 41.7897 44.1487H2.69611C1.20709 44.1487 0 42.9417 0 41.4526V40.1046Z"/>
<path d="M0 52.5741C0 51.0851 1.20709 49.878 2.69611 49.878H53.9221C55.4112 49.878 56.6182 51.0851 56.6182 52.5741V53.9221C56.6182 55.4112 55.4112 56.6182 53.9221 56.6182H2.69611C1.20709 56.6182 0 55.4112 0 53.9221V52.5741Z"/>
</g>
<g id="text" fill="#2B2B2B">
<path d="M85.1589 46.1334V16.0175H91.9682V22.9929L91.1932 21.8303C91.7468 19.6528 92.854 18.0289 94.5148 16.9586C96.1756 15.8883 98.1316 15.3532 100.383 15.3532C102.856 15.3532 105.033 15.9991 106.915 17.2908C108.798 18.5825 110.016 20.2802 110.569 22.3839L108.521 22.55C109.444 20.1511 110.828 18.3611 112.673 17.1801C114.518 15.9622 116.64 15.3532 119.039 15.3532C121.18 15.3532 123.081 15.833 124.741 16.7926C126.439 17.7521 127.768 19.0992 128.727 20.8338C129.687 22.5316 130.167 24.5061 130.167 26.7574V46.1334H122.914V28.4735C122.914 27.1449 122.675 26.0008 122.195 25.0412C121.715 24.0816 121.051 23.3435 120.202 22.8268C119.353 22.2732 118.32 21.9964 117.102 21.9964C115.958 21.9964 114.943 22.2732 114.057 22.8268C113.171 23.3435 112.488 24.0816 112.009 25.0412C111.529 26.0008 111.289 27.1449 111.289 28.4735V46.1334H104.037V28.4735C104.037 27.1449 103.797 26.0008 103.317 25.0412C102.837 24.0816 102.154 23.3435 101.269 22.8268C100.42 22.2732 99.4049 21.9964 98.2239 21.9964C97.0798 21.9964 96.0649 22.2732 95.1791 22.8268C94.2933 23.3435 93.6106 24.0816 93.1308 25.0412C92.651 26.0008 92.4111 27.1449 92.4111 28.4735V46.1334H85.1589Z"/>
<path d="M150.806 46.7977C147.817 46.7977 145.123 46.1149 142.724 44.7494C140.362 43.3469 138.498 41.4647 137.133 39.1027C135.767 36.7037 135.084 34.0095 135.084 31.0201C135.084 28.0307 135.767 25.3549 137.133 22.9929C138.498 20.6309 140.362 18.7671 142.724 17.4015C145.123 16.036 147.817 15.3532 150.806 15.3532C152.947 15.3532 154.94 15.7407 156.785 16.5158C158.631 17.2539 160.218 18.2873 161.546 19.6159C162.912 20.9077 163.89 22.4577 164.48 24.2662L158.114 27.0342C157.56 25.521 156.619 24.3031 155.291 23.3804C153.999 22.4577 152.504 21.9964 150.806 21.9964C149.219 21.9964 147.799 22.3839 146.544 23.159C145.326 23.934 144.366 25.0043 143.665 26.3699C142.964 27.7354 142.613 29.3039 142.613 31.0755C142.613 32.847 142.964 34.4155 143.665 35.7811C144.366 37.1466 145.326 38.2169 146.544 38.9919C147.799 39.767 149.219 40.1545 150.806 40.1545C152.541 40.1545 154.054 39.6932 155.346 38.7705C156.638 37.8478 157.56 36.6115 158.114 35.0614L164.48 37.9401C163.89 39.6378 162.93 41.1694 161.602 42.535C160.273 43.8636 158.686 44.9155 156.841 45.6905C154.995 46.4286 152.984 46.7977 150.806 46.7977Z"/>
<path d="M169.983 46.1334V4.22583H177.235V46.1334H169.983Z"/>
<path d="M198.657 46.7977C195.704 46.7977 193.01 46.1149 190.574 44.7494C188.175 43.3838 186.256 41.52 184.817 39.158C183.415 36.796 182.713 34.1018 182.713 31.0755C182.713 28.0491 183.415 25.3549 184.817 22.9929C186.256 20.6309 188.175 18.7671 190.574 17.4015C192.973 16.036 195.668 15.3532 198.657 15.3532C201.61 15.3532 204.285 16.036 206.684 17.4015C209.083 18.7671 210.984 20.6309 212.386 22.9929C213.826 25.318 214.545 28.0122 214.545 31.0755C214.545 34.1018 213.826 36.796 212.386 39.158C210.947 41.52 209.028 43.3838 206.629 44.7494C204.23 46.1149 201.573 46.7977 198.657 46.7977ZM198.657 40.1545C200.281 40.1545 201.702 39.767 202.92 38.9919C204.175 38.2169 205.153 37.1466 205.854 35.7811C206.592 34.3786 206.961 32.8101 206.961 31.0755C206.961 29.3039 206.592 27.7539 205.854 26.4252C205.153 25.0597 204.175 23.9894 202.92 23.2143C201.702 22.4024 200.281 21.9964 198.657 21.9964C196.996 21.9964 195.538 22.4024 194.284 23.2143C193.029 23.9894 192.032 25.0597 191.294 26.4252C190.593 27.7539 190.242 29.3039 190.242 31.0755C190.242 32.8101 190.593 34.3786 191.294 35.7811C192.032 37.1466 193.029 38.2169 194.284 38.9919C195.538 39.767 196.996 40.1545 198.657 40.1545Z"/>
<path d="M223.477 46.1334V38.383H230.785V46.1334H223.477Z"/>
<path d="M255.582 58.3126C253.331 58.3126 251.246 57.9435 249.327 57.2054C247.407 56.4673 245.747 55.4339 244.344 54.1052C242.979 52.8135 241.982 51.2819 241.355 49.5104L248.109 46.9638C248.552 48.3662 249.419 49.4919 250.711 50.3408C252.039 51.2265 253.663 51.6694 255.582 51.6694C257.059 51.6694 258.35 51.3926 259.457 50.839C260.602 50.2854 261.487 49.4734 262.115 48.4032C262.742 47.3698 263.056 46.1149 263.056 44.6387V37.774L264.44 39.4348C263.406 41.2433 262.022 42.6088 260.288 43.5315C258.553 44.4541 256.579 44.9155 254.364 44.9155C251.559 44.9155 249.05 44.2696 246.835 42.9779C244.621 41.6861 242.886 39.9146 241.632 37.6633C240.377 35.412 239.749 32.8839 239.749 30.079C239.749 27.2372 240.377 24.709 241.632 22.4946C242.886 20.2802 244.603 18.5456 246.78 17.2908C248.957 15.9991 251.43 15.3532 254.198 15.3532C256.45 15.3532 258.424 15.833 260.122 16.7926C261.856 17.7152 263.296 19.0623 264.44 20.8338L263.443 22.6607V16.0175H270.308V44.6387C270.308 47.259 269.662 49.6026 268.37 51.6694C267.116 53.7362 265.381 55.3601 263.167 56.5411C260.989 57.7221 258.461 58.3126 255.582 58.3126ZM255.25 38.2169C256.8 38.2169 258.147 37.8847 259.291 37.2204C260.472 36.5192 261.395 35.5596 262.059 34.3417C262.724 33.1238 263.056 31.7213 263.056 30.1343C263.056 28.5843 262.705 27.2003 262.004 25.9823C261.34 24.7275 260.417 23.7495 259.236 23.0482C258.092 22.347 256.763 21.9964 255.25 21.9964C253.737 21.9964 252.371 22.347 251.153 23.0482C249.936 23.7495 248.976 24.7275 248.275 25.9823C247.61 27.2003 247.278 28.5843 247.278 30.1343C247.278 31.6844 247.61 33.0684 248.275 34.2863C248.976 35.5043 249.917 36.4638 251.098 37.1651C252.316 37.8663 253.7 38.2169 255.25 38.2169Z"/>
<path d="M288.541 46.7977C285.33 46.7977 282.525 46.0411 280.126 44.5279C277.764 42.9779 276.14 40.8926 275.254 38.2723L280.679 35.6703C281.454 37.368 282.525 38.6967 283.89 39.6563C285.293 40.6158 286.843 41.0956 288.541 41.0956C289.869 41.0956 290.921 40.8004 291.696 40.2099C292.471 39.6194 292.859 38.8443 292.859 37.8847C292.859 37.2942 292.693 36.8144 292.36 36.4454C292.065 36.0394 291.641 35.7072 291.087 35.4489C290.57 35.1536 289.998 34.9138 289.371 34.7292L284.444 33.3452C281.897 32.6071 279.96 31.4814 278.631 29.9683C277.339 28.4551 276.693 26.6651 276.693 24.5983C276.693 22.753 277.155 21.1476 278.077 19.782C279.037 18.3796 280.347 17.2908 282.008 16.5158C283.706 15.7407 285.643 15.3532 287.821 15.3532C290.663 15.3532 293.172 16.036 295.35 17.4015C297.527 18.7671 299.077 20.6862 300 23.159L294.464 25.7609C293.947 24.3953 293.08 23.3066 291.862 22.4946C290.644 21.6827 289.279 21.2767 287.765 21.2767C286.548 21.2767 285.588 21.5535 284.887 22.1071C284.186 22.6607 283.835 23.3804 283.835 24.2662C283.835 24.8198 283.983 25.2996 284.278 25.7055C284.573 26.1115 284.979 26.4437 285.496 26.702C286.049 26.9604 286.677 27.2003 287.378 27.4217L292.194 28.8611C294.667 29.5992 296.568 30.7064 297.896 32.1827C299.262 33.6589 299.945 35.4674 299.945 37.6079C299.945 39.4164 299.465 41.0218 298.505 42.4243C297.546 43.7898 296.217 44.8601 294.519 45.6351C292.822 46.4102 290.829 46.7977 288.541 46.7977Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

309
web/public/js/log.js Normal file
View File

@@ -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`
);
}

365
web/public/js/start.js Normal file
View File

@@ -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<void>}
*/
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<Uint8Array>}
*/
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<Uint8Array>}
*/
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<Uint8Array>}
*/
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]);
}

Some files were not shown because too many files have changed in this diff Show More