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

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.