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

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