Browse Source

Merge pull request #1880 from causefx/v2-develop

V2 develop
causefx 3 years ago
parent
commit
3d3e064c93

+ 2 - 0
.gitignore

@@ -176,6 +176,8 @@ api/plugins/*
 !api/plugins/invites/
 !api/plugins/php-mailer/
 !api/plugins/speedTest/
+!api/plugins/shuck-stop/
+api/plugins/shuck-stop/drives.json
 
 # =========================
 # Custom files

+ 79 - 8
api/classes/organizr.class.php

@@ -37,6 +37,7 @@ class Organizr
 	use HTMLHomepageItem;
 	use ICalHomepageItem;
 	use JackettHomepageItem;
+	use ProwlarrHomepageItem;
 	use JDownloaderHomepageItem;
 	use JellyfinHomepageItem;
 	use LidarrHomepageItem;
@@ -48,6 +49,7 @@ class Organizr
 	use OmbiHomepageItem;
 	use OverseerrHomepageItem;
 	use PiHoleHomepageItem;
+	use AdGuardHomepageItem;
 	use PlexHomepageItem;
 	use QBitTorrentHomepageItem;
 	use RadarrHomepageItem;
@@ -65,7 +67,7 @@ class Organizr
 
 	// ===================================
 	// Organizr Version
-	public $version = '2.1.2330';
+	public $version = '2.1.2370';
 	// ===================================
 	// Quick php Version check
 	public $minimumPHP = '7.4';
@@ -2429,6 +2431,7 @@ class Organizr
 				$this->settingsOption('enable', 'autoBackupCronEnabled', ['label' => 'Auto-Backup Organizr']),
 				$this->settingsOption('cron', 'autoBackupCronSchedule'),
 				$this->settingsOption('number', 'keepBackupsCountCron', ['label' => '# Backups Keep', 'help' => 'Number of backups to keep', 'attr' => 'min="1"']),
+				$this->settingsOption('folder', 'backupLocation', ['label' => 'Backup Save Path', 'help' => 'Folder path to save Organizr Backups - Please test before saving', 'value' => $this->getOrganizrBackupLocation()]),
 				$this->settingsOption('blank'),
 
 			],
@@ -3587,17 +3590,68 @@ class Organizr
 			'expires' => gmdate('Y-m-d H:i:s', time() + (86400 * $days))
 		];
 		$response = [
-			array(
+			[
 				'function' => 'query',
-				'query' => array(
+				'query' => [
 					'INSERT INTO [tokens]',
 					$addToken
-				)
-			),
+				],
+				'key' => 'insert'
+			],
+			[
+				'function' => 'fetchAll',
+				'query' => [
+					'SELECT * FROM `tokens` WHERE user_id = ? ORDER BY `id` DESC LIMIT 100',
+					[$result['id']]
+				],
+				'key' => 'tokens'
+			],
 		];
-		$this->processQueries($response);
+		$query = $this->processQueries($response);
 		if ($token) {
 			$this->logger->debug('Token has been created');
+			$browserCount = array_column($query['tokens'], 'browser');
+			$browserCount = array_count_values($browserCount);
+			if (isset($browserCount[$_SERVER ['HTTP_USER_AGENT']])) {
+				if ($browserCount[$_SERVER ['HTTP_USER_AGENT']] <= 1) {
+					if ($this->config['PHPMAILER-enabled']) {
+						$PhpMailer = new PhpMailer();
+						$emailTemplate = array(
+							'type' => 'device',
+							'body' => '
+								<h2>Hey there {user}!</h2>
+								We noticed a login attempt to your account and want to make sure it\'s you.<br />
+								If this was you, please ignore this email.<br /><br />
+								If this wasn\'t you, please change your password and revoke all tokens.<br /><br />
+								<b>Details:</b><br/ >
+								IP: ' . $this->userIP() . '<br />
+								Browser: ' . $_SERVER ['HTTP_USER_AGENT'] . '<br />
+								',
+							'subject' => 'We noticed a login attempt to your account on a new device.',
+							'user' => $result['username'],
+							'password' => null,
+							'inviteCode' => null,
+						);
+
+						$emailTemplate = $PhpMailer->_phpMailerPluginEmailTemplate($emailTemplate);
+						$sendEmail = array(
+							'to' => $result['email'],
+							'subject' => $emailTemplate['subject'],
+							'body' => $PhpMailer->_phpMailerPluginBuildEmail($emailTemplate),
+						);
+						$response = $PhpMailer->_phpMailerPluginSendEmail($sendEmail);
+						if ($response == true) {
+							$this->logger->debug('Sent new device email');
+						} else {
+							$this->logger->debug('Could not send new device email');
+						}
+					} else {
+						$this->logger->debug('Email not setup - cannot send new device email');
+					}
+				}
+			} else {
+				$this->logger->debug('Could not find token in database');
+			}
 		} else {
 			$this->logger->warning('Token creation error');
 		}
@@ -4227,6 +4281,9 @@ class Organizr
 				'jackett' => [
 					'homepageJackettBackholeDownload' => $this->config['homepageJackettBackholeDownload'] ? true : false
 				],
+				'prowlarr' => [
+					'homepageProwlarrBackholeDownload' => $this->config['homepageProwlarrBackholeDownload'] ? true : false
+				],
 				'options' => [
 					'alternateHomepageHeaders' => $this->config['alternateHomepageHeaders'],
 					'healthChecksTags' => $this->config['healthChecksTags'],
@@ -4591,6 +4648,13 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
+				case 'homepageOrderAdGuard':
+					$class = 'bg-info';
+					$image = 'plugins/images/tabs/AdGuardHomepageItem';
+					if (!$this->config['homepageAdGuardEnabled']) {
+						$class .= ' faded';
+					}
+					break;
 				case 'homepageOrderMonitorr':
 					$class = 'bg-info';
 					$image = 'plugins/images/tabs/monitorr.png';
@@ -4647,6 +4711,13 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
+				case 'homepageOrderProwlarr':
+					$class = 'bg-inverse';
+					$image = 'plugins/images/tabs/prowlarr.png';
+					if (!$this->config['homepageProwlarrEnabled']) {
+						$class .= ' faded';
+					}
+					break;
 				case 'homepageOrderBookmarks':
 					$class = 'bg-bookmarks';
 					$image = 'plugins/images/bookmark.png';
@@ -6028,7 +6099,7 @@ class Organizr
 				}
 				$this->setAPIResponse('success', '', 200, $sponsors);
 				return $sponsors;
-			} catch (\PHPHtmlParser\Exceptions\ChildNotFoundException | \PHPHtmlParser\Exceptions\CircularException | \PHPHtmlParser\Exceptions\LogicalException | \PHPHtmlParser\Exceptions\StrictException | \PHPHtmlParser\Exceptions\ContentLengthException | \PHPHtmlParser\Exceptions\NotLoadedException $e) {
+			} catch (\PHPHtmlParser\Exceptions\ChildNotFoundException|\PHPHtmlParser\Exceptions\CircularException|\PHPHtmlParser\Exceptions\LogicalException|\PHPHtmlParser\Exceptions\StrictException|\PHPHtmlParser\Exceptions\ContentLengthException|\PHPHtmlParser\Exceptions\NotLoadedException $e) {
 				$this->setAPIResponse('error', 'Error connecting to Github', 409);
 				return false;
 			}
@@ -7791,4 +7862,4 @@ class Organizr
 		return count($request) > 1 ? $results : $results[$firstKey];
 	}
 
-}
+}

+ 22 - 0
api/config/default.php

@@ -214,6 +214,14 @@ return [
 	'jackettUseCustomCertificate' => false,
 	'jackettDisableCertCheck' => false,
 	'homepageJackettBackholeDownload' => false,
+	'homepageProwlarrBackholeDownload' => false,
+	'homepageProwlarrEnabled' => false,
+	'homepageProwlarrAuth' => '1',
+	'ProwlarrURL' => '',
+	'ProwlarrToken' => '',
+	'ProwlarrUseCustomCertificate' => false,
+	'ProwlarrDisableCertCheck' => false,
+	'homepageProwlarrBackholeDownload' => false,
 	'homepageCalendarEnabled' => false,
 	'homepageCalendarAuth' => '4',
 	'calendariCal' => '',
@@ -364,6 +372,8 @@ return [
 	'homepageOrderoverseerr' => '39',
 	'homepageOrderBookmarks' => '40',
 	'homepageOrderDonate' => '41',
+	'homepageOrderAdguard' => '42',
+	'homepageOrderProwlarr' => '43',
 	'homepageShowStreamNames' => false,
 	'homepageShowStreamNamesAuth' => '1',
 	'homepageShowStreamNamesWithoutIp' => false,
@@ -523,6 +533,17 @@ return [
 	'homepagePiholeCombine' => false,
 	'piholeHeaderToggle' => true,
 	'piholeURL' => '',
+	'homepageAdGuardEnabled' => false,
+	'homepageAdGuardAuth' => '1',
+	'homepageAdGuardRefresh' => '10000',
+	'homepageAdGuardCombine' => false,
+	'adguardQueriesToggle' => true,
+	'adguardQueriesBlockedToggle' => true,
+	'adguardPercentToggle' => true,
+	'adguardProcessingToggle' => true,
+	'adguardDomainListToggle' => false,
+	'adguardHeaderToggle' => true,
+	'adguardURL' => '',
 	'homepageMonitorrEnabled' => false,
 	'homepageMonitorrAuth' => '1',
 	'homepageMonitorrRefresh' => '60000',
@@ -663,6 +684,7 @@ return [
 	'autoBackupCronEnabled' => false,
 	'autoBackupCronSchedule' => '@weekly',
 	'keepBackupsCountCron' => '20',
+	'backupLocation' => '',
 	'useRandomMediaImage' => false,
 	'sendLogsToSlack' => false,
 	'slackLogLevel' => 'WARNING',

+ 15 - 4
api/functions/backup-functions.php

@@ -2,6 +2,17 @@
 
 trait BackupFunctions
 {
+	public function getOrganizrBackupLocation()
+	{
+		$defaultPath = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$userPath = $this->config['backupLocation'];
+		if ($this->config['backupLocation'] !== '') {
+			if (file_exists($userPath)) {
+				return $userPath;
+			}
+		}
+		return $defaultPath;
+	}
 
 	public function fileArray($files)
 	{
@@ -18,7 +29,7 @@ trait BackupFunctions
 	public function deleteBackup($filename)
 	{
 		$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
-		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$path = $this->getOrganizrBackupLocation();
 		$filename = $path . $filename;
 		if ($ext == 'zip') {
 			if (file_exists($filename)) {
@@ -37,7 +48,7 @@ trait BackupFunctions
 
 	public function downloadBackup($filename)
 	{
-		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$path = $this->getOrganizrBackupLocation();
 		$filename = $path . $filename;
 		if (file_exists($filename)) {
 			header('Content-Type: application/zip');
@@ -54,7 +65,7 @@ trait BackupFunctions
 
 	public function backupOrganizr($type = 'config')
 	{
-		$directory = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$directory = $this->getOrganizrBackupLocation();
 		@mkdir($directory, 0770, true);
 		switch ($type) {
 			case 'config':
@@ -115,7 +126,7 @@ trait BackupFunctions
 
 	public function getBackups()
 	{
-		$path = $this->config['dbLocation'] . 'backups' . DIRECTORY_SEPARATOR;
+		$path = $this->getOrganizrBackupLocation();
 		@mkdir($path, 0770, true);
 		$files = array_diff(scandir($path), array('.', '..'));
 		$fileList = [];

+ 10 - 6
api/functions/normal-functions.php

@@ -439,22 +439,26 @@ trait NormalFunctions
 			$scheme = $digest['scheme'];
 		}
 		// Host
-		$host = (isset($digest['host']) ? $digest['host'] : '');
+		$host = ($digest['host'] ?? '');
 		// Port
 		$port = (isset($digest['port']) ? ':' . $digest['port'] : '');
 		// Path
-		$path = (isset($digest['path']) ? $digest['path'] : '');
+		$path = ($digest['path'] ?? '');
 		// Query
 		$query = (isset($digest['query']) ? '?' . $digest['query'] : '');
+		// Fragment
+		$fragment = (isset($digest['fragment']) ? '#' . $digest['fragment'] : '');
 		// Output
-		$array = array(
+		$array = [
 			'scheme' => $scheme,
 			'host' => $host,
 			'port' => $port,
 			'path' => $path,
-			'query' => $query
-		);
-		return ($return) ? $array : $scheme . '://' . $host . $port . $path . $query;
+			'query' => $query,
+			'fragment' => $fragment,
+			'digest' => $digest
+		];
+		return ($return) ? $array : $scheme . '://' . $host . $port . $path . $query . $fragment;
 	}
 
 	public function getServer($over = false)

+ 10 - 7
api/functions/oauth.php

@@ -4,13 +4,16 @@ trait OAuthFunctions
 {
 	public function traktOAuth()
 	{
-		$provider = new Bogstag\OAuth2\Client\Provider\Trakt([
-			'clientId' => $this->config['traktClientId'],
-			'clientSecret' => $this->config['traktClientSecret'],
-			'redirectUri' => $this->getServerPath() . 'api/v2/oauth/trakt'
-		], [
-			'httpClient' => new GuzzleHttp\Client(['verify' => getCert()]),
-		]);
+		$provider = new Bogstag\OAuth2\Client\Provider\Trakt(
+			[
+				'clientId' => $this->config['traktClientId'],
+				'clientSecret' => $this->config['traktClientSecret'],
+				'redirectUri' => $this->getServerPath() . 'api/v2/oauth/trakt'
+			],
+			[
+				'httpClient' => new GuzzleHttp\Client(['verify' => $this->getCert()]),
+			]
+		);
 		if (!isset($_GET['code'])) {
 			$authUrl = $provider->getAuthorizationUrl();
 			header('Location: ' . $authUrl);

+ 40 - 6
api/functions/sso-functions.php

@@ -22,6 +22,40 @@ trait SSOFunctions
 		return $cookies;
 	}
 
+	public function getSSOList($enabledOnly = false)
+	{
+		$searchTerm = 'sso';
+		$list = array_filter($this->config, function ($k) use ($searchTerm) {
+			return stripos($k, $searchTerm) !== false;
+		}, ARRAY_FILTER_USE_KEY);
+		foreach ($list as $key => $sso) {
+			if (stripos(substr($key, 0, 3), $searchTerm) === false) {
+				unset($list[$key]);
+				continue;
+			}
+			if (gettype($sso) !== 'boolean') {
+				unset($list[$key]);
+			}
+			if ($enabledOnly) {
+				if (!$sso) {
+					unset($list[$key]);
+				}
+			}
+		}
+		return $list;
+	}
+
+	public function getSSOEnabledCount()
+	{
+		$count = count($this->getSSOList(true));
+		return ($count <= 0) ? 1 : $count;
+	}
+
+	public function getSSOTimeout()
+	{
+		return (60000 / $this->getSSOEnabledCount());
+	}
+
 	public function getSSOUserFor($app, $userobj)
 	{
 		$map = array(
@@ -134,7 +168,7 @@ trait SSOFunctions
 			}
 			$credentials = array('auth' => new Requests_Auth_Digest(array($email, $password)));
 			$url = $this->qualifyURL($this->config['komgaURL']);
-			$options = $this->requestOptions($url, 60000, true, false, $credentials);
+			$options = $this->requestOptions($url, $this->getSSOTimeout(), true, false, $credentials);
 			$response = Requests::get($url . '/api/v1/users/me', ['X-Auth-Token' => 'organizrSSO'], $options);
 			if ($response->success) {
 				if ($response->headers['x-auth-token']) {
@@ -179,7 +213,7 @@ trait SSOFunctions
 				"Pw" => $password
 			);
 			$endpoint = '/Users/authenticatebyname';
-			$options = $this->requestOptions($url, 60000);
+			$options = $this->requestOptions($url, $this->getSSOTimeout());
 			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
 			if ($response->success) {
 				$token = json_decode($response->body, true);
@@ -214,7 +248,7 @@ trait SSOFunctions
 				"plexToken" => $oAuthToken
 			);
 			$endpoint = ($oAuthToken) ? '/api/v1/Token/plextoken' : '/api/v1/Token';
-			$options = $this->requestOptions($url, 60000);
+			$options = $this->requestOptions($url, $this->getSSOTimeout());
 			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
 			if ($response->success) {
 				$token = json_decode($response->body, true)['access_token'];
@@ -258,7 +292,7 @@ trait SSOFunctions
 						"token" => $plexToken,
 						"remember_me" => 1,
 					);
-					$options = $this->requestOptions($url, 60000);
+					$options = $this->requestOptions($url, $this->getSSOTimeout());
 					$response = Requests::post($url . '/auth/signin', $headers, $data, $options);
 					if ($response->success) {
 						$qualifiedURL = $this->qualifyURL($url, true);
@@ -293,7 +327,7 @@ trait SSOFunctions
 				"authToken" => $oAuthToken
 			);
 			$endpoint = ($oAuthToken ? '/api/v1/auth/plex' : '/api/v1/auth/local');
-			$options = $this->requestOptions($url, 60000);
+			$options = $this->requestOptions($url, $this->getSSOTimeout());
 			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
 			if ($response->success) {
 				$user = json_decode($response->body, true); // not really needed yet
@@ -337,7 +371,7 @@ trait SSOFunctions
 				'token' => $oAuthToken
 			);
 			$endpoint = ($oAuthToken) ? '/api/login/plex_login' : '/api/login';
-			$options = $this->requestOptions($url, 60000);
+			$options = $this->requestOptions($url, $this->getSSOTimeout());
 			$response = Requests::post($url . $endpoint, $headers, json_encode($data), $options);
 			if ($response->success) {
 				$user = json_decode($response->body, true)['user'];

+ 29 - 4
api/functions/update-functions.php

@@ -2,6 +2,25 @@
 
 trait UpdateFunctions
 {
+	public function testScriptFilePermissions($script, $retest = false)
+	{
+		if (file_exists($script)) {
+			if (is_executable($script)) {
+				return true;
+			} elseif ($retest == false) {
+				$this->setLoggerChannel('Update')->notice('Attempting to set correct permissions', ['file' => $script]);
+				$permissions = shell_exec('chmod 777 ' . $script);
+				return $this->testScriptFilePermissions($script, true);
+			} else {
+				$this->setLoggerChannel('Update')->warning('Update script doesn\'t have the correct permissions', ['file' => $script]);
+				return false;
+			}
+		} else {
+			$this->setLoggerChannel('Update')->warning('Update script doesn\'t exist', ['file' => $script]);
+			return false;
+		}
+	}
+
 	public function updateOrganizr()
 	{
 		if ($this->docker) {
@@ -81,8 +100,8 @@ trait UpdateFunctions
 		$branch = ($this->config['branch'] == 'v2-master') ? '-m' : '-d';
 		ini_set('max_execution_time', 0);
 		set_time_limit(0);
-		$logFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'log.txt';
-		$windowsScript = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'windows-update.bat ' . $branch . ' > ' . $logFile . ' 2>&1';
+		$logFile = $this->root . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'log.txt';
+		$windowsScript = $this->root . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'windows-update.bat ' . $branch . ' > ' . $logFile . ' 2>&1';
 		$windowsUpdate = shell_exec($windowsScript);
 		$this->removeUpdateStatusFile();
 		if ($windowsUpdate) {
@@ -109,8 +128,14 @@ trait UpdateFunctions
 		$branch = $this->config['branch'];
 		ini_set('max_execution_time', 0);
 		set_time_limit(0);
-		$logFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'log.txt';
-		$script = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'linux-update.sh ' . $branch . ' > ' . $logFile . ' 2>&1';
+		$logFile = $this->root . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'log.txt';
+		$script = $this->root . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'linux-update.sh ' . $branch . ' > ' . $logFile . ' 2>&1';
+		$checkScript = $this->testScriptFilePermissions($script);
+		if (!$checkScript) {
+			$this->setResponse(500, 'Update script permissions error');
+			$this->removeUpdateStatusFile();
+			return false;
+		}
 		$update = shell_exec($script);
 		$this->removeUpdateStatusFile();
 		if ($update) {

+ 176 - 0
api/homepage/adguard.php

@@ -0,0 +1,176 @@
+<?php
+
+trait AdGuardHomepageItem
+{
+	public function adguardSettingsArray($infoOnly = false)
+	{
+		$homepageInformation = [
+			'name' => 'AdGuardHome',
+			'enabled' => true,
+			'image' => 'plugins/images/tabs/AdGuardHome.png',
+			'category' => 'Monitor',
+			'settingsArray' => __FUNCTION__
+		];
+		if ($infoOnly) {
+			return $homepageInformation;
+		}
+		$homepageSettings = [
+			'debug' => true,
+			'settings' => [
+				'Enable' => [
+					$this->settingsOption('enable', 'homepageAdGuardEnabled'),
+					$this->settingsOption('auth', 'homepageAdGuardAuth'),
+				],
+				'Connection' => [
+					$this->settingsOption('url', 'adguardURL', ['help' => 'Please make sure to use local IP address and port. You can add multiple AdGuard Homes by comma separating the URLs.', 'placeholder' => 'http(s)://hostname:port']),
+          $this->settingsOption('username', 'adGuardUsername'),
+					$this->settingsOption('password', 'adGuardPassword'),
+        ],
+				'Misc' => [
+					$this->settingsOption('toggle-title', 'adguardToggle'),
+					$this->settingsOption('switch', 'homepageAdGuardCombine', ['label' => 'Combine stat cards', 'help' => 'This controls whether to combine the stats for multiple adguard instances into 1 card.']),
+				],
+				'Stats' => [
+					$this->settingsOption('switch', 'adguardQueriesToggle', ['label' => 'Total Queries']),
+					$this->settingsOption('switch', 'adguardQueriesBlockedToggle', ['label' => 'Queries Blocked']),
+					$this->settingsOption('switch', 'adguardPercentToggle', ['label' => 'Percent Blocked']),
+					$this->settingsOption('switch', 'adguardProcessingToggle', ['label' => 'Processing Time']),
+					$this->settingsOption('switch', 'adguardDomainListToggle', ['label' => 'Domains on Blocklist']),
+				],
+				'Test Connection' => [
+					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
+					$this->settingsOption('test', 'adguard'),
+				]
+			]
+		];
+		return array_merge($homepageInformation, $homepageSettings);
+	}
+
+	public function testConnectionAdGuard()
+	{
+		if (empty($this->config['adguardURL'])) {
+			$this->setAPIResponse('error', 'AdGuard URL is not defined', 422);
+			return false;
+		}
+		$api = array();
+		$failed = false;
+		$errors = '';
+		$urls = explode(',', $this->config['adguardURL']);
+		foreach ($urls as $url) {
+			$url = $url . '/control/stats';
+			try {
+				$options = array(
+					'auth' => array($this->config['adGuardUsername'], $this->decrypt($this->config['adGuardPassword']))
+				);
+				$response = Requests::get($url, [], $options);
+				if ($response->success) {
+					@$test = json_decode($response->body, true);
+					if (!is_array($test)) {
+						$ip = $this->qualifyURL($url, true)['host'];
+						$errors .= $ip . ': Response was not JSON';
+						$failed = true;
+					}
+				}
+				if (!$response->success) {
+					$ip = $this->qualifyURL($url, true)['host'];
+					$errors .= $ip . ": Unknown Failure";
+					$failed = true;
+				}
+			} catch (Requests_Exception $e) {
+				$failed = true;
+				$ip = $this->qualifyURL($url, true)['host'];
+				$errors .= $ip . ': ' . $e->getMessage();
+				$this->setLoggerChannel('AdGuard')->error($e);
+			};
+		}
+		if ($failed) {
+			$this->setAPIResponse('error', $errors, 500);
+			return false;
+		} else {
+			$this->setAPIResponse('success', null, 200);
+			return true;
+		}
+	}
+
+	public function adguardHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageAdGuardEnabled'
+				],
+				'auth' => [
+					'homepageAdGuardAuth'
+				],
+				'not_empty' => [
+					'adguardURL'
+				]
+			]
+		];
+		return $this->homepageCheckKeyPermissions($key, $permissions);
+	}
+
+	public function homepageOrderAdGuard()
+	{
+		if ($this->homepageItemPermissions($this->adguardHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading AdGuard...</h2></div>
+					<script>
+						// Pi-hole Stats
+						homepageAdGuard("' . $this->config['homepageAdGuardRefresh'] . '");
+						// End Pi-hole Stats
+					</script>
+				</div>
+				';
+		}
+	}
+
+	public function getAdGuardHomepageStats()
+	{
+		if (!$this->homepageItemPermissions($this->adguardHomepagePermissions('main'), true)) {
+			return false;
+		}
+		$stats = array();
+		$urls = explode(',', $this->config['adguardURL']);
+		foreach ($urls as $url) {
+			$stats_url = $url . '/control/stats?';
+			$filter_url = $url . '/control/filtering/status?';
+			try {
+				$options = array(
+					'auth' => array($this->config['adGuardUsername'], $this->decrypt($this->config['adGuardPassword']))
+				);
+				$response = Requests::get($stats_url, [], $options);
+				if ($response->success) {
+					@$adguardResults = json_decode($response->body, true);
+					if (is_array($adguardResults)) {
+						$ip = $this->qualifyURL($stats_url, true)['host'];
+						$stats['data'][$ip] = $adguardResults;
+					}
+				}
+				$response = Requests::get($filter_url, [], $options);
+				if ($response->success) {
+					@$adguardFilterResults = json_decode($response->body, true);
+					if (is_array($adguardFilterResults)) {
+						$ip = $this->qualifyURL($filter_url, true)['host'];
+						$stats['filters'][$ip] = $adguardFilterResults;
+					}
+				}
+			} catch (Requests_Exception $e) {
+				$this->setResponse(500, $e->getMessage());
+				$this->setLoggerChannel('AdGuard')->error($e);
+				return false;
+			};
+		}
+		$stats['options']['combine'] = $this->config['homepageAdGuardCombine'];
+		$stats['options']['title'] = $this->config['adguardHeaderToggle'];
+		$stats['options']['queries'] = $this->config['adguardQueriesToggle'];
+		$stats['options']['blocked_count'] = $this->config['adguardQueriesBlockedToggle'];
+		$stats['options']['blocked_percent'] = $this->config['adguardPercentToggle'];
+		$stats['options']['processing_time'] = $this->config['adguardProcessingToggle'];
+		$stats['options']['domain_count'] = $this->config['adguardDomainListToggle'];
+		$stats = isset($stats) ? $stats : null;
+		$this->setAPIResponse('success', null, 200, $stats);
+		return $stats;
+	}
+}

+ 185 - 0
api/homepage/prowlarr.php

@@ -0,0 +1,185 @@
+<?php
+
+trait ProwlarrHomepageItem
+{
+	public function prowlarrSettingsArray($infoOnly = false)
+	{
+		$homepageInformation = [
+			'name' => 'Prowlarr',
+			'enabled' => true,
+			'image' => 'plugins/images/tabs/prowlarr.png',
+			'category' => 'Utility',
+			'settingsArray' => __FUNCTION__
+		];
+		if ($infoOnly) {
+			return $homepageInformation;
+		}
+		$homepageSettings = [
+			'debug' => true,
+			'settings' => [
+				'Enable' => [
+					$this->settingsOption('enable', 'homepageProwlarrEnabled'),
+					$this->settingsOption('auth', 'homepageProwlarrAuth'),
+				],
+				'Connection' => [
+					$this->settingsOption('url', 'prowlarrURL'),
+					$this->settingsOption('token', 'prowlarrToken'),
+					$this->settingsOption('disable-cert-check', 'prowlarrDisableCertCheck'),
+					$this->settingsOption('use-custom-certificate', 'prowlarrUseCustomCertificate'),
+				],
+				'Options' => [
+					$this->settingsOption('switch', 'homepageProwlarrBackholeDownload', ['label' => 'Prefer black hole download', 'help' => 'Prefer black hole download link instead of direct/magnet download']),
+				],
+				'Test Connection' => [
+					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
+					$this->settingsOption('test', 'prowlarr'),
+				]
+			]
+		];
+		return array_merge($homepageInformation, $homepageSettings);
+	}
+
+	public function prowlarrHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageProwlarrEnabled'
+				],
+				'auth' => [
+					'homepageProwlarrAuth'
+				],
+				'not_empty' => [
+					'prowlarrURL',
+					'prowlarrToken'
+				]
+			],
+			'test' => [
+				'auth' => [
+					'homepageProwlarrAuth'
+				],
+				'not_empty' => [
+					'prowlarrURL',
+					'prowlarrToken'
+				]
+			]
+		];
+		return $this->homepageCheckKeyPermissions($key, $permissions);
+	}
+
+	public function homepageOrderProwlarr()
+	{
+		if ($this->homepageItemPermissions($this->prowlarrHomepagePermissions('main'))) {
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Prowlarr...</h2></div>
+					<script>
+						// Prowlarr
+						homepageProwlarr();
+						// End Prowlarr
+					</script>
+				</div>
+				';
+		}
+	}
+
+	public function testConnectionProwlarr()
+	{
+		if (!$this->homepageItemPermissions($this->prowlarrHomepagePermissions('test'), true)) {
+			return false;
+		}
+		$apiURL = $this->qualifyURL($this->config['prowlarrURL']);
+		$endpoint = $apiURL . '/api/v1/search?apikey=' . $this->config['prowlarrToken'] . '&query=this-is-just-a-test-for-organizr';
+		try {
+			$headers = [];
+			$options = $this->requestOptions($apiURL, 120, $this->config['prowlarrDisableCertCheck'], $this->config['prowlarrUseCustomCertificate']);
+			$response = Requests::get($endpoint, $headers, $options);
+			if ($response->success) {
+				$apiData = json_decode($response->body, true);
+				$api['content'] = $apiData;
+				unset($apiData);
+			} else {
+				$this->setResponse(403, 'Error connecting to Prowlarr');
+				return false;
+			}
+		} catch (Requests_Exception $e) {
+			$this->setResponse(500, $e->getMessage());
+			return false;
+		};
+		$api['content'] = $api['content'] ?? false;
+		$this->setResponse(200, null, $api);
+		return $api;
+	}
+
+	public function searchProwlarrIndexers($query = null)
+	{
+		if (!$this->homepageItemPermissions($this->prowlarrHomepagePermissions('main'), true)) {
+			return false;
+		}
+		if (!$query) {
+			$this->setAPIResponse('error', 'Query was not supplied', 422);
+			return false;
+		}
+		$apiURL = $this->qualifyURL($this->config['prowlarrURL']);
+		$endpoint = $apiURL . '/api/v1/search?apikey=' . $this->config['prowlarrToken'] . '&query=' . urlencode($query);
+		try {
+			$headers = [];
+			$options = $this->requestOptions($apiURL, 120, $this->config['prowlarrDisableCertCheck'], $this->config['prowlarrUseCustomCertificate']);
+			$response = Requests::get($endpoint, $headers, $options);
+			if ($response->success) {
+				$apiData = json_decode($response->body, true);
+				$api['content'] = $apiData;
+				unset($apiData);
+			}
+		} catch (Requests_Exception $e) {
+			$this->setResponse(500, $e->getMessage());
+			return false;
+		};
+		$api['content'] = isset($api['content']) ? $api['content'] : false;
+		$this->setAPIResponse('success', null, 200, $api);
+		return $api;
+	}
+
+	public function performProwlarrBackHoleDownload($guid = null, $indexerId = null)
+	{
+		if (!$this->homepageItemPermissions($this->prowlarrHomepagePermissions('main'), true)) {
+			return false;
+		}
+		if (!$guid) {
+			$this->setAPIResponse('error', 'guid was not supplied', 422);
+			return false;
+		}
+		if (!$indexerId) {
+			$this->setAPIResponse('error', 'indexerId was not supplied', 422);
+			return false;
+		}
+		$apiURL = $this->qualifyURL($this->config['prowlarrURL']);
+		$endpoint = $apiURL . '/api/v1/search?apikey=' . $this->config['prowlarrToken'];
+		try {
+			$headers = [];
+			$data = ['guid'=>$guid,'indexerId'=>$indexerId];
+			$options = $this->requestOptions($apiURL, 120, $this->config['prowlarrDisableCertCheck'], $this->config['prowlarrUseCustomCertificate']);
+			$ch = curl_init($endpoint);
+			$payload = json_encode($data);
+			curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+			curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json'));
+			curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+			$response = curl_exec($ch);
+			curl_close($ch);
+			if ($response) {
+				$api['content'] = $response;
+			}
+		} catch (Requests_Exception $e) {
+			$this->setLoggerChannel('Prowlarr')->error($e);
+			$this->setResponse(500, $e->getMessage());
+			return false;
+		};
+		$api['content'] = isset($api['content']) ? $api['content'] : false;
+		if ($api['content']) {
+			$this->setAPIResponse('success', null, 200, $api);
+		} else {
+			$this->setAPIResponse('error', 'Unknown error', 400, $api);
+		}
+		return $api;
+	}
+}

+ 10 - 2
api/plugins/php-mailer/plugin.php

@@ -197,6 +197,14 @@ class PhpMailer extends Organizr
 		$subject = isset($emailInfo['subject']) ? $emailInfo['subject'] : null;
 		$body = isset($emailInfo['body']) ? $emailInfo['body'] : null;
 		$username = isset($emailInfo['user']) ? $emailInfo['user'] : 'Organizr User';
+		$data = [
+			'to' => $to,
+			'cc' => $cc,
+			'bcc' => $bcc,
+			'subject' => $subject,
+			'body' => $body,
+			'username' => $username,
+		];
 		try {
 			$mail = new PHPMailer\PHPMailer\PHPMailer(true);
 			$mail->isSMTP();
@@ -242,8 +250,8 @@ class PhpMailer extends Organizr
 			$mail->send();
 			return true;
 		} catch (PHPMailer\PHPMailer\Exception $e) {
-			$this->setLoggerChannel('Email')->error($e);
-			return $e->errorMessage();
+			$this->setLoggerChannel('Email')->error($e, $data);
+			return false;
 		}
 	}
 

+ 53 - 0
api/plugins/shuck-stop/api.php

@@ -0,0 +1,53 @@
+<?php
+$app->get('/plugins/shuck-stop/settings', function ($request, $response, $args) {
+	/**
+	 * @OA\Get(
+	 *     tags={"plugins-shuck-stop"},
+	 *     path="/api/v2/plugins/shuck-stop/settings",
+	 *     summary="Get settings",
+	 *     @OA\Response(
+	 *      response="200",
+	 *      description="Success",
+	 *      @OA\JsonContent(ref="#/components/schemas/pluginSettingsPage"),
+	 *     ),
+	 *     @OA\Response(response="401",description="Unauthorized"),
+	 *     security={{ "api_key":{} }}
+	 * )
+	 */
+	$shuckStop = new ShuckStop();
+	if ($shuckStop->checkRoute($request)) {
+		if ($shuckStop->qualifyRequest(1, true)) {
+			$GLOBALS['api']['response']['data'] = $shuckStop->_shuckStopPluginGetSettings();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/plugins/shuck-stop/run', function ($request, $response, $args) {
+	/**
+	 * @OA\Get(
+	 *     tags={"plugins-shuck-stop"},
+	 *     path="/api/v2/plugins/shuck-stop/run",
+	 *     summary="Run ShuckStop plugin",
+	 *     @OA\Response(
+	 *      response="200",
+	 *      description="Success",
+	 *      @OA\JsonContent(ref="#/components/schemas/shuckStopRun"),
+	 *     ),
+	 *     @OA\Response(response="401",description="Unauthorized"),
+	 *     security={{ "api_key":{} }}
+	 * )
+	 */
+	$shuckStop = new ShuckStop();
+	if ($shuckStop->checkRoute($request)) {
+		if ($shuckStop->qualifyRequest(1, true)) {
+			$shuckStop->_shuckStopPluginRun();
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 17 - 0
api/plugins/shuck-stop/config.php

@@ -0,0 +1,17 @@
+<?php
+return array(
+	'SHUCKSTOP-enabled' => false,
+	'SHUCKSTOP-cron-run-enabled' => false,
+	'SHUCKSTOP-cron-run-schedule' => '*/5 * * * *',
+	'SHUCKSTOP-emails' => '',
+	'SHUCKSTOP-easystore' => false,
+	'SHUCKSTOP-my-book' => false,
+	'SHUCKSTOP-elements' => false,
+	'SHUCKSTOP-8' => false,
+	'SHUCKSTOP-10' => false,
+	'SHUCKSTOP-12' => false,
+	'SHUCKSTOP-14' => false,
+	'SHUCKSTOP-16' => false,
+	'SHUCKSTOP-18' => false,
+	'SHUCKSTOP-20' => false,
+);

+ 10 - 0
api/plugins/shuck-stop/cron.php

@@ -0,0 +1,10 @@
+<?php
+/*
+ * Simple Cron job
+ */
+$GLOBALS['cron'][] = [
+	'class' => 'ShuckStop', // Class name of plugin (case-sensitive)
+	'enabled' => 'SHUCKSTOP-cron-run-enabled', // Config item for job enable
+	'schedule' => 'SHUCKSTOP-cron-run-schedule', // Config item for job schedule
+	'function' => '_shuckStopPluginRun', // Function to run during job
+];

BIN
api/plugins/shuck-stop/logo.png


+ 249 - 0
api/plugins/shuck-stop/plugin.php

@@ -0,0 +1,249 @@
+<?php
+// PLUGIN INFORMATION
+$GLOBALS['plugins']['ShuckStop'] = [ // Plugin Name
+	'name' => 'ShuckStop', // Plugin Name
+	'author' => 'CauseFX', // Who wrote the plugin
+	'category' => 'Utilities', // One to Two Word Description
+	'link' => '', // Link to plugin info
+	'license' => 'personal,business', // License Type use , for multiple
+	'idPrefix' => 'SHUCKSTOP', // html element id prefix
+	'configPrefix' => 'SHUCKSTOP', // config file prefix for array items without the hyphen
+	'version' => '1.0.0', // SemVer of plugin
+	'image' => 'api/plugins/shuck-stop/logo.png', // 1:1 non transparent image for plugin
+	'settings' => true, // does plugin need a settings modal?
+	'bind' => true, // use default bind to make settings page - true or false
+	'api' => 'api/v2/plugins/shuck-stop/settings', // api route for settings page
+	'homepage' => false // Is plugin for use on homepage? true or false
+];
+
+class ShuckStop extends Organizr
+{
+	public function _shuckStopPluginGetSettings()
+	{
+		return [
+			'Cron' => [
+				$this->settingsOption('cron-file'),
+				$this->settingsOption('blank'),
+				$this->settingsOption('enable', 'SHUCKSTOP-cron-run-enabled'),
+				$this->settingsOption('cron', 'SHUCKSTOP-cron-run-schedule')
+			],
+			'Email' => [
+				$this->settingsOption('multiple', 'SHUCKSTOP-emails', ['label' => 'Emails']),
+			],
+			'Model' => [
+				$this->settingsOption('switch', 'SHUCKSTOP-easystore', ['label' => 'Monitor EasyStore']),
+				$this->settingsOption('switch', 'SHUCKSTOP-my-book', ['label' => 'Monitor My Book']),
+				$this->settingsOption('switch', 'SHUCKSTOP-elements', ['label' => 'Monitor Elements']),
+			],
+			'Capacity' => [
+				$this->settingsOption('switch', 'SHUCKSTOP-8', ['label' => 'Monitor 8TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-10', ['label' => 'Monitor 10TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-12', ['label' => 'Monitor 12TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-14', ['label' => 'Monitor 14TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-16', ['label' => 'Monitor 16TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-18', ['label' => 'Monitor 18TB']),
+				$this->settingsOption('switch', 'SHUCKSTOP-20', ['label' => 'Monitor 20TB']),
+			]
+		];
+	}
+
+	public function _shuckStopPluginRun()
+	{
+		if ($this->config['SHUCKSTOP-enabled'] && !empty($this->config['SHUCKSTOP-emails']) && $this->qualifyRequest(1)) {
+			if (
+				($this->config['SHUCKSTOP-easystore'] ||
+					$this->config['SHUCKSTOP-my-book'] ||
+					$this->config['SHUCKSTOP-elements']
+				) &&
+				($this->config['SHUCKSTOP-8'] ||
+					$this->config['SHUCKSTOP-10'] ||
+					$this->config['SHUCKSTOP-12'] ||
+					$this->config['SHUCKSTOP-14'] ||
+					$this->config['SHUCKSTOP-16'] ||
+					$this->config['SHUCKSTOP-18'] ||
+					$this->config['SHUCKSTOP-20']
+				)
+			) {
+				$file = $this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'shuck-stop' . DIRECTORY_SEPARATOR . 'drives.json';
+				$hasFile = file_exists($file);
+				$json = null;
+				if ($hasFile && filesize($file) > 0) {
+					$jsonFile = file_get_contents($file);
+					$json = json_decode($jsonFile, true);
+				}
+				$url = 'https://shucks.top/';
+				$options = ($this->localURL($url)) ? ['verify' => false] : [];
+				$response = Requests::get($url, [], $options);
+				if ($response->success) {
+					$drives = [
+						'run_date' => $this->currentTime,
+						'has_file' => $hasFile,
+						'email_setup' => $this->config['PHPMAILER-enabled'],
+						'last_email_drives_lowest' => ($json) ? $json['last_email_drives_lowest'] : [],
+						'last_email_drives_decent' => ($json) ? $json['last_email_drives_decent'] : [],
+						'lowest_priced_drives' => [],
+						'recent_decent_drives' => [],
+					];
+					$dom = new PHPHtmlParser\Dom;
+					try {
+						$dom->loadStr($response->body);
+						$contents = $dom->find('tbody tr');
+						foreach ($contents as $content) {
+							//$html = $content->innerHtml;
+							$capacity = $content->getAttribute('data-capacity');
+							$model = str_replace(' ', '-', $content->find('td')[1]->text);
+							$lastDecent = $content->find('td')[8]->text;
+							$lowestDollars = $content->find('td')[7]->getAttribute('data-dollars');
+							$lowestPerTB = $content->find('td')[7]->getAttribute('data-per-tb');
+							$lowestNow = $content->find('td')[7]->find('p')[1]->text;
+							$drives['drives'][$capacity][strtolower($model)]['capacity'] = $capacity;
+							$drives['drives'][$capacity][strtolower($model)]['model'] = strtolower($model);
+							$drives['drives'][$capacity][strtolower($model)]['last_decent'] = $lastDecent;
+							$drives['drives'][$capacity][strtolower($model)]['lowest_dollars'] = $lowestDollars;
+							$drives['drives'][$capacity][strtolower($model)]['lowest_decent_dollars'] = 100000;
+							$drives['drives'][$capacity][strtolower($model)]['lowest_per_tb'] = $lowestPerTB;
+							$drives['drives'][$capacity][strtolower($model)]['lowest_now'] = $lowestNow == 'now';
+							$drives['drives'][$capacity][strtolower($model)]['decent_now'] = $lastDecent == 'now';
+
+							$checkItems = [
+								'amazon' => $this->_checkShuckClassNA('amazon', $content->find('td')[2]->getAttribute('class')),
+								'bestbuy' => $this->_checkShuckClassNA('bestbuy', $content->find('td')[3]->getAttribute('class')),
+								'bhphoto' => $this->_checkShuckClassNA('bhphoto', $content->find('td')[4]->getAttribute('class')),
+								'ebay' => $this->_checkShuckClassNA('ebay', $content->find('td')[5]->getAttribute('class')),
+								'newegg' => $this->_checkShuckClassNA('newegg', $content->find('td')[6]->getAttribute('class'))
+							];
+							$i = 2;
+							foreach ($checkItems as $store => $class) {
+								if ($class) {
+									$driveInfo = $class;
+								} else {
+									$driveInfo = $this->_checkShuckStore($store, [
+										'data-per-tb' => $content->find('td')[$i]->getAttribute('data-per-tb'),
+										'data-dollars' => $content->find('td')[$i]->getAttribute('data-dollars'),
+										'title' => $content->find('td')[$i]->getAttribute('title'),
+										'link' => $content->find('td')[$i]->find('a')->getAttribute('href'),
+										'lowest_dollars' => $lowestDollars,
+										'lowest_now' => $lowestNow == 'now'
+									]);
+								}
+								$i++;
+								$drives['drives'][$capacity][strtolower($model)]['lowest_decent_dollars'] = ($driveInfo['data-dollars'] <= $drives['drives'][$capacity][strtolower($model)]['lowest_decent_dollars'] && $driveInfo['data-dollars'] !== 0 && $driveInfo['data-dollars'] !== null) ? $driveInfo['data-dollars'] : $drives['drives'][$capacity][strtolower($model)]['lowest_decent_dollars'];
+								$drives['drives'][$capacity][strtolower($model)]['stores'][$store] = $driveInfo;
+							}
+							if ($drives['drives'][$capacity][strtolower($model)]['lowest_now']) {
+								$drives['lowest_priced_drives'][$capacity][strtolower($model)] = $lowestDollars;
+							}
+							if ($drives['drives'][$capacity][strtolower($model)]['decent_now']) {
+								$drives['recent_decent_drives'][$capacity][strtolower($model)] = $drives['drives'][$capacity][strtolower($model)]['lowest_decent_dollars'];
+							}
+						}
+						// Run the checks...
+						$capacities = [8, 10, 12, 14, 16, 18, 20];
+						$models = ['easystore', 'elements', 'my-book'];
+						$emailBody = '';
+						foreach ($capacities as $capacity) {
+							if ($this->config['SHUCKSTOP-' . $capacity]) {
+								foreach ($models as $model) {
+									if ($this->config['SHUCKSTOP-' . $model]) {
+										if (isset($drives['lowest_priced_drives'][$capacity][$model]) &&
+											(!$json ||
+												(isset($json['lowest_priced_drives'][$capacity][$model]) &&
+													$drives['lowest_priced_drives'][$capacity][$model] !== $json['last_email_drives_lowest'][$capacity][$model])
+											)
+										) {
+											$emailBody .= '<br/>The ' . $capacity . 'TB drive is at the lowest price of $' . $drives['lowest_priced_drives'][$capacity][$model];
+											foreach ($drives['drives'][$capacity][$model]['stores'] as $store => $storeInfo) {
+												if ($storeInfo['data-dollars'] == $drives['lowest_priced_drives'][$capacity][$model]) {
+													if ($storeInfo['link'] !== '') {
+														$emailBody .= '<br/><a href="' . $storeInfo['link'] . '">' . $store . '</a>';
+													}
+												}
+											}
+											$drives['last_email_drives_lowest'][$capacity][$model] = $drives['lowest_priced_drives'][$capacity][$model];
+										}
+										if (isset($drives['recent_decent_drives'][$capacity][$model]) &&
+											(!$json ||
+												(isset($json['recent_decent_drives'][$capacity][$model]) &&
+													$drives['recent_decent_drives'][$capacity][$model] !== $json['last_email_drives_decent'][$capacity][$model])
+											)
+										) {
+											$emailBody .= '<br/>The ' . $capacity . 'TB drive has dropped to the price of $' . $drives['recent_decent_drives'][$capacity][$model];
+											foreach ($drives['drives'][$capacity][$model]['stores'] as $store => $storeInfo) {
+												if ($storeInfo['data-dollars'] == $drives['recent_decent_drives'][$capacity][$model]) {
+													if ($storeInfo['link'] !== '') {
+														$emailBody .= '<br/><a href="' . $storeInfo['link'] . '">' . $store . '</a>';
+													}
+												}
+											}
+											$drives['last_email_drives_decent'][$capacity][$model] = $drives['recent_decent_drives'][$capacity][$model];
+										}
+									}
+								}
+							}
+						}
+						// Send email if setup
+						if ($this->config['PHPMAILER-enabled'] && $emailBody !== '') {
+							$PhpMailer = new PhpMailer();
+							$emailTemplate = [
+								'type' => 'shuckstop',
+								'body' => $emailBody,
+								'subject' => 'New Shuck Drive Alert!',
+								'user' => null,
+								'password' => null,
+								'inviteCode' => null,
+							];
+							$emailTemplate = $PhpMailer->_phpMailerPluginEmailTemplate($emailTemplate);
+							$sendEmail = [
+								'bcc' => $this->config['SHUCKSTOP-emails'],
+								'subject' => $emailTemplate['subject'],
+								'body' => $PhpMailer->_phpMailerPluginBuildEmail($emailTemplate),
+							];
+							$PhpMailer->_phpMailerPluginSendEmail($sendEmail);
+						}
+						// Write file
+						file_put_contents($file, safe_json_encode($drives, JSON_HEX_QUOT | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+						$this->setResponse(200, null, $drives);
+						return $drives;
+					} catch (\PHPHtmlParser\Exceptions\ChildNotFoundException|\PHPHtmlParser\Exceptions\CircularException|\PHPHtmlParser\Exceptions\LogicalException|\PHPHtmlParser\Exceptions\StrictException|\PHPHtmlParser\Exceptions\ContentLengthException|\PHPHtmlParser\Exceptions\NotLoadedException $e) {
+						$this->setResponse(500, 'Error connecting to ShuckStop');
+						return false;
+					}
+				}
+				$this->setResponse(500, 'Error connecting to ShuckStop');
+			} else {
+				$this->setResponse(409, 'No Drives are monitored');
+			}
+		} else {
+			$this->setResponse(401, 'User does not have access or user email not setup');
+		}
+		return false;
+	}
+
+	public function _checkShuckStore($store, $info)
+	{
+		return [
+			'store' => $store,
+			'data-per-tb' => $info['data-per-tb'],
+			'data-dollars' => $info['data-dollars'],
+			'title' => $info['title'],
+			'link' => $info['link'],
+			'lowest_now' => $info['lowest_now'] && $info['data-dollars'] == $info['lowest_dollars']
+		];
+	}
+
+	public function _checkShuckClassNA($store, $class)
+	{
+		if ($class == 'n-a') {
+			return [
+				'store' => $store,
+				'data-per-tb' => 0,
+				'data-dollars' => 0,
+				'title' => '',
+				'link' => '',
+				'lowest_now' => false
+			];
+		} else {
+			return false;
+		}
+	}
+}

+ 45 - 1
api/v2/routes/connectionTester.php

@@ -200,6 +200,28 @@ $app->post('/test/pihole', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->post('/test/adguard', function ($request, $response, $args) {
+	/**
+ * @OA\Post(
+ *     security={{ "api_key":{} }},
+ *     tags={"test connection"},
+ *     path="/api/v2/test/adguard",
+ *     summary="Test connection to AdGuard",
+ *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+ *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+ *     @OA\Response(response="422",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+ *     @OA\Response(response="500",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+ * )
+ */
+$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+if ($Organizr->qualifyRequest(1, true)) {
+	$Organizr->testConnectionAdGuard($Organizr->apiData($request));
+}
+$response->getBody()->write(jsonE($GLOBALS['api']));
+return $response
+	->withHeader('Content-Type', 'application/json;charset=UTF-8')
+	->withStatus($GLOBALS['responseCode']);
+});
 $app->post('/test/rtorrent', function ($request, $response, $args) {
 	/**
 	 * @OA\Post(
@@ -667,6 +689,28 @@ $app->post('/test/jackett', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->post('/test/prowlarr', function ($request, $response, $args) {
+	/**
+	 * @OA\Post(
+	 *     security={{ "api_key":{} }},
+	 *     tags={"test connection"},
+	 *     path="/api/v2/test/prowlarr",
+	 *     summary="Test connection to prowlarr",
+	 *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+	 *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+	 *     @OA\Response(response="422",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 *     @OA\Response(response="500",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 * )
+	 */
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testConnectionProwlarr();
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->post('/test/slack-logs', function ($request, $response, $args) {
 	/**
 	 * @OA\Post(
@@ -688,4 +732,4 @@ $app->post('/test/slack-logs', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-});
+});

+ 26 - 1
api/v2/routes/homepage.php

@@ -112,6 +112,14 @@ $app->get('/homepage/pihole/stats', function ($request, $response, $args) {
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/adguard/stats', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->getAdGuardHomepageStats();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->get('/homepage/rtorrent/queue', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	$Organizr->getRTorrentHomepageQueue();
@@ -515,6 +523,23 @@ $app->post('/homepage/jackett/download/', function ($request, $response, $args)
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->get('/homepage/prowlarr/{query}', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->searchProwlarrIndexers($args['query']);
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/homepage/prowlarr/download/', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$postData = $request->getParsedBody();
+	$Organizr->performProwlarrBackHoleDownload($postData['guid'], $postData['indexerId']);
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->get('/homepage/trakt/calendar', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	$Organizr->getTraktCalendar();
@@ -571,4 +596,4 @@ $app->post('/homepage/donate', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-});
+});

+ 166 - 0
docs/api.json

@@ -504,6 +504,62 @@
                 ]
             }
         },
+        "/api/v2/plugins/shuck-stop/settings": {
+            "get": {
+                "tags": [
+                    "plugins-shuck-stop"
+                ],
+                "summary": "Get settings",
+                "responses": {
+                    "200": {
+                        "description": "Success",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/pluginSettingsPage"
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized"
+                    }
+                },
+                "security": [
+                    {
+                        "api_key": []
+                    }
+                ]
+            }
+        },
+        "/api/v2/plugins/shuck-stop/run": {
+            "get": {
+                "tags": [
+                    "plugins-shuck-stop"
+                ],
+                "summary": "Run ShuckStop plugin",
+                "responses": {
+                    "200": {
+                        "description": "Success",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/shuckStopRun"
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized"
+                    }
+                },
+                "security": [
+                    {
+                        "api_key": []
+                    }
+                ]
+            }
+        },
         "/api/v2/plugins/speedtest/settings": {
             "get": {
                 "tags": [
@@ -1713,6 +1769,61 @@
                 ]
             }
         },
+        "/api/v2/test/adguard": {
+            "post": {
+                "tags": [
+                    "test connection"
+                ],
+                "summary": "Test connection to AdGuard",
+                "responses": {
+                    "200": {
+                        "description": "Success",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/success-message"
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/unauthorized-message"
+                                }
+                            }
+                        }
+                    },
+                    "422": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    }
+                },
+                "security": [
+                    {
+                        "api_key": []
+                    }
+                ]
+            }
+        },
         "/api/v2/test/rtorrent": {
             "post": {
                 "tags": [
@@ -2866,6 +2977,61 @@
                 ]
             }
         },
+        "/api/v2/test/prowlarr": {
+            "post": {
+                "tags": [
+                    "test connection"
+                ],
+                "summary": "Test connection to prowlarr",
+                "responses": {
+                    "200": {
+                        "description": "Success",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/success-message"
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/unauthorized-message"
+                                }
+                            }
+                        }
+                    },
+                    "422": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    }
+                },
+                "security": [
+                    {
+                        "api_key": []
+                    }
+                ]
+            }
+        },
         "/api/v2/test/slack-logs": {
             "post": {
                 "tags": [

+ 6 - 1
index.php

@@ -96,6 +96,8 @@ $Organizr = new Organizr(true);
                                 class="ti-reload"></i></a></li>
                 <li class=""><a class="dropdown-toggle waves-effect waves-light" onclick="closeCurrentTab(event);"> <i
                                 class="ti-close"></i></a></li>
+                <li class=""><a class="dropdown-toggle waves-effect waves-light" onclick="openInNewBrowserTab();"> <i
+                                class="ti-arrow-top-right"></i></a></li>
                 <li class=""><a class="dropdown-toggle waves-effect waves-light hidden" onclick="splashMenu();"> <i
                                 class="ti-layout-grid2"></i></a></li>
             </ul>
@@ -136,7 +138,10 @@ $Organizr = new Organizr(true);
                     <span class="hide-menu hidden-sm hidden-md hidden-lg" id="side-logo"></span>
                 </h3>
             </div>
-            <ul class="nav" id="side-menu"></ul>
+            <ul class="nav" id="side-menu">
+                <li class="side-menu-top-sort"></li>
+                <li class="side-menu-bottom-sort"></li>
+            </ul>
         </div>
     </div>
     <!-- ============================================================== -->

+ 461 - 1
js/functions.js

@@ -1062,6 +1062,16 @@ function closeCurrentTab(event){
 			organizrConsole('Tab Function','No Available Tab to open', 'error');
 	}
 }
+function openInNewBrowserTab(){
+    let id = $('body').attr('data-active-tab-id');
+    let tabInfo = findTab(id);
+    if(!tabInfo){
+        organizrConsole('Open In New Browser Tab Function', 'No Tab Info Found... Id: '+id, 'error');
+        return false;
+    }
+    let url = tabInfo.access_url;
+    window.open(url, '_blank');
+}
 function findTab(query, term = 'id'){
     let tabInfo = activeInfo.tabs.filter(tab => tab[term] == query );
     return tabInfo.length >= 1 ? tabInfo[0] : false;
@@ -2454,6 +2464,8 @@ function checkTabHomepageItem(id, name, url, urlLocal){
         addEditHomepageItem(id,'HealthChecks');
     }else if(name.includes('jackett') || url.includes('jackett') || urlLocal.includes('jackett')){
 	    addEditHomepageItem(id,'Jackett');
+    }else if(name.includes('prowlarr') || url.includes('prowlarr') || urlLocal.includes('prowlarr')){
+	    addEditHomepageItem(id,'Prowlarr');
     }else if(name.includes('unifi') || url.includes('unifi') || urlLocal.includes('unifi')){
 	    addEditHomepageItem(id,'Unifi');
     }else if(name.includes('tautulli') || url.includes('tautulli') || urlLocal.includes('tautulli')){
@@ -7272,6 +7284,29 @@ function buildPihole(array){
     `;
     return (array) ? html : '';
 }
+function buildAdGuard(array){
+    if(array === false){ return ''; }
+    var html = `
+    <div id="allAdGuard">
+        <div class="el-element-overlay row">`;
+    if(array['options']['title']) {
+        html += `
+            <div class="col-md-12">
+                <h4 class="pull-left homepage-element-title"><span lang="en">AdGuard Home</span> : </h4><h4 class="pull-left">&nbsp;</h4>
+                <hr class="hidden-xs ml-2">
+            </div>
+            <div class="clearfix"></div>
+        `;
+    }
+    html += `
+		    <div class="adguardCards col-sm-12 my-3">
+			    `+buildAdGuardItem(array)+`
+			</div>
+		</div>
+	</div>
+    `;
+    return (array) ? html : '';
+}
 function buildUnifi(array){
     if(array === false){ return ''; }
     var items = (typeof array.content.unifi.data !== 'undefined') ? array.content.unifi.data.length : false;
@@ -7555,6 +7590,243 @@ function arrayRemove(arr, value) {
 		return ele != value;
 	});
 }
+function buildAdGuardItem(array){
+    var stats = `
+    <style>
+    .bg-green {
+        background-color: #00a65a !important;
+    }
+    
+    .bg-aqua {
+        background-color: #00c0ef!important;
+    }
+    
+    .bg-yellow {
+        background-color: #f39c12!important;
+    }
+    
+    .bg-red {
+        background-color: #dd4b39!important;
+    }
+    
+    .adguard-stat {
+        color: #fff !important;
+    }
+    
+    .adguard-stat .card-body h3 {
+        font-size: 38px;
+        font-weight: 700;
+    }
+
+    .adguard-stat .card-body i {
+        font-size: 5em;
+        float: right;
+        color: #ffffff6b;
+    }
+
+    .inline-block {
+        display: inline-block;
+    }
+    </style>
+    `;
+    var length = Object.keys(array['data']).length;
+    var combine = array['options']['combine'];
+    var totalQueries = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card text-white mb-3 adguard-stat bg-green">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Total Queries</p>`;
+        for(var key in data) {
+            var e = data[key];
+	        if(length > 1 && !combine) {
+		        card += `<p class="d-inline text-muted">(`+key+`)</p>`;
+	        }
+	        card += `<h3 data-toggle="tooltip" data-placement="right" title="`+key+`">`+e['num_dns_queries'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")+`</h3>`;
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-globe inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+    var totalBlocked = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card bg-inverse text-white mb-3 adguard-stat bg-aqua">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Queries Blocked</p>`;
+        for(var key in data) {
+            var e = data[key];
+		    if (length > 1 && !combine) {
+			    card += `<p class="d-inline text-muted">(${key})</p>`;
+		    }
+		    card += `<h3 data-toggle="tooltip" data-placement="right" title="${key}">${e['num_blocked_filtering'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</h3>`;
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-hand-paper-o inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+    var avgProcessingTime = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card bg-inverse text-white mb-3 adguard-stat bg-purple">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Avg Processing Time</p>`;
+        for(var key in data) {
+            var e = data[key];
+		        if (length > 1 && !combine) {
+			        card += `<p class="d-inline text-muted">(${key})</p>`;
+		        }
+                ms_time = parseFloat(e['avg_processing_time'])*1000
+		        card += `<h3 data-toggle="tooltip" data-placement="right" title="${key}">${ms_time.toFixed(2)} ms</h3>`;
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-group inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+    var domainsBlocked = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card bg-inverse text-white mb-3 adguard-stat bg-red">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Domains on Blocklist</p>`;
+        for(var key in data) {
+            var e = data[key];
+		        if (length > 1 && !combine) {
+			        card += `<p class="d-inline text-muted">(${key})</p>`;
+		        }
+                var total_domains_blocked = 0
+                for(var key in e['filters']){
+                    total_domains_blocked += parseFloat(e['filters'][key]['rules_count'])
+                }
+		        card += `<h3 data-toggle="tooltip" data-placement="right" title="${key}">${total_domains_blocked.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</h3>`;
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-list inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+    var domainsBlocked = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card bg-inverse text-white mb-3 adguard-stat bg-red">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Domains on Blocklist</p>`;
+        for(var key in data) {
+            var e = data[key];
+		        if (length > 1 && !combine) {
+			        card += `<p class="d-inline text-muted">(${key})</p>`;
+		        }
+                var total_domains_blocked = 0
+                for(var key in e['filters']){
+                    total_domains_blocked += parseFloat(e['filters'][key]['rules_count'])
+                }
+                total_domains_blocked += Object.keys(e['user_rules']).length
+		        card += `<h3 data-toggle="tooltip" data-placement="right" title="${key}">${total_domains_blocked.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</h3>`;
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-list inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+    var percentBlocked = function(data) {
+        var card = `
+        <div class="col-lg-3 col-md-6 col-sm-6 col-xs-12">
+            <div class="card bg-inverse text-white mb-3 adguard-stat bg-yellow">
+                <div class="card-body">
+                    <div class="inline-block">
+                        <p class="d-inline mr-1">Percent Blocked</p>`;
+        for(var key in data) {
+            var e = data[key];
+	        if(typeof e['FTLnotrunning'] == 'undefined') {
+		        if (length > 1 && !combine) {
+			        card += `<p class="d-inline text-muted">(${key})</p>`;
+		        }
+                var percent = 100*(parseFloat(e['num_blocked_filtering'])/parseFloat(e['num_dns_queries']))
+		        card += `<h3 data-toggle="tooltip" data-placement="right" title="${key}">${percent.toFixed(2)}%</h3>`
+	        }
+        };
+        card += `
+                    </div>
+                    <i class="fa fa-pie-chart inline-block" aria-hidden="true"></i>
+                </div>
+            </div>
+        </div>
+        `
+        return card;
+    };
+        if(combine) {
+            stats += '<div class="row">'
+            if(array['options']['queries']){
+                stats += totalQueries(array['data']);
+            }
+            if(array['options']['blocked_count']){
+                stats += totalBlocked(array['data']);
+            }
+            if(array['options']['blocked_percent']){
+                stats += percentBlocked(array['data']);
+            }
+            if(array['options']['processing_time']){
+                stats += avgProcessingTime(array['data']);
+            }
+            if(array['options']['domain_count']){
+                stats += domainsBlocked(array['filters']);
+            }
+            stats += '</div>';
+        } else {
+            for(var key in array['data']) {
+                var data = array['data'][key];
+                obj = {};
+                obj[key] = data;
+                stats += '<div class="row">'
+                if(array['options']['queries']){
+                    stats += totalQueries(array['data']);
+                }
+                if(array['options']['blocked_count']){
+                    stats += totalBlocked(array['data']);
+                }
+                if(array['options']['blocked_percent']){
+                    stats += percentBlocked(array['data']);
+                }
+                if(array['options']['processing_time']){
+                    stats += avgProcessingTime(array['data']);
+                }
+                if(array['options']['domain_count']){
+                    stats += domainsBlocked(array['filters']);
+                }
+                stats += '</div>';
+            };
+        };
+        return stats
+}
+
 function buildPiholeItem(array){
     var stats = `
     <style>
@@ -7740,6 +8012,27 @@ function homepagePihole(timeout){
     timeouts[timeoutTitle] = setTimeout(function(){ homepagePihole(timeout); }, timeout);
     delete timeout;
 }
+function homepageAdGuard(timeout){
+    var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh.homepageAdGuardRefresh;
+    organizrAPI2('GET','api/v2/homepage/adguard/stats').success(function(data) {
+        try {
+            let response = data.response;
+	        document.getElementById('homepageOrderAdGuard').innerHTML = '';
+	        if(response.data !== null){
+		        buildAdGuard(response.data)
+		        $('#homepageOrderAdGuard').html(buildAdGuard(response.data));
+	        }
+        }catch(e) {
+	        organizrCatchError(e,data);
+        }
+    }).fail(function(xhr) {
+	    OrganizrApiError(xhr);
+    });
+    let timeoutTitle = 'AdGuard-Homepage';
+    if(typeof timeouts[timeoutTitle] !== 'undefined'){ clearTimeout(timeouts[timeoutTitle]); }
+    timeouts[timeoutTitle] = setTimeout(function(){ homepageAdGuard(timeout); }, timeout);
+    delete timeout;
+}
 function homepageHealthChecks(tags, timeout){
     tags = (typeof tags !== 'undefined') ? tags : activeInfo.settings.homepage.options.healthChecksTags;
     if(tags == ''){
@@ -8175,7 +8468,7 @@ function buildTautulliItem(array){
         var card = '';
         data.forEach(e => {
             let classes = '';
-            if(e['stat_id'] == stat) {
+	        if(e['stat_id'] == stat && e['rows'].length > 0) {
                 if(stat === 'top_platforms') {
                     classes = ' platform-' + e['rows'][0]['platform_name'] + '-rgba';
                 } else {
@@ -9558,6 +9851,10 @@ function searchJackett(){
 	let jackettTable = $("#jackettDataTable")
 		.on( 'error.dt', function ( e, settings, techNote, message ) {
 			console.log( 'An error has been reported by DataTables: ', message );
+			$('#jackettDataTable').DataTable().destroy();
+			ajaxblocker('.jackett-panel .white-box');
+			$('.clearJackett').parent().removeClass('hidden');
+			window.message('Jackett Connection Error','',activeInfo.settings.notifications.position,"#FFF","error","5000");
 		} )
 		.DataTable( {
 			"ajax": {
@@ -9643,6 +9940,169 @@ function jackettDownload(url) {
 			OrganizrApiError(xhr, 'Error downloading torrent');
 		});
 }
+function homepageProwlarr(){
+	if(activeInfo.settings.homepage.options.alternateHomepageHeaders){
+		var header = `
+		<div class="col-md-12">
+			<h2 class="text-white m-0 pull-left text-uppercase"><img class="lazyload homepageImageTitle" data-src="plugins/images/tabs/prowlarr.png"> &nbsp; <span lang="en">Prowlarr</span>&nbsp;</h2>
+			<hr class="hidden-xs"><div class="clearfix"></div>
+		</div>
+		<div class="clearfix"></div>
+		<script>$('.prowlarr-panel').removeClass('panel panel-default');</script>
+		`;
+	}else{
+		var header = `
+		<div class="panel-heading bg-info p-t-10 p-b-10">
+			<span class="pull-left m-t-5 text-white"><img class="lazyload homepageImageTitle" data-src="plugins/images/tabs/prowlarr.png" > &nbsp; <span lang="en">Prowlarr</span></span>
+			<div class="clearfix"></div>
+		</div>
+		`;
+	}
+	let html = `
+	<div id="prowlarrSearch" class="row">
+		<div class="col-lg-12">
+			<div class="prowlarr-panel panel panel-default">
+				`+header+`
+				<div class="panel-wrapper p-b-0 collapse in">
+					<div class="white-box">
+	                    <h3 class="box-title m-b-0" lang="en">Search</h3>
+	                    
+	                    <form onsubmit="searchProwlarr();return false;">
+	                        <div class="input-group m-b-30">
+	                        	<span class="input-group-btn hidden">
+									<button type="button" class="btn waves-effect waves-light btn-primary clearProwlarr" onclick="clearProwlarr();"><i class="fa fa-eraser"></i></button>
+								</span>
+	                            <input id="prowlarr-search-query" class="form-control" placeholder="Search for..." lang="en">
+	                            <span class="input-group-btn">
+									<button type="submit" class="btn waves-effect waves-light btn-info"><i class="fa fa-search"></i></button>
+								</span>
+	                        </div>
+	
+	                    </form>
+	                    
+	                    <div class="prowlarrDataTable hidden">
+        					<h3 class="box-title m-b-0" lang="en">Results</h3>
+					        <div class="table-responsive">
+					            <table id="prowlarrDataTable" class="table table-striped">
+					                <thead>
+					                    <tr>
+					                        <th lang="en">Date</th>
+					                        <th lang="en">Indexer</th>
+					                        <th lang="en">Title</th>
+					                        <th lang="en">Size</th>
+					                        <th lang="en">Grabs</th>
+					                        <th lang="en">Seeders</th>
+					                        <th lang="en">Leechers</th>
+					                        <th lang="en">Download</th>
+					                    </tr>
+					                </thead>
+					                <tbody></tbody>
+					            </table>
+					        </div>
+    					</div>
+	                </div>
+					
+				</div>
+			</div>
+		</div>
+	</div>
+	`;
+	$('#homepageOrderProwlarr').html(html);
+}
+function clearProwlarr(){
+	$('#prowlarr-search-query').val('');
+	$('.clearProwlarr').parent().addClass('hidden');
+	$('#prowlarrDataTable').DataTable().destroy();
+	$('.prowlarrDataTable').addClass('hidden');
+}
+function searchProwlarr(){
+	let query = $('#prowlarr-search-query').val();
+	if(query !== ''){
+		$('.prowlarrDataTable').removeClass('hidden');
+		ajaxloader('#prowlarrSearch .panel-wrapper', 'in');
+	}else{
+		return false;
+	}
+	$.fn.dataTable.ext.errMode = 'none';
+	$('#prowlarrDataTable').DataTable().destroy();
+	let preferBlackholeDownload = activeInfo.settings.homepage.prowlarr.homepageProwlarrBackholeDownload
+	let prowlarrTable = $("#prowlarrDataTable")
+		.on( 'error.dt', function ( e, settings, techNote, message ) {
+			console.log( 'An error has been reported by DataTables: ', message );
+		} )
+		.DataTable( {
+			"ajax": {
+				"url": "api/v2/homepage/prowlarr/" + query,
+				"dataSrc": function ( json ) {
+					return json.response.data.content;
+				}
+			},
+			"columns": [
+				{ data: 'publishDate',
+					render: function ( data, type, row ) {
+						if ( type === 'display' || type === 'filter' ) {
+							var m = moment.tz(data, activeInfo.timezone);
+							return moment.utc(m, "YYYY-MM-DD hh:mm[Z]").local().fromNow();
+						}
+						return data;
+					}
+				},
+				{ data: 'indexer' },
+				{ data: 'title',
+					render: function ( data, type, row ) {
+						if(row.Details !== null){
+							return '<a href="'+row['infoUrl']+'" target="_blank">'+data+'</a>';
+						}else{
+							return data;
+						}
+
+					}
+				},
+				{ data: 'size',	render: function ( data ) {
+                    return humanFileSize(data, false);
+                }},
+				{ data: 'grabs' },
+				{ data: 'seeders' },
+				{ data: 'leechers' },
+				{ data: 'downloadUrl',
+					render: function ( data, type, row ) {
+						if ( type === 'display' || type === 'filter' ) {
+            	if(data !== null){
+								if(preferBlackholeDownload === true && row.guid !== null){
+									return '<a onclick="prowlarrDownload(\''+row.guid+","+row.indexerId+'\');return false;" href="#"><i class="fa fa-cloud-download"></i></a>';
+								} else {
+									return '<a href="'+data+'" target="_blank"><i class="fa fa-download"></i></a>';
+								}
+							}	else{
+								return 'No Download Link';
+							}
+						}
+						return data;
+					}
+				},
+			],
+			"order": [[ 5, 'desc' ]],
+			"initComplete": function(settings, json) {
+				ajaxloader();
+				//ajaxblocker('.prowlarr-panel .white-box');
+				$('.clearProwlarr').parent().removeClass('hidden');
+			}
+		} );
+}
+function prowlarrDownload(url) {
+	const args = url.split(",")
+	var post = {
+		guid: args[0],
+		indexerId: args[1],
+	};
+	organizrAPI2('POST', 'api/v2/homepage/prowlarr/download/', post, true)
+		.success(function() {
+			message('Torrent downloaded','',activeInfo.settings.notifications.position,"#FFF","success","5000");
+		})
+		.fail(function(xhr) {
+			OrganizrApiError(xhr, 'Error downloading torrent');
+		});
+}
 function homepageOctoprint(timeout){
     var timeout = (typeof timeout !== 'undefined') ? timeout : activeInfo.settings.homepage.refresh.homepageOctoprintRefresh;
     organizrAPI2('GET','api/v2/homepage/octoprint/data').success(function(data) {

+ 19 - 19
js/langpack/nl[Dutch].json

@@ -16,7 +16,7 @@
         "Email": "Email",
         "Enter your Email and instructions will be sent to you!": "Voor je Email in en de instructies worden naar je verstuurd!",
         "Register": "Registreren",
-        "Registration Password": "Registratiewachtwoord",
+        "Registration Password": "Registratie Wachtwoord",
         "Recover Password": "Herstel Wachtwoord",
         "Password": "Wachtwoord",
         "Sign Up": "Aanmelden",
@@ -800,38 +800,38 @@
         "This will enable the use of SSL for LDAP connections": "This will enable the use of SSL for LDAP connections",
         "Enabling this will bypass external 2FA security if user is on local Subnet": "Enabling this will bypass external 2FA security if user is on local Subnet",
         "Enabling this will only allow Friends that have shares to the Machine ID entered above to login, Having this disabled will allow all Friends on your Friends list to login": "Enabling this will only allow Friends that have shares to the Machine ID entered above to login, Having this disabled will allow all Friends on your Friends list to login",
-        "Since you are using the official Docker image, you can just restart your Docker container to update Organizr": "Since you are using the official Docker image, you can just restart your Docker container to update Organizr",
-        "Since you are using the Official Docker image, Change the image to change the branch": "Since you are using the Official Docker image, Change the image to change the branch",
+        "Since you are using the official Docker image, you can just restart your Docker container to update Organizr": "Aangezien u de officiële Docker-image gebruikt, kunt u uw Docker-container gewoon opnieuw opstarten om Organizr bij te werken",
+        "Since you are using the Official Docker image, Change the image to change the branch": "Aangezien u de officiële Docker image gebruikt, wijzigt u de 'image' om de 'branch' te wijzigen",
         "Choose which Settings Tab to be default when opening settings page": "Choose which Settings Tab to be default when opening settings page",
         "Please make sure to use the same (sub)domain to access Jellyfin as Organizr's": "Please make sure to use the same (sub)domain to access Jellyfin as Organizr's",
         "Please make sure to use the local address to the API": "Please make sure to use the local address to the API",
         "DO NOT SET THIS TO YOUR ADMIN ACCOUNT. We recommend you create a local account as a \"catch all\" for when Organizr is unable to perform SSO.  Organizr will request a User Token based off of this user credentials": "DO NOT SET THIS TO YOUR ADMIN ACCOUNT. We recommend you create a local account as a \"catch all\" for when Organizr is unable to perform SSO.  Organizr will request a User Token based off of this user credentials",
-        "Purge Log": "Purge Log",
+        "Purge Log": "Verwijder Log",
         "Avatar": "Avatar",
-        "Date Registered": "Date Registered",
+        "Date Registered": "Registratie Datum",
         "Group": "Groep",
         "Locked": "Vergrendeld",
         "Copy to Clipboard": "Copy to Clipboard",
-        "Choose action:": "Choose action:",
+        "Choose action:": "Kies actie:",
         "You may enter multiple URL's using the CSV format.  i.e. link#1,link#2,link#3": "You may enter multiple URL's using the CSV format.  i.e. link#1,link#2,link#3",
         "Used to set the description for SEO meta tags": "Used to set the description for SEO meta tags",
         "Also sets the title of your site": "Also sets the title of your site",
-        "Up to date": "Up to date",
-        "Loading Pihole...": "Loading Pihole...",
-        "Loading Unifi...": "Loading Unifi...",
+        "Up to date": "Bijgewerkt",
+        "Loading Pihole...": "Pihole laden...",
+        "Loading Unifi...": "Unifi laden...",
         "Loading Weather...": "Weer laden...",
-        "Loading Tautulli...": "Loading Tautulli...",
+        "Loading Tautulli...": "Tautulli Laden...",
         "Loading Health Checks...": "Loading Health Checks...",
         "Health Checks": "Health Checks",
         "UniFi": "UniFi",
-        "Connection Error to rTorrent": "Connection Error to rTorrent",
-        "Request a Show or Movie": "Request a Show or Movie",
-        "Set": "Set",
+        "Connection Error to rTorrent": "Verbindingsfout naar rTorrent",
+        "Request a Show or Movie": "Vraag een nieuwe Tvserie of Film aan",
+        "Set": "Instellen",
         "Set WAL Mode": "Set WAL Mode",
         "Set DELETE Mode (Default)": "Set DELETE Mode (Default)",
         "Journal Mode Status": "Journal Mode Status",
         "This feature is experimental - You may face unexpected database is locked errors in logs": "This feature is experimental - You may face unexpected database is locked errors in logs",
-        "Warning": "Warning",
+        "Warning": "Waarschuwing",
         "Tab Help": "Tab Help",
         "Toggle this tab to loaded in the background on page load": "Toggle this tab to loaded in the background on page load",
         "Preload": "Preload",
@@ -843,14 +843,14 @@
         "Default": "Standaard",
         "Internal is for Organizr pages": "Intern is voor Organizr-pagina's",
         "iFrame is for all others": "iFrame is voor alle anderen",
-        "New Window is for items to open in a new window": "New Window is for items to open in a new window",
+        "New Window is for items to open in a new window": "Nieuw venster is voor onderdelen te openen in een nieuw venster",
         "The lowest Group that will have access to this tab": "De laagste groep die toegang moeten hebben tot dit tabblad",
-        "Each Tab is assigned a Category, the default is unsorted.  You may create new categories on the Category settings tab": "Each Tab is assigned a Category, the default is unsorted.  You may create new categories on the Category settings tab",
+        "Each Tab is assigned a Category, the default is unsorted.  You may create new categories on the Category settings tab": "Elke tab is toegewezen aan een categorie, de standaard is ongesorteerd. Je mag nieuwe categorien aanmaken in de categorie tab",
         "Category": "Categorie",
         "The text that will be displayed for that certain tab": "De tekst die weergegeven wordt voor het tabblad",
-        "Please Save before Testing. Note that using a blank password might not work correctly.": "Please Save before Testing. Note that using a blank password might not work correctly.",
-        "Use Custom Certificate": "Use Custom Certificate",
-        "Note that using a blank password might not work correctly.": "Note that using a blank password might not work correctly.",
+        "Please Save before Testing. Note that using a blank password might not work correctly.": "Sla aub op voordat je test. Een leeg wachtwoord werkt mogelijk niet correct.",
+        "Use Custom Certificate": "Gebruik Eigen Certificaat",
+        "Note that using a blank password might not work correctly.": "Een leeg wachtwoord werkt mogelijk niet correct.",
         "Database Password": "Databasewachtwoord",
         "Database Username": "Databasegebruiker",
         "Database Host": "Databasehost",

+ 13 - 13
js/langpack/pl[Polish].json

@@ -632,20 +632,20 @@
         "OpenCollective Sponsor": "OpenCollective Sponsor",
         "Patreon Sponsor": "Patreon Sponsor",
         "New Organizr API v2": "New Organizr API v2",
-        "Develop Branch Users - Please switch to Master for mean time": "Develop Branch Users - Please switch to Master for mean time",
+        "Develop Branch Users - Please switch to Master for mean time": "Użytkownicy gałęzi Develop - Tymczasowo zmieńcie ją na Master",
         "API V2 TESTING almost complete": "API V2 TESTING prawie zakończone",
-        "Important Messages - Each message can now be ignored using ignore button": "Important Messages - Each message can now be ignored using ignore button",
+        "Important Messages - Each message can now be ignored using ignore button": "Ważne wiadomości - Każda wiadomość może być teraz zignorowana przyciskiem",
         "Minimum PHP Version change": "Zmiana min. wersji PHP",
         "You": "Ty",
-        "Drop Certificate file here to upload": "Drop Certificate file here to upload",
-        "Custom Certificate Loaded": "Custom Certificate Loaded",
-        "By default, Organizr uses certificates from https://curl.se/docs/caextract.html": "By default, Organizr uses certificates from https://curl.se/docs/caextract.html",
-        "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.": "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.",
-        "i.e. X-Forwarded-Email": "i.e. X-Forwarded-Email",
-        "Auth Proxy Header Name for Email": "Auth Proxy Header Name for Email",
+        "Drop Certificate file here to upload": "Upuść tutaj plik certyfikatu, aby przesłać",
+        "Custom Certificate Loaded": "Wczytano niestandardowy certyfikat",
+        "By default, Organizr uses certificates from https://curl.se/docs/caextract.html": "Domyślnie, Organizr używa certyfikatów z https://curl.se/docs/caextract.html",
+        "If you would like to use your own certificate, please upload it below.  You will then need to enable each homepage item to use it.": "Jeśli chcesz użyć własnego certyfikatu, prześlij go poniżej. Będziesz wtedy musiał włączyć każdy element strony głównej, aby go używać.",
+        "i.e. X-Forwarded-Email": "np. X-Forwarded-Email",
+        "Auth Proxy Header Name for Email": "Nazwa nagłówka proxy uwierzytelniania dla e-mail",
         "Custom Recover Password Text": "Niestandardowy tekst odzyskiwania hasła",
         "Disable Recover Password": "Wyłącz odzyskiwanie hasła",
-        "Blacklisted Error Message": "Blacklisted Error Message",
+        "Blacklisted Error Message": "Komunikat o błędzie czarnej listy",
         "Blacklisted IP's": "Blokowane adresy IP",
         "http(s)://domain": "http(s)://domena",
         "Traefik Domain for Return Override": "Traefik Domain for Return Override",
@@ -654,15 +654,15 @@
         "Enable LDAP TLS": "Włącz LDAP TLS",
         "Enable LDAP SSL": "Włącz LDAP SSL",
         "Bind Password": "Bind Password",
-        "http(s) | ftp(s) | ldap(s)://hostname:port": "http(s) | ftp(s) | ldap(s)://hostname:port",
-        "Plex Admin Username": "Plex Admin Username",
-        "Default Settings Tab": "Default Settings Tab",
+        "http(s) | ftp(s) | ldap(s)://hostname:port": "http(s) | ftp(s) | ldap(s)://nazwa_hosta:port",
+        "Plex Admin Username": "Nazwa użytkownika administratora Plex",
+        "Default Settings Tab": "Zakładka Ustawienia domyślne",
         "Certificate": "Certyfikat",
         "Ping": "Ping",
         "API": "API",
         "Github": "Github",
         "Settings Page": "Ustawienia",
-        "http(s)://domain.com": "http(s)://domain.com",
+        "http(s)://domain.com": "http(s)://domena.com",
         "Jellyfin SSO URL": "Jellyfin SSO URL",
         "Jellyfin API URL": "Jellyfin API URL",
         "Ombi Fallback Password": "Ombi Fallback Password",

+ 7 - 0
js/version.json

@@ -593,5 +593,12 @@
     "new": "added check for updates on organizr class",
     "fixed": "fix fatal error on mariadb installs",
     "notes": "update api docs|Updated the following languages: [Catalan] [Chinese] [German] [Italian]"
+  },
+  "2.1.2370": {
+    "date": "2022-09-30 17:17",
+    "title": "Long Lost Update",
+    "new": "added button to open current Organizr tab in new browser tab|added context to mailer error|added custom backup location FR#160|added Duplicati tab icon|added new device emails to login|added new functions getSSOList getSSOEnabledCount getSSOTimeout|added new timeouts for sso item based of sso items enabled|added shuckStop plugin|added zwavejs image|change to address to bcc",
+    "fixed": "add blackhole downloads and fix order|fix cert issue on trakt oauth (#1872)|fixed anchor in url (#1858)|fixed Jackett is freezing in UI on API 500 Error (#1869)|fix order|fix tautulli api parse error (#1860)",
+    "notes": "Adguard Home|adguard home|Better Kavita.png so the icon is now the same size as other tabs|change relative path to use root path|change syling to adguard|Issue 1866 - Open in new browser tab|pihole refresh -\u003E adguard refresh|prowlarr|Prowlarr homepage|prowlarr homepage item|remove blackhole option|remove unneeded cases|remove unneeded text change|sometimes chmod sees double|update api.json|update api.json to reflect shucks plugin|updated gitignore to included shucktop plugin|Updated the following languages: [Catalan] [Dutch] [Polish]|Update Kavita.png|update linux script to check and set permissions"
   }
 }

BIN
plugins/images/tabs/Duplicati.png


BIN
plugins/images/tabs/Kavita.png


BIN
plugins/images/tabs/zwavejs.png