first commit
This commit is contained in:
commit
db313f5eef
47 changed files with 2006 additions and 0 deletions
198
app/App.php
Normal file
198
app/App.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
use Exception;
|
||||
use JetBrains\PhpStorm\NoReturn;
|
||||
use League\Plates\Engine;
|
||||
use MingTsay\Akanyan\Discord\Me;
|
||||
use MingTsay\Akanyan\Discord\Token;
|
||||
|
||||
require_once __DIR__ . '/../env.php';
|
||||
|
||||
class App
|
||||
{
|
||||
private const allowedUsers = [
|
||||
305331852225544193, // 小喵#3521
|
||||
307567957863694348, // Aᴋᴀɴʏᴀɴ#2870
|
||||
559026859547951104, // 2072#7474
|
||||
];
|
||||
|
||||
public static function getTemplate(): Engine
|
||||
{
|
||||
static $templates = new Engine(__DIR__ . '/../templates');
|
||||
$me = self::me();
|
||||
$isLogin = self::checkU();
|
||||
$templates->addData([
|
||||
'me' => $me,
|
||||
'whoami' => "$me->username#$me->userDiscriminator",
|
||||
'noStatus' => false,
|
||||
'isLogin' => $isLogin,
|
||||
'isAllowed' => $isLogin && $me !== null && self::isAllowed($me->userId),
|
||||
]);
|
||||
return $templates;
|
||||
}
|
||||
|
||||
public static function getU(): ?Token
|
||||
{
|
||||
return Auth::decrypt($_COOKIE['u'] ?? '');
|
||||
}
|
||||
|
||||
public static function checkU(): bool
|
||||
{
|
||||
$token = self::getU();
|
||||
if ($token === null) return false;
|
||||
if ($token->timestamp + $token->expires_in < time()) return false;
|
||||
return self::me() !== null;
|
||||
}
|
||||
|
||||
public static function setU(Token $token): void
|
||||
{
|
||||
try {
|
||||
setcookie('u', Auth::encrypt($token), [
|
||||
'expires' => $token->timestamp + $token->expires_in,
|
||||
'path' => '/',
|
||||
'domain' => 'akanyan.oho.tw',
|
||||
'samesite' => 'None',
|
||||
'secure' => true,
|
||||
'httponly' => true,
|
||||
]);
|
||||
} catch (Exception) {
|
||||
error_log('Failed to setU.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function unsetU(): void
|
||||
{
|
||||
try {
|
||||
setcookie('u', null, [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'domain' => 'akanyan.oho.tw',
|
||||
'samesite' => 'None',
|
||||
'secure' => true,
|
||||
'httponly' => true,
|
||||
]);
|
||||
} catch (Exception) {
|
||||
error_log('Failed to unsetU.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireAuth(): void
|
||||
{
|
||||
if (!self::checkU()) {
|
||||
header('location: /login.php');
|
||||
http_response_code(302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireNonAuth(): void
|
||||
{
|
||||
if (self::checkU()) {
|
||||
header('location: /');
|
||||
http_response_code(302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireAllowed(): void
|
||||
{
|
||||
self::requireAuth();
|
||||
$me = self::me();
|
||||
if ($me === null || !self::isAllowed($me->userId)) {
|
||||
self::template([
|
||||
'title' => '您無權限使用本系統',
|
||||
'body' => <<<HTML
|
||||
<div class="mt-5">
|
||||
<p>您的 Discord 帳號不在白名單中。</p>
|
||||
<p>若您認為這是個錯誤,請聯絡 <a href="https://discordapp.com/users/305331852225544193" target="_blank">小喵#3521</a> 並提供您的使用者編號 <code>$me->userId</code>。</p>
|
||||
</div>
|
||||
HTML,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function auth(string $code): void
|
||||
{
|
||||
try {
|
||||
$token = Auth::getTokenByCode($code);
|
||||
if ($token !== null) self::setU($token);
|
||||
} catch (Exception) {
|
||||
error_log('Failed to getTokenByCode.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function me(): ?Me
|
||||
{
|
||||
try {
|
||||
$u = self::getU();
|
||||
if ($u === null) return null;
|
||||
return Auth::getMe($u);
|
||||
} catch (Exception) {
|
||||
error_log('Failed to getMe.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#[NoReturn]
|
||||
public static function template(array $params): void
|
||||
{
|
||||
$status = '';
|
||||
if (self::checkU() && !$params['no_status']) {
|
||||
$me = self::me();
|
||||
$status = <<<HTML
|
||||
<div>您已使用 <code>$me->username#$me->userDiscriminator</code> 登入,點選此處以<a href="/logout.php">登出系統</a>。</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant-TW">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>$params[title]</title>
|
||||
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="/style.css"/>
|
||||
</head>
|
||||
<body class="text-center">
|
||||
<h1 class="h3 mb-3 font-weight-normal">$params[title]</h1>
|
||||
$status
|
||||
<main>$params[body]</main>
|
||||
<footer class="mt-5 mb-3 text-muted">Copyright © 2022 Ming Tsay. All rights reserved.</footer>
|
||||
<script src="/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Content-Length: ' . strlen($html));
|
||||
http_response_code(200);
|
||||
echo($html);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function authUrl(): string
|
||||
{
|
||||
return Auth::authorize();
|
||||
}
|
||||
|
||||
public static function isAllowed(string $userId): bool
|
||||
{
|
||||
return in_array($userId, self::allowedUsers);
|
||||
}
|
||||
|
||||
#[NoReturn]
|
||||
public static function render(string $name, array $data = []): void
|
||||
{
|
||||
$html = self::getTemplate()->render($name, $data);
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Content-Length: ' . strlen($html));
|
||||
http_response_code(200);
|
||||
echo($html);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
80
app/Auth.php
Normal file
80
app/Auth.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
use Exception;
|
||||
use MingTsay\Akanyan\Discord\Me;
|
||||
use MingTsay\Akanyan\Discord\Token;
|
||||
|
||||
require_once __DIR__ . '/../env.php';
|
||||
|
||||
class Auth
|
||||
{
|
||||
private const DISCORD_REDIRECT_URI = 'https://akanyan.oho.tw/auth.php?source=discord';
|
||||
|
||||
public static function encrypt(Token $payload): ?string
|
||||
{
|
||||
$key = file_get_contents(__DIR__ . '/../sodium_key');
|
||||
try {
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(json_encode($payload), '', $nonce, $key);
|
||||
|
||||
return implode(';', array_map('base64_encode', [$nonce, $cipher]));
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function decrypt(string $u): ?Token
|
||||
{
|
||||
$key = file_get_contents(__DIR__ . '/../sodium_key');
|
||||
try {
|
||||
list($nonce, $cipher) = array_map('base64_decode', explode(';', $u));
|
||||
$payload = json_decode(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $key));
|
||||
if ($payload === null) return null;
|
||||
return new Token($payload);
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function authorize(): string
|
||||
{
|
||||
$query_string = http_build_query([
|
||||
'client_id' => $_ENV['DISCORD_CLIENT_ID'],
|
||||
'redirect_uri' => self::DISCORD_REDIRECT_URI,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'identify',
|
||||
]);
|
||||
return "https://discord.com/api/oauth2/authorize?$query_string";
|
||||
}
|
||||
|
||||
public static function getTokenByCode(string $code): ?Token
|
||||
{
|
||||
$payload = json_decode(file_get_contents('https://discord.com/api/oauth2/token', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/x-www-form-urlencoded',
|
||||
'content' => http_build_query([
|
||||
'client_id' => $_ENV['DISCORD_CLIENT_ID'],
|
||||
'client_secret' => $_ENV['DISCORD_CLIENT_SECRET'],
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => self::DISCORD_REDIRECT_URI,
|
||||
]),
|
||||
],
|
||||
])));
|
||||
if ($payload === null) return null;
|
||||
return new Token($payload);
|
||||
}
|
||||
|
||||
public static function getMe(Token $getU): ?Me
|
||||
{
|
||||
return new Me(json_decode(file_get_contents('https://discord.com/api/oauth2/@me', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => "Authorization: Bearer $getU->access_token",
|
||||
],
|
||||
]))));
|
||||
}
|
||||
}
|
||||
32
app/ConfigFilesViewer.php
Normal file
32
app/ConfigFilesViewer.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
class ConfigFilesViewer extends FileViewer
|
||||
{
|
||||
|
||||
protected static function directory(): string
|
||||
{
|
||||
return '/media/sdc/mt/minecraft-docker-image/data';
|
||||
}
|
||||
|
||||
protected static function whitelist(): ?array
|
||||
{
|
||||
return [
|
||||
'banned-ips.json',
|
||||
'banned-players.json',
|
||||
'ops.json',
|
||||
'server.properties',
|
||||
'usercache.json',
|
||||
'whitelist.json',
|
||||
];
|
||||
}
|
||||
|
||||
public static function read($file): ?string
|
||||
{
|
||||
$content = parent::read($file);
|
||||
if ($content !== null && $file === 'server.properties')
|
||||
$content = preg_replace('/^(rcon\.password)=(.*)$/m', '$1=***hidden***', $content);
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
39
app/Discord/Me.php
Normal file
39
app/Discord/Me.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan\Discord;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class Me extends stdClass
|
||||
{
|
||||
public string $appId;
|
||||
public string $appName;
|
||||
public ?string $appIcon;
|
||||
public string $appDescription;
|
||||
public bool $appHook;
|
||||
public string $appVerifyKey;
|
||||
public array $scopes;
|
||||
public int $expires;
|
||||
public string $userId;
|
||||
public string $username;
|
||||
public string $userAvatar;
|
||||
public string $userDiscriminator;
|
||||
public string $userPublicFlags;
|
||||
|
||||
public function __construct(stdClass $payload)
|
||||
{
|
||||
$this->appId = $payload->application->id;
|
||||
$this->appName = $payload->application->name;
|
||||
$this->appIcon = $payload->application->icon;
|
||||
$this->appDescription = $payload->application->description;
|
||||
$this->appHook = $payload->application->hook;
|
||||
$this->appVerifyKey = $payload->application->verify_key;
|
||||
$this->scopes = $payload->scopes;
|
||||
$this->expires = strtotime($payload->expires);
|
||||
$this->userId = $payload->user->id;
|
||||
$this->username = $payload->user->username;
|
||||
$this->userAvatar = $payload->user->avatar;
|
||||
$this->userDiscriminator = $payload->user->discriminator;
|
||||
$this->userPublicFlags = $payload->user->public_flags;
|
||||
}
|
||||
}
|
||||
27
app/Discord/Token.php
Normal file
27
app/Discord/Token.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan\Discord;
|
||||
|
||||
use stdClass;
|
||||
|
||||
class Token extends stdClass
|
||||
{
|
||||
public string $access_token;
|
||||
public string $token_type;
|
||||
public int $expires_in;
|
||||
public string $refresh_token;
|
||||
public string $scope;
|
||||
|
||||
public int $timestamp;
|
||||
|
||||
public function __construct(stdClass $payload)
|
||||
{
|
||||
$this->access_token = $payload->access_token;
|
||||
$this->token_type = $payload->token_type;
|
||||
$this->expires_in = $payload->expires_in;
|
||||
$this->refresh_token = $payload->refresh_token;
|
||||
$this->scope = $payload->scope;
|
||||
|
||||
$this->timestamp = time();
|
||||
}
|
||||
}
|
||||
33
app/FileViewer.php
Normal file
33
app/FileViewer.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
abstract class FileViewer
|
||||
{
|
||||
protected abstract static function directory(): string;
|
||||
|
||||
protected abstract static function whitelist(): ?array;
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$directory = static::directory();
|
||||
$whitelist = static::whitelist();
|
||||
|
||||
return array_values(array_filter(
|
||||
scandir($directory),
|
||||
fn($file) => ($whitelist === null || in_array($file, $whitelist)) && is_file("$directory/$file")
|
||||
));
|
||||
}
|
||||
|
||||
public static function read($file): ?string
|
||||
{
|
||||
$directory = static::directory();
|
||||
$filename = "$directory/$file";
|
||||
|
||||
if (!in_array($file, self::list()) || !file_exists($filename) || !is_file($filename)) return null;
|
||||
|
||||
$extension = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$content = file_get_contents($filename);
|
||||
return $extension === 'gz' ? gzdecode($content) : $content;
|
||||
}
|
||||
}
|
||||
17
app/LogsViewer.php
Normal file
17
app/LogsViewer.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
class LogsViewer extends FileViewer
|
||||
{
|
||||
|
||||
protected static function directory(): string
|
||||
{
|
||||
return '/media/sdc/mt/minecraft-docker-image/data/logs';
|
||||
}
|
||||
|
||||
protected static function whitelist(): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
25
app/Minecraft.php
Normal file
25
app/Minecraft.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace MingTsay\Akanyan;
|
||||
|
||||
use xPaw\MinecraftPing;
|
||||
use xPaw\MinecraftPingException;
|
||||
|
||||
class Minecraft
|
||||
{
|
||||
public static function query(): ?array
|
||||
{
|
||||
$host = $_ENV['MINECRAFT_QUERY_HOST'];
|
||||
$port = (int)$_ENV['MINECRAFT_QUERY_PORT'];
|
||||
|
||||
$query = new MinecraftPing($host, $port);
|
||||
try {
|
||||
$result = $query->Query();
|
||||
} catch (MinecraftPingException) {
|
||||
$result = null;
|
||||
} finally {
|
||||
$query->Close();
|
||||
}
|
||||
return $result ?: null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue