1491 lines
35 KiB
PHP
1491 lines
35 KiB
PHP
#!/usr/bin/php
|
|
<?php
|
|
/*! untisbot v1.1.1 | (c) 2023 DrMaxNix | www.drmaxnix.de */
|
|
declare(strict_types = 1);
|
|
define("CONFIG_PATH", "/etc/untisbot/untisbot.ini");
|
|
define("UNTISBOT_VERSION", "1.1.1");
|
|
|
|
|
|
|
|
|
|
|
|
/////////////
|
|
// I N I T //
|
|
/////////////
|
|
|
|
// EXIT IF NOT CALLED BY SYSTEMD //
|
|
if(getenv("SYSTEMD") !== "true"){
|
|
echo("[ERROR] Must be started by systemd" . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
|
|
// EXIT IF NOT RUNNING AS ROOT USER //
|
|
if(!isset($_SERVER["USER"]) or $_SERVER["USER"] !== "untisbot"){
|
|
echo("[ERROR] Must run as user 'untisbot'" . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
|
|
// LOAD PHPMAILER EXTENSION //
|
|
// check if installed
|
|
if(!is_file(__DIR__ . "/PHPMailer/src/Exception.php")){
|
|
echo("[ERROR] PHPMailer not installed" . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
// load
|
|
require(__DIR__ . "/PHPMailer/src/Exception.php");
|
|
require(__DIR__ . "/PHPMailer/src/PHPMailer.php");
|
|
require(__DIR__ . "/PHPMailer/src/SMTP.php");
|
|
|
|
|
|
// SET UP SIGNAL HANDLERS //
|
|
declare(ticks = 1);
|
|
pcntl_signal(SIGHUP, ["Daemon", "interrupt"]);
|
|
pcntl_signal(SIGTERM, ["Daemon", "interrupt"]);
|
|
pcntl_signal(SIGINT, ["Daemon", "interrupt"]);
|
|
|
|
|
|
// RUN INIT //
|
|
Daemon::init();
|
|
|
|
|
|
// PRINT INFO //
|
|
echo("[INFO ] untisbot v" . UNTISBOT_VERSION . " initialized successfully" . "\n");
|
|
|
|
|
|
// RUN MAIN //
|
|
Daemon::main();
|
|
|
|
|
|
|
|
|
|
|
|
/////////////////////////////
|
|
// D A E M O N C L A S S //
|
|
/////////////////////////////
|
|
|
|
class Daemon {
|
|
/**
|
|
* @var bool $interrupt_allow Whether interrupts are currently allowed.
|
|
*/
|
|
public static bool $interrupt_allow = false;
|
|
|
|
|
|
|
|
/**
|
|
* @var array $interrupt_queue List of pending interrupts to handle.
|
|
*/
|
|
public static array $interrupt_queue = [];
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Do initialization.
|
|
*/
|
|
public static function init(): void {
|
|
// LOAD CONFIG //
|
|
Manager::config_load();
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Run main code.
|
|
*/
|
|
public static function main(): void {
|
|
while(true){
|
|
// POSTPONE INTERRUPTS //
|
|
self::$interrupt_allow = false;
|
|
|
|
|
|
// ALIGN BOTS TO CONFIG //
|
|
Manager::align();
|
|
|
|
|
|
// BOT PARENTING //
|
|
Manager::parenting();
|
|
|
|
|
|
// CATCH UP ON MISSED INTERRUPTS //
|
|
self::interrupt_catchup();
|
|
|
|
|
|
// SLEEP //
|
|
usleep(1 * pow(10, 6));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Execute or enqueue interrupt.
|
|
*
|
|
* @param int $signo Signal number.
|
|
* @param mixed $siginfo Additional signal info.
|
|
*/
|
|
public static function interrupt(int $signo, mixed $siginfo): void {
|
|
// CHECK IF INTERRUPTS ARE CURRENTLY ALLOWED //
|
|
if(self::$interrupt_allow){
|
|
// execute now
|
|
self::$interrupt_allow = false;
|
|
self::interrupt_callback($signo, $siginfo);
|
|
|
|
} else {
|
|
// enqueue
|
|
self::$interrupt_queue[$signo] = ["siginfo" => $siginfo];
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Execute enqueued interrupts and allow new interrupts.
|
|
*/
|
|
private static function interrupt_catchup(): void {
|
|
// EXECUTE ENQUEUED INTERRUPTS //
|
|
foreach(self::$interrupt_queue as $signo => $data){
|
|
// execute callback
|
|
self::interrupt_callback($signo, $data["siginfo"]);
|
|
|
|
// remove self from queue
|
|
unset(self::$interrupt_queue[$signo]);
|
|
}
|
|
|
|
|
|
// ALLOW INTERRUPTS //
|
|
self::$interrupt_allow = true;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Decide which callback to execute for interrupt.
|
|
*
|
|
* @param int $signo Signal number.
|
|
* @param mixed $siginfo Additional signal info.
|
|
*/
|
|
private static function interrupt_callback(int $signo, mixed $siginfo): void {
|
|
// hup
|
|
if($signo === SIGHUP){
|
|
self::sighup();
|
|
}
|
|
|
|
// int / term
|
|
if(in_array($signo, [SIGINT, SIGTERM])){
|
|
self::sigterm();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Sighup received.
|
|
*/
|
|
private static function sighup(): void {
|
|
// RELOAD CONFIG //
|
|
Manager::config_load();
|
|
echo("[INFO ] sighub, config reloaded!" . "\n");
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: Sigterm received.
|
|
*/
|
|
private static function sigterm(): void {
|
|
// print info
|
|
echo("[INFO ] sigterm, coming to a halt" . "\n");
|
|
|
|
// exit
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
///////////////////////////////
|
|
// M A N A G E R C L A S S //
|
|
///////////////////////////////
|
|
|
|
class Manager {
|
|
/**
|
|
* @var ?array $config Parsed configuration.
|
|
*/
|
|
private static ?array $config = null;
|
|
|
|
|
|
|
|
/**
|
|
* @var array $bot List of bot instance objects.
|
|
*/
|
|
private static array $bot = [];
|
|
|
|
|
|
|
|
/**
|
|
* @var array $bot_data List of config data for each individual bot.
|
|
*/
|
|
private static array $bot_data = [];
|
|
|
|
|
|
|
|
/**
|
|
* @var array $bot_fetch_interval_config List to keep track of changes in interval config.
|
|
*/
|
|
private static array $bot_fetch_interval_config = [];
|
|
|
|
|
|
|
|
/**
|
|
* CALLBACK: (Re)load config.
|
|
*/
|
|
public static function config_load(): void {
|
|
// READ FILE CONTENTS //
|
|
// get
|
|
$ini = parse_ini_file(CONFIG_PATH, true, INI_SCANNER_TYPED);
|
|
|
|
// check
|
|
if(!is_array($ini)){
|
|
// check if we can still run with old config
|
|
if(is_array(self::$config)){
|
|
echo("[WARN ] unable to reload config" . "\n");
|
|
return;
|
|
|
|
} else {
|
|
echo("[ERROR] unable to load config" . "\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
|
|
// CLEAR OLD CONFIG //
|
|
// account list
|
|
self::$config["account"] = [];
|
|
|
|
|
|
// PARSE //
|
|
self::config_parse($ini);
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Parse an ini config.
|
|
*
|
|
* @param array $ini Ini data obtained via `parse_ini_file()`.
|
|
*/
|
|
private static function config_parse(array $ini): void {
|
|
foreach($ini as $section_key => $section_value){
|
|
// check if this is a section (array without sequential numeric keys)
|
|
if(is_array($section_value) and array_keys($section_value) !== range(0, sizeof($section_value) - 1)){
|
|
// SECTION NAME IS BOT NAME //
|
|
$name = $section_key;
|
|
|
|
|
|
// SERVER HOST //
|
|
$host = $section_value["host"] ?? "";
|
|
if(!preg_match("/^(\w|-)+(\.(\w|-)+)+$/", $host)){
|
|
echo("[WARN ] ignoring account '" . $section_key . "': invalid host address '" . $host . "' in config" . "\n");
|
|
continue;
|
|
}
|
|
|
|
|
|
// SCHOOL NAME //
|
|
$school = $section_value["school"] ?? "";
|
|
if(!preg_match("/^(\w|-)+$/", $school)){
|
|
echo("[WARN ] ignoring account '" . $section_key . "': invalid school name '" . $school . "' in config" . "\n");
|
|
continue;
|
|
}
|
|
|
|
|
|
// AUTHENTICATION //
|
|
if(!isset($section_value["auth.username"])){
|
|
echo("[WARN ] ignoring account '" . $section_key . "': missing username in config" . "\n");
|
|
continue;
|
|
}
|
|
if(!isset($section_value["auth.password"])){
|
|
echo("[WARN ] ignoring account '" . $section_key . "': missing password in config" . "\n");
|
|
continue;
|
|
}
|
|
$auth_username = $section_value["auth.username"];
|
|
$auth_passwd = $section_value["auth.password"];
|
|
|
|
|
|
// NOTIFICATION PREFERENCES //
|
|
$notify = [];
|
|
|
|
// messages
|
|
if(isset($section_value["notify.messages"])){
|
|
$mail = $section_value["notify.messages"];
|
|
if(preg_match("/^(\w|-)+(\.(\w|-)+)*@(\w|-)+(\.(\w|-)+)+$/", $mail)){
|
|
$notify["messages"] = $mail;
|
|
} else {
|
|
echo("[WARN ] ignoring notification preference 'messages' for account '" . $section_key . "': invalid mail address '" . $mail . "' in config" . "\n");
|
|
}
|
|
}
|
|
|
|
|
|
// ADD ACCOUNT //
|
|
self::$config["account"][$name] = [
|
|
"fetch" => [
|
|
"host" => $host,
|
|
"school" => $school,
|
|
"auth" => ["username" => $auth_username, "passwd" => $auth_passwd]
|
|
],
|
|
"notify" => $notify
|
|
];
|
|
}
|
|
}
|
|
|
|
|
|
// FETCH OPTIONS //
|
|
// timeout
|
|
self::$config["fetch"]["timeout"] = 20;
|
|
if(isset($ini["fetch.timeout"])){
|
|
if(is_int($ini["fetch.timeout"]) and $ini["fetch.timeout"] > 0){
|
|
self::$config["fetch"]["timeout"] = $ini["fetch.timeout"];
|
|
} else {
|
|
echo("[WARN ] ignoring fetch timeout: invalid value '" . $ini["fetch.timeout"] . "' in config" . "\n");
|
|
}
|
|
}
|
|
|
|
// file download timeout
|
|
self::$config["fetch"]["file_download_timeout"] = 120;
|
|
if(isset($ini["fetch.file_download_timeout"])){
|
|
if(is_int($ini["fetch.file_download_timeout"]) and $ini["fetch.file_download_timeout"] > 0){
|
|
self::$config["fetch"]["file_download_timeout"] = $ini["fetch.file_download_timeout"];
|
|
} else {
|
|
echo("[WARN ] ignoring fetch file download timeout: invalid value '" . $ini["fetch.file_download_timeout"] . "' in config" . "\n");
|
|
}
|
|
}
|
|
|
|
// messages
|
|
self::$config["fetch"]["interval"]["messages"] = 300;
|
|
if(isset($ini["fetch.interval.messages"])){
|
|
if(is_int($ini["fetch.interval.messages"]) and $ini["fetch.interval.messages"] > 0){
|
|
self::$config["fetch"]["interval"]["messages"] = $ini["fetch.interval.messages"];
|
|
} else {
|
|
echo("[WARN ] ignoring messages fetch interval: invalid value '" . $ini["fetch.interval.messages"] . "' in config" . "\n");
|
|
}
|
|
}
|
|
|
|
|
|
// OPTIONS FOR OUTGOING MAIL SERVER //
|
|
$had_error = false;
|
|
|
|
// host
|
|
$host = $ini["mail.host"] ?? "";
|
|
if(!preg_match("/^(\w|-)+(\.(\w|-)+)+$/", $host)){
|
|
echo("[WARN ] ignoring mail settings: invalid host address '" . $host . "' in config" . "\n");
|
|
$had_error = true;
|
|
}
|
|
|
|
// port
|
|
$port = $ini["mail.port"] ?? -1;
|
|
if(!is_int($port) or $port <= 0 or $port > 65535){
|
|
echo("[WARN ] ignoring mail settings: invalid port '" . $port . "' in config" . "\n");
|
|
$had_error = true;
|
|
}
|
|
|
|
// username
|
|
if(!isset($ini["mail.username"])){
|
|
echo("[WARN ] ignoring mail settings: missing username in config" . "\n");
|
|
$had_error = true;
|
|
}
|
|
$username = $ini["mail.username"];
|
|
|
|
// password
|
|
if(!isset($ini["mail.password"])){
|
|
echo("[WARN ] ignoring mail settings: missing password in config" . "\n");
|
|
$had_error = true;
|
|
}
|
|
$passwd = $ini["mail.password"];
|
|
|
|
// starttls?
|
|
$starttls = $ini["mail.starttls"] ?? "";
|
|
if(!is_bool($starttls)){
|
|
echo("[WARN ] ignoring mail settings: invalid boolean value for starttls option '" . $ini["mail.starttls"] . "' in config" . "\n");
|
|
$had_error = true;
|
|
}
|
|
|
|
// collect options
|
|
if(!$had_error){
|
|
// valid config
|
|
self::$config["mail"] = [
|
|
"host" => $host,
|
|
"port" => $port,
|
|
"username" => $username,
|
|
"passwd" => $passwd,
|
|
"starttls" => $starttls
|
|
];
|
|
|
|
} else {
|
|
// invalid config
|
|
self::$config["mail"] = null;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* GETTER: Get config.
|
|
*/
|
|
public static function config(): array {
|
|
return self::$config;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* SCHEDULED: Create/remove bot instances to match config.
|
|
*/
|
|
public static function align(): void {
|
|
// REMOVE BOTS //
|
|
foreach(self::$bot as $name => $object){
|
|
// check if set in config
|
|
if(!isset(self::$config["account"][$name])){
|
|
// remove bot
|
|
unset(self::$bot[$name]);
|
|
unset(self::$bot_data[$name]);
|
|
unset(self::$bot_fetch_interval_config[$name]);
|
|
echo("[INFO ] removed bot " . $name . "\n");
|
|
}
|
|
}
|
|
|
|
|
|
foreach(self::$config["account"] as $name => $data){
|
|
// CREATE BOT //
|
|
// check if bot with this name exists
|
|
if(!isset(self::$bot[$name])){
|
|
// store account config and fetch interval data
|
|
self::$bot_data[$name] = $data;
|
|
self::$bot_fetch_interval_config[$name] = self::config()["fetch"]["interval"];
|
|
|
|
// create bot
|
|
self::$bot[$name] = new Untisbot($name, self::$bot_data[$name]);
|
|
echo("[INFO ] created bot '" . $name . "'" . "\n");
|
|
continue;
|
|
}
|
|
|
|
|
|
// UPDATE BOT //
|
|
if(self::$bot_data[$name] !== $data or self::$bot_fetch_interval_config[$name] !== self::config()["fetch"]["interval"]){
|
|
// set new account config and fetch interval data
|
|
self::$bot_data[$name] = $data;
|
|
self::$bot_fetch_interval_config[$name] = self::config()["fetch"]["interval"];
|
|
|
|
// create new bot
|
|
self::$bot[$name] = new Untisbot($name, self::$bot_data[$name]);
|
|
echo("[INFO ] updated bot '" . $name . "'" . "\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* SCHEDULED: Do parenting on all bots.
|
|
*/
|
|
public static function parenting(): void {
|
|
foreach(self::$bot as $name => $object){
|
|
$object->parenting();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/////////////////////////////////
|
|
// U N T I S B O T C L A S S //
|
|
/////////////////////////////////
|
|
|
|
class Untisbot {
|
|
/**
|
|
* @var string $name Bot name.
|
|
*/
|
|
private string $name;
|
|
|
|
|
|
|
|
/**
|
|
* @var string $host Remote Server host.
|
|
*/
|
|
private string $host;
|
|
|
|
|
|
|
|
/**
|
|
* @var string $school Account's school name.
|
|
*/
|
|
private string $school;
|
|
|
|
|
|
|
|
/**
|
|
* @var array $auth Auth to use for api requests.
|
|
*/
|
|
private array $auth;
|
|
|
|
|
|
|
|
/**
|
|
* @var array $notify Notification preferences.
|
|
*/
|
|
private array $notify;
|
|
|
|
|
|
|
|
/**
|
|
* @var Timer $timer_fetch_messages Timer object for periodic check for new messages.
|
|
*/
|
|
private Timer $timer_fetch_messages;
|
|
|
|
|
|
|
|
/**
|
|
* CONSTRUCTOR: Create new bot.
|
|
*
|
|
* @param string $name Bot name.
|
|
* @param array $data Bot configuration data.
|
|
*
|
|
* @return Untisbot New Untisbot object.
|
|
*/
|
|
public function __construct(string $name, array $data){
|
|
// SAVE NAME //
|
|
$this->name = $name;
|
|
|
|
|
|
// SAVE DATA //
|
|
// server host
|
|
$this->host = $data["fetch"]["host"];
|
|
|
|
// school name
|
|
$this->school = $data["fetch"]["school"];
|
|
|
|
// authentication
|
|
$this->auth = $data["fetch"]["auth"];
|
|
|
|
// notification preferences
|
|
$this->notify = $data["notify"];
|
|
|
|
|
|
// SET UP FETCH TIMERS //
|
|
$this->timer_fetch_messages = new Timer(Manager::config()["fetch"]["interval"]["messages"]);
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* SCHEDULED: Do parenting on our account.
|
|
*/
|
|
public function parenting(): void {
|
|
// CHECK TIMERS //
|
|
// fetch messages
|
|
if($this->timer_fetch_messages->check()){
|
|
$this->fetch_messages();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* SCHEDULED: Fetch new messages.
|
|
*/
|
|
private function fetch_messages(): void {
|
|
// IGNORE IF DISABLED FOR THIS ACCOUNT //
|
|
if(!isset($this->notify["messages"])) return;
|
|
|
|
|
|
// CREATE NEW API CONNECTION //
|
|
// get api object
|
|
$api = new Untisapi(
|
|
host: $this->host,
|
|
school: $this->school,
|
|
auth: $this->auth
|
|
);
|
|
|
|
// initialize connection
|
|
if(!$api->init()){
|
|
echo("[WARN ] {" . $this->name . "} api init failed" . "\n");
|
|
return;
|
|
}
|
|
|
|
|
|
// LOAD MESSAGE LIST //
|
|
// get api data
|
|
$message_list = $api->message_list();
|
|
if($message_list === null){
|
|
echo("[WARN ] {" . $this->name . "} load message list failed" . "\n");
|
|
return;
|
|
}
|
|
|
|
// merge normal and read-confirmation messages
|
|
$all_messages = array_merge($message_list["incomingMessages"], $message_list["readConfirmationMessages"]);
|
|
|
|
|
|
// GET NEW MESSAGES //
|
|
$new_message_list = [];
|
|
foreach($all_messages as $one_message){
|
|
// check if marked read
|
|
if(!$one_message["isMessageRead"]){
|
|
// handle unread message
|
|
echo("[INFO ] {" . $this->name . "} sending notification for message " . $one_message["id"] . "\n");
|
|
$this->message_unread_handle($api, $one_message["id"]);
|
|
echo("[INFO ] {" . $this->name . "} notification sent" . "\n");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Handle unread message.
|
|
*
|
|
* @param Untisapi $api Api instance to use.
|
|
* @param int $id Id of message to handle.
|
|
*/
|
|
private function message_unread_handle(Untisapi $api, int $id): void {
|
|
// LOAD MESSAGE DETAILS //
|
|
$message = $api->message_details($id);
|
|
if($message === null){
|
|
echo("[WARN ] {" . $this->name . "} load message details failed" . "\n");
|
|
return;
|
|
}
|
|
|
|
|
|
// COLLECT IMPORTANT DATA //
|
|
// subject and content
|
|
$subject = $message["subject"];
|
|
$content = $message["content"];
|
|
|
|
// sender
|
|
$sender = $message["sender"]["displayName"];
|
|
|
|
// timestamp (when the message was sent)
|
|
$timestamp = strtotime($message["sentDateTime"]);
|
|
|
|
// attachments
|
|
$attachment_list = $message["storageAttachments"];
|
|
|
|
|
|
// DOWNLOAD ATTACHMENTS //
|
|
foreach($attachment_list as $num => $one_attachment){
|
|
// store to tmp file
|
|
echo("[INFO ] {" . $this->name . "} downloading attachment " . $one_attachment["id"] . "\n");
|
|
$tmp_path = $api->attachment_download($one_attachment["id"]);
|
|
|
|
// remember tmp path
|
|
$attachment_list[$num]["tmp_path"] = $tmp_path;
|
|
}
|
|
|
|
|
|
// CHECK IF READ RECEIPT IS REQUESTED //
|
|
$read_receipt_requested = isset($message["requestConfirmation"]);
|
|
|
|
|
|
// SEND MAIL //
|
|
// send
|
|
$success = $this->message_unread_send_mail([
|
|
"subject" => $subject,
|
|
"content" => $content,
|
|
"sender" => $sender,
|
|
"timestamp" => $timestamp,
|
|
"attachment_list" => $attachment_list,
|
|
"read_receipt_requested" => $read_receipt_requested
|
|
]);
|
|
|
|
// check if successful
|
|
if(!$success){
|
|
echo("[WARN ] {" . $this->name . "} sending mail notification failed" . "\n");
|
|
}
|
|
|
|
|
|
// CLEAN UP TMP FILES //
|
|
foreach($attachment_list as $num => $one_attachment){
|
|
if($one_attachment["tmp_path"] === null) continue;
|
|
|
|
// delete file
|
|
unlink($one_attachment["tmp_path"]);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Send a mail for an unread message.
|
|
*
|
|
* @param array $data Message data.
|
|
*/
|
|
private function message_unread_send_mail(array $data): bool {
|
|
// MAKE SURE MAIL IS CONFIGURED //
|
|
if(Manager::config()["mail"] === null){
|
|
echo("[WARN ] {" . $this->name . "} unable to send mail because it is not configured" . "\n");
|
|
return false;
|
|
}
|
|
|
|
|
|
// GET MAILER OBJECT //
|
|
$mailer = new Mailer($this->notify["messages"]);
|
|
|
|
|
|
// BUILD SUBJECT //
|
|
$subject = "[UntisBot] " . $data["sender"];
|
|
|
|
|
|
// BUILD BODY //
|
|
// start with subject
|
|
$body = "<b><u>" . $data["subject"] . "</u></b>" . "<br />";
|
|
$body .= "<br />";
|
|
|
|
// add text content
|
|
$body .= $data["content"] . "<br />";
|
|
$body .= "<br />";
|
|
|
|
// add footer
|
|
$body .= "<hr />";
|
|
|
|
if($data["read_receipt_requested"]){
|
|
$body .= "<span style=\"color: orange;\">!! Read receipt requested !!</span>" . "<br />";
|
|
$body .= "<br />";
|
|
}
|
|
|
|
if(sizeof($data["attachment_list"]) > 0){
|
|
$body .= "Attachments (" . sizeof($data["attachment_list"]) . "):" . "<br />";
|
|
$body .= "<ul>";
|
|
foreach($data["attachment_list"] as $one_attachment){
|
|
$body .= "<li>" . $one_attachment["name"] . ($one_attachment["tmp_path"] === null ? " <span style=\"color: red;\">[DOWNLOAD FAILED]</span>" : "") . "</li>" . "<br />";
|
|
}
|
|
$body .= "</ul>";
|
|
}
|
|
|
|
$body .= "<span style=\"color: gray\">" . "Message sent to " . $this->name . " on " . date("D d-M-Y H:i:s T", $data["timestamp"]) . "</span>" . "<br />";
|
|
$body .= "<span style=\"color: gray\">" . "UntisBot v" . UNTISBOT_VERSION . "</span>";
|
|
|
|
|
|
// ADD SUBJECT AND BODY //
|
|
$mailer->content_set(subject: $subject, body: $body);
|
|
|
|
|
|
// ADD ATTACHMENTS //
|
|
foreach($data["attachment_list"] as $one_attachment){
|
|
// skip if not able to be downloaded or missing
|
|
if($one_attachment["tmp_path"] === null or !file_exists($one_attachment["tmp_path"])) continue;
|
|
|
|
// attach file
|
|
$mailer->attachment_add(path: $one_attachment["tmp_path"], filename: $one_attachment["name"]);
|
|
}
|
|
|
|
|
|
// SEND MAIL //
|
|
return $mailer->send();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/////////////////////////////////
|
|
// U N T I S A P I C L A S S //
|
|
/////////////////////////////////
|
|
|
|
class Untisapi {
|
|
/**
|
|
* @var string $host Remote Server host.
|
|
*/
|
|
private string $host;
|
|
|
|
|
|
|
|
/**
|
|
* @var string $school Account's school name.
|
|
*/
|
|
private string $school;
|
|
|
|
|
|
|
|
/**
|
|
* @var array $auth Auth to use for api requests.
|
|
*/
|
|
private array $auth;
|
|
|
|
|
|
|
|
/**
|
|
* @var CurlHandle $curl Curl handle.
|
|
*/
|
|
private CurlHandle $curl;
|
|
|
|
|
|
|
|
/**
|
|
* @var ?string $csrf Csrf token for current session.
|
|
*/
|
|
private ?string $csrf;
|
|
|
|
|
|
|
|
/**
|
|
* @var ?string $bearer Bearer token for current session.
|
|
*/
|
|
private ?string $bearer;
|
|
|
|
|
|
|
|
/**
|
|
* FLAG: Don't send a token.
|
|
*/
|
|
public const TOKEN_NONE = 0;
|
|
|
|
|
|
|
|
/**
|
|
* FLAG: Use csrf token.
|
|
*/
|
|
public const TOKEN_CSRF = 1;
|
|
|
|
|
|
|
|
/**
|
|
* FLAG: Use bearer token.
|
|
*/
|
|
public const TOKEN_BEARER = 2;
|
|
|
|
|
|
|
|
/**
|
|
* CONSTRUCTOR: Create new api handle.
|
|
*
|
|
* @param string $host Remote Server host.
|
|
* @param string $school Account's school name.
|
|
* @param array $auth Auth to use for api requests.
|
|
*
|
|
* @return Untisapi New Untisapi object.
|
|
*/
|
|
public function __construct(string $host, string $school, array $auth){
|
|
// SAVE ACCOUNT DATA //
|
|
$this->host = $host;
|
|
$this->school = $school;
|
|
$this->auth = $auth;
|
|
|
|
|
|
// GET CURL OBJECT //
|
|
$this->curl = curl_init();
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Initialize connection with api.
|
|
*
|
|
* @return bool Whether initialization was successful.
|
|
*/
|
|
public function init(): bool {
|
|
// GET CSRF TOKEN //
|
|
$this->csrf = $this->csrf();
|
|
if($this->csrf === null){
|
|
echo("[WARN ] unable to initialize api connection (get csrf failed)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
|
|
// DO LOGIN //
|
|
if(!$this->login()){
|
|
echo("[WARN ] unable to initialize api connection (login failed)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
|
|
// GET BEARER TOKEN //
|
|
$this->bearer = $this->bearer();
|
|
if($this->bearer === null){
|
|
echo("[WARN ] unable to initialize api connection (get bearer failed)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
|
|
// SUCCESS //
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Get a csrf token.
|
|
*
|
|
* @return ?string `null`: Failed to obtain token
|
|
* string: Value of csrf token.
|
|
*/
|
|
private function csrf(): ?string {
|
|
// GET RAW HTML //
|
|
// do fetch
|
|
$html = $this->fetch("index.do?school=" . $this->school, token: self::TOKEN_NONE);
|
|
|
|
// check if successful
|
|
if($html === null){
|
|
echo("[WARN ] unable to get csrf token (fetch failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// GET CONFIG //
|
|
// find in html
|
|
if(!preg_match("/(?<=config: ){[^\n]+}(?=,)/", $html, $match)){
|
|
echo("[WARN ] unable to get csrf token (didn't find config section in html)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
// decode json
|
|
$config = json_decode($match[0], true);
|
|
if(json_last_error() !== JSON_ERROR_NONE){
|
|
echo("[WARN ] unable to get csrf token (config section has invalid json)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// GET TOKEN //
|
|
// load from config
|
|
$csrf = $config["csrfToken"] ?? "";
|
|
|
|
// validate format
|
|
if(!preg_match("/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/", $csrf)){
|
|
echo("[WARN ] unable to get csrf token (invalid format)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// RETURN CSRF TOKEN //
|
|
return $csrf;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Do login with given credentials.
|
|
*
|
|
* @return bool Whether login was successful.
|
|
*/
|
|
private function login(): bool {
|
|
// MAKE REQUEST //
|
|
// do fetch
|
|
$response = $this->fetch("j_spring_security_check",
|
|
post: [
|
|
"school" => $this->school,
|
|
"j_username" => $this->auth["username"],
|
|
"j_password" => $this->auth["passwd"]
|
|
],
|
|
header: [
|
|
"Accept" => "application/json"
|
|
],
|
|
token: self::TOKEN_CSRF
|
|
);
|
|
|
|
// check if successful
|
|
if($response === null){
|
|
echo("[WARN ] unable to do login (fetch failed)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
|
|
// CHECK IF SUCCESSFUL //
|
|
// decode response
|
|
$response = json_decode($response, true);
|
|
if(json_last_error() !== JSON_ERROR_NONE){
|
|
echo("[WARN ] unable to do login (invalid json response)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
// check state
|
|
if(!isset($response["state"])){
|
|
echo("[WARN ] unable to do login (response missing state attribute)" . "\n");
|
|
return false;
|
|
}
|
|
|
|
// return state
|
|
return ($response["state"] === "SUCCESS");
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Get a bearer authorization token.
|
|
*
|
|
* @return ?string `null`: Failed to obtain token
|
|
* string: Value of bearer token.
|
|
*/
|
|
private function bearer(): ?string {
|
|
// GET BEARER FROM API //
|
|
// fetch
|
|
$bearer = $this->fetch("api/token/new", token: self::TOKEN_CSRF);
|
|
|
|
// check if successful
|
|
if($bearer === null){
|
|
echo("[WARN ] unable to get bearer token (fetch failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
// validate format
|
|
if(!preg_match("/^[\w\.-]{600,1000}$/", $bearer)){
|
|
echo("[WARN ] unable to get bearer token (invalid format)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// RETURN BEARER TOKEN //
|
|
return $bearer;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Fetch list of messages.
|
|
*
|
|
* @return ?array `null`: Failed to obtain list
|
|
* array: List of all messages.
|
|
*/
|
|
public function message_list(): ?array {
|
|
// MAKE REQUEST //
|
|
// do fetch
|
|
$response = $this->fetch("api/rest/view/v1/messages");
|
|
|
|
// check if successful
|
|
if($response === null){
|
|
echo("[WARN ] unable to load message list (fetch failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// DECODE RESPONSE //
|
|
$message_list = json_decode($response, true);
|
|
if(json_last_error() !== JSON_ERROR_NONE){
|
|
echo("[WARN ] unable to load message list (invalid json response)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// RETURN LIST //
|
|
return $message_list;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Fetch message details.
|
|
*
|
|
* @param int $id Message id.
|
|
* @param bool $content_as_html Whether message content should be formatted as html.
|
|
*
|
|
* @return ?array `null`: Failed to obtain data
|
|
* array: Message data.
|
|
*/
|
|
public function message_details(int $id, bool $content_as_html = true): ?array {
|
|
// MAKE REQUEST //
|
|
// do fetch
|
|
$response = $this->fetch("api/rest/view/v1/messages/" . $id . ($content_as_html ? "?contentAsHtml=true" : ""));
|
|
|
|
// check if successful
|
|
if($response === null){
|
|
echo("[WARN ] unable to load message details (fetch failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// DECODE RESPONSE //
|
|
$message = json_decode($response, true);
|
|
if(json_last_error() !== JSON_ERROR_NONE){
|
|
echo("[WARN ] unable to load message details (invalid json response)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// RETURN DETAILS //
|
|
return $message;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Download a message attachment.
|
|
*
|
|
* @param string $id Attachment id.
|
|
*
|
|
* @return ?string `null`: Failed to download attachment
|
|
* string: Temporary path of downloaded file on local machine.
|
|
*/
|
|
public function attachment_download(string $id): ?string {
|
|
// MAKE REQUEST //
|
|
// do fetch
|
|
$response = $this->fetch("api/rest/view/v1/messages/" . $id . "/attachmentstorageurl");
|
|
|
|
// check if successful
|
|
if($response === null){
|
|
echo("[WARN ] unable to download attachment (fetch download url failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// DECODE RESPONSE //
|
|
// decode json
|
|
$response = json_decode($response, true);
|
|
if(json_last_error() !== JSON_ERROR_NONE){
|
|
echo("[WARN ] unable to download attachment (invalid json response for fetch download url)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
// get download url
|
|
$download_url = urldecode($response["downloadUrl"]);
|
|
|
|
// get list of additional headers
|
|
$additional_header_list = [];
|
|
foreach($response["additionalHeaders"] as $one_additional_header){
|
|
if(strpos($one_additional_header["key"], "x-amz-") !== 0) continue;
|
|
$additional_header_list[$one_additional_header["key"]] = $one_additional_header["value"];
|
|
}
|
|
|
|
// add date header
|
|
$additional_header_list["X-Amz-Date"] = gmdate("Ymd\THis\Z");
|
|
|
|
|
|
// DOWNLOAD FILE //
|
|
// get tmp path
|
|
$tmp_path = tempnam(sys_get_temp_dir(), "untisbot-attachment-");
|
|
|
|
// do download
|
|
if($this->fetch($download_url, download_path: $tmp_path, header: $additional_header_list) === null){
|
|
echo("[WARN ] unable to download attachment (fetch download failed)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// MAKE SURE FILE EXISTS //
|
|
if(!file_exists($tmp_path) or filesize($tmp_path) <= 0){
|
|
echo("[WARN ] unable to download attachment (downloaded file not existing or empty)" . "\n");
|
|
return null;
|
|
}
|
|
|
|
|
|
// RETURN TMP PATH //
|
|
return $tmp_path;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* HELPER: Execute curl request.
|
|
*
|
|
* @param string $location Url or sub-path to fetch.
|
|
*
|
|
* @param ?array $post `null`: GET request
|
|
* array: POST request with given data.
|
|
*
|
|
* @param ?array $header Optional list of headers to send.
|
|
* @param int $token Bitmask of tokens to send.
|
|
*
|
|
* @param ?string $download_path `null`: Return response as string
|
|
* string: Save response to local file at given path.
|
|
*
|
|
* @return ?string `null`: Request failed
|
|
* string: Response content (request successful).
|
|
*/
|
|
private function fetch(string $location, ?array $post = null, ?array $header = null, int $token = self::TOKEN_BEARER, ?string $download_path = null): ?string {
|
|
// RESET CURL OPTIONS //
|
|
curl_reset($this->curl);
|
|
|
|
|
|
// SET URL //
|
|
if(strpos($location, "http") === 0){
|
|
// absolute path
|
|
curl_setopt($this->curl, CURLOPT_URL, $location);
|
|
|
|
} else {
|
|
// relative path
|
|
curl_setopt($this->curl, CURLOPT_URL, "https://" . $this->host . "/WebUntis/" . $location);
|
|
}
|
|
|
|
|
|
// PREPARE RESPONSE RETURN OR DOWNLOAD //
|
|
if($download_path === null){
|
|
// return response
|
|
curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($this->curl, CURLOPT_TIMEOUT, Manager::config()["fetch"]["timeout"]);
|
|
|
|
} else {
|
|
// download response to file
|
|
$download_handle = fopen($download_path, "w+");
|
|
curl_setopt($this->curl, CURLOPT_FILE, $download_handle);
|
|
curl_setopt($this->curl, CURLOPT_TIMEOUT, Manager::config()["fetch"]["file_download_timeout"]);
|
|
}
|
|
|
|
|
|
// SET OTHER OPTIONS //
|
|
curl_setopt_array($this->curl, [
|
|
// default request method is get
|
|
CURLOPT_HTTPGET => true,
|
|
|
|
// store cookies with curl object
|
|
CURLOPT_COOKIEFILE => "",
|
|
|
|
// fake user agent for proprietary companies (to prevent them from banning curl requests)
|
|
CURLOPT_USERAGENT => "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0"
|
|
]);
|
|
|
|
|
|
// BUILD REQUEST HEADERS //
|
|
$header_list = [];
|
|
|
|
// maybe add csrf token
|
|
if($token === self::TOKEN_CSRF){
|
|
$header_list["X-CSRF-TOKEN"] = $this->csrf;
|
|
}
|
|
|
|
// maybe add bearer token
|
|
if($token === self::TOKEN_BEARER){
|
|
$header_list["Authorization"] = "Bearer " . $this->bearer;
|
|
}
|
|
|
|
// maybe add custom headers
|
|
if($header !== null){
|
|
$header_list = array_merge($header_list, $header);
|
|
}
|
|
|
|
// flush headers
|
|
$header_list_string = [];
|
|
foreach($header_list as $key => $value){
|
|
$header_list_string[] = $key . ": " . $value;
|
|
}
|
|
curl_setopt($this->curl, CURLOPT_HTTPHEADER, $header_list_string);
|
|
|
|
|
|
// MAYBE ADD POST REQUEST BODY //
|
|
if($post !== null){
|
|
curl_setopt_array($this->curl, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query($post),
|
|
]);
|
|
}
|
|
|
|
|
|
// EXECUTE //
|
|
// send request
|
|
$exec = curl_exec($this->curl);
|
|
|
|
// maybe close file handle
|
|
if(isset($download_handle) and is_resource($download_handle)){
|
|
fclose($download_handle);
|
|
}
|
|
|
|
// check if request was successful
|
|
if($exec === false){
|
|
return null;
|
|
}
|
|
|
|
// check http-status-code
|
|
if(curl_getinfo($this->curl, CURLINFO_HTTP_CODE) !== 200){
|
|
return null;
|
|
}
|
|
|
|
// return
|
|
if($download_path === null){
|
|
// return source
|
|
return $exec;
|
|
|
|
} else {
|
|
// download was successful
|
|
return "";
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/////////////////////////////
|
|
// M A I L E R C L A S S //
|
|
/////////////////////////////
|
|
|
|
class Mailer {
|
|
/**
|
|
* @var PHPMailer\PHPMailer\PHPMailer $phpmailer PHPMailer object.
|
|
*/
|
|
private PHPMailer\PHPMailer\PHPMailer $phpmailer;
|
|
|
|
|
|
|
|
/**
|
|
* CONSTRUCTOR: Create new mailer object.
|
|
*
|
|
* @param string $to Recipient mail address.
|
|
*
|
|
* @return Mailer New Mailer object.
|
|
*/
|
|
public function __construct(string $to){
|
|
// GET NEW PHPMAILER OBJECT //
|
|
$this->phpmailer = new PHPMailer\PHPMailer\PHPMailer();
|
|
|
|
|
|
// GENERAL SETTINGS //
|
|
// we use smtp to send our mail
|
|
$this->phpmailer->IsSMTP();
|
|
|
|
// we use utf-8 encoding
|
|
$this->phpmailer->CharSet = "UTF-8";
|
|
|
|
|
|
// SMTP SETTINGS //
|
|
// host and smtp port
|
|
$this->phpmailer->Host = Manager::config()["mail"]["host"];
|
|
$this->phpmailer->Port = Manager::config()["mail"]["port"];
|
|
|
|
// we need to authenticate
|
|
$this->phpmailer->SMTPAuth = true;
|
|
|
|
// username and password
|
|
$this->phpmailer->Username = Manager::config()["mail"]["username"];
|
|
$this->phpmailer->Password = Manager::config()["mail"]["passwd"];
|
|
|
|
// encryption
|
|
$this->phpmailer->SMTPSecure = (Manager::config()["mail"]["starttls"] ? PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS : PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS);
|
|
|
|
|
|
// FROM AND TO //
|
|
// from
|
|
$this->phpmailer->setFrom(Manager::config()["mail"]["username"], "UntisBot");
|
|
|
|
// recipient
|
|
$this->phpmailer->addAddress($to);
|
|
|
|
|
|
// META STUFF //
|
|
// replace x-mailer header
|
|
$this->phpmailer->XMailer = "untisbot (v" . UNTISBOT_VERSION . ")";
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Set mail content.
|
|
*
|
|
* @param string $subject Mail subject.
|
|
* @param string $body Mail body.
|
|
*/
|
|
public function content_set(string $subject, string $body): void {
|
|
// CONTENT //
|
|
// this mail has html content
|
|
$this->phpmailer->isHTML(true);
|
|
|
|
// set subject
|
|
$this->phpmailer->Subject = $subject;
|
|
|
|
// html body
|
|
$this->phpmailer->Body = $body;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Add an attachment to the mail.
|
|
*
|
|
* @param string $path Local path to the attachment file.
|
|
* @param string $filename Filename to display in the mail.
|
|
*
|
|
* @return bool Whether attaching the file was successful.
|
|
*/
|
|
public function attachment_add(string $path, string $filename): bool {
|
|
return($this->phpmailer->addAttachment($path, $filename));
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Send the mail.
|
|
*
|
|
* @return bool Whether sending the mail was successful.
|
|
*/
|
|
public function send(): bool {
|
|
return($this->phpmailer->send());
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/////////////////////////////
|
|
// T I M E R H E L P E R //
|
|
/////////////////////////////
|
|
|
|
class Timer {
|
|
/**
|
|
* @var float $last Unix timestamp of last time the timer fired.
|
|
*/
|
|
private float $last = 0;
|
|
|
|
|
|
|
|
/**
|
|
* @var float $interval Timer interval in seconds.
|
|
*/
|
|
private float $interval;
|
|
|
|
|
|
|
|
/**
|
|
* Create new timer object.
|
|
*
|
|
* @param float $interval Timer interval in seconds.
|
|
*
|
|
* @return Timer New Timer object.
|
|
*/
|
|
public function __construct(float $interval){
|
|
$this->interval = $interval;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Check if timer should fire.
|
|
*
|
|
* @return bool Whether timer should fire.
|
|
*/
|
|
public function check(): bool {
|
|
// GET TIME //
|
|
$time = microtime(true);
|
|
|
|
|
|
// CHECK IF TIMER SHOULD FIRE //
|
|
if($time - $this->last >= $this->interval){
|
|
// set new last-run-time
|
|
$this->last = $time;
|
|
|
|
// timer should fire
|
|
return true;
|
|
}
|
|
|
|
|
|
// TIMER SHOULD NOT FIRE //
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Reset elapsed time to 0.
|
|
*/
|
|
public function restart(): void {
|
|
// FAKE LAST-RUN-TIME //
|
|
// get time
|
|
$time = microtime(true);
|
|
|
|
// set
|
|
$this->last = $time;
|
|
}
|
|
}
|
|
?>
|