aio-interface: allow to manage the community containers via the AIO interface (#6443)

Signed-off-by: Simon L. <szaimen@e.mail.de>
Signed-off-by: Jean-Yves <7360784+docjyJ@users.noreply.github.com>
This commit is contained in:
Simon L. 2025-05-30 09:32:51 +02:00 committed by GitHub
parent 17ec503bf3
commit 673b1db07e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 220 additions and 205 deletions

View file

@ -1,81 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="6.11.0@4ed53b7ccebc09ef60ec4c9e464bf8a01bfd35b0">
<file src="src/Auth/AuthManager.php">
<ClassMustBeFinal>
<code><![CDATA[AuthManager]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Auth/PasswordGenerator.php">
<ClassMustBeFinal>
<code><![CDATA[PasswordGenerator]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/AioVariables.php">
<ClassMustBeFinal>
<code><![CDATA[AioVariables]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/Container.php">
<ClassMustBeFinal>
<code><![CDATA[Container]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/ContainerEnvironmentVariables.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerEnvironmentVariables]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/ContainerPort.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerPort]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/ContainerPorts.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerPorts]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/ContainerVolume.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerVolume]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Container/ContainerVolumes.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerVolumes]]></code>
</ClassMustBeFinal>
</file>
<file src="src/ContainerDefinitionFetcher.php">
<ClassMustBeFinal>
<code><![CDATA[ContainerDefinitionFetcher]]></code>
</ClassMustBeFinal>
<PossiblyFalseArgument>
<code><![CDATA[file_get_contents($path)]]></code>
<code><![CDATA[file_get_contents(__DIR__ . '/../containers.json')]]></code>
</PossiblyFalseArgument>
</file>
<file src="src/Controller/ConfigurationController.php">
<ClassMustBeFinal>
<code><![CDATA[ConfigurationController]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Controller/DockerController.php">
<ClassMustBeFinal>
<code><![CDATA[DockerController]]></code>
</ClassMustBeFinal>
<InvalidOperand>
<code><![CDATA[$port]]></code>
</InvalidOperand>
</file>
<file src="src/Controller/LoginController.php">
<ClassMustBeFinal>
<code><![CDATA[LoginController]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Data/ConfigurationManager.php">
<ClassMustBeFinal>
<code><![CDATA[ConfigurationManager]]></code>
</ClassMustBeFinal>
<FalsableReturnStatement>
<code><![CDATA[$additionalBackupDirectories]]></code>
</FalsableReturnStatement>
@ -98,9 +34,6 @@
</PossiblyFalseArgument>
</file>
<file src="src/Data/DataConst.php">
<ClassMustBeFinal>
<code><![CDATA[DataConst]]></code>
</ClassMustBeFinal>
<FalsableReturnStatement>
<code><![CDATA[realpath(__DIR__ . '/../../../community-containers/')]]></code>
<code><![CDATA[realpath(__DIR__ . '/../../data/')]]></code>
@ -112,57 +45,18 @@
<code><![CDATA[string]]></code>
</InvalidFalsableReturnType>
</file>
<file src="src/Data/InvalidSettingConfigurationException.php">
<ClassMustBeFinal>
<code><![CDATA[InvalidSettingConfigurationException]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Data/Setup.php">
<ClassMustBeFinal>
<code><![CDATA[Setup]]></code>
</ClassMustBeFinal>
</file>
<file src="src/DependencyInjection.php">
<ClassMustBeFinal>
<code><![CDATA[DependencyInjection]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Docker/DockerActionManager.php">
<ClassMustBeFinal>
<code><![CDATA[DockerActionManager]]></code>
</ClassMustBeFinal>
<PossiblyFalseArgument>
<code><![CDATA[$line]]></code>
<code><![CDATA[$line]]></code>
</PossiblyFalseArgument>
</file>
<file src="src/Docker/DockerHubManager.php">
<ClassMustBeFinal>
<code><![CDATA[DockerHubManager]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Docker/GitHubContainerRegistryManager.php">
<ClassMustBeFinal>
<code><![CDATA[GitHubContainerRegistryManager]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Middleware/AuthMiddleware.php">
<ClassMustBeFinal>
<code><![CDATA[AuthMiddleware]]></code>
</ClassMustBeFinal>
</file>
<file src="src/Twig/ClassExtension.php">
<ClassMustBeFinal>
<code><![CDATA[ClassExtension]]></code>
</ClassMustBeFinal>
<MissingOverrideAttribute>
<code><![CDATA[public function getFunctions() : array]]></code>
</MissingOverrideAttribute>
</file>
<file src="src/Twig/CsrfExtension.php">
<ClassMustBeFinal>
<code><![CDATA[CsrfExtension]]></code>
</ClassMustBeFinal>
<MissingOverrideAttribute>
<code><![CDATA[public function getGlobals() : array]]></code>
</MissingOverrideAttribute>

View file

@ -19,5 +19,6 @@
<directory name="vendor" />
</extraFiles>
<issueHandlers>
<ClassMustBeFinal errorLevel="suppress" />
</issueHandlers>
</psalm>

View file

@ -0,0 +1,88 @@
document.addEventListener("DOMContentLoaded", function () {
// Hide submit button initially
const optionsFormSubmit = document.getElementById("options-form-submit");
optionsFormSubmit.style.display = 'none';
const communityFormSubmit = document.getElementById("community-form-submit");
communityFormSubmit.style.display = 'none';
// Store initial states for all checkboxes
const initialStateOptionsContainers = {};
const initialStateCommunityContainers = {};
const optionsContainersCheckboxes = document.querySelectorAll("#options-form input[type='checkbox']");
const communityContainersCheckboxes = document.querySelectorAll("#community-form input[type='checkbox']");
optionsContainersCheckboxes.forEach(checkbox => {
initialStateOptionsContainers[checkbox.id] = checkbox.checked; // Use checked property to capture actual initial state
});
communityContainersCheckboxes.forEach(checkbox => {
initialStateCommunityContainers[checkbox.id] = checkbox.checked; // Use checked property to capture actual initial state
});
// Function to compare current states to initial states
function checkForOptionContainerChanges() {
let hasChanges = false;
optionsContainersCheckboxes.forEach(checkbox => {
if (checkbox.checked !== initialStateOptionsContainers[checkbox.id]) {
hasChanges = true;
}
});
// Show or hide submit button based on changes
optionsFormSubmit.style.display = hasChanges ? 'block' : 'none';
}
// Function to compare current states to initial states
function checkForCommunityContainerChanges() {
let hasChanges = false;
communityContainersCheckboxes.forEach(checkbox => {
if (checkbox.checked !== initialStateCommunityContainers[checkbox.id]) {
hasChanges = true;
}
});
// Show or hide submit button based on changes
communityFormSubmit.style.display = hasChanges ? 'block' : 'none';
}
// Event listener to trigger visibility check on each change
optionsContainersCheckboxes.forEach(checkbox => {
checkbox.addEventListener("change", checkForOptionContainerChanges);
});
communityContainersCheckboxes.forEach(checkbox => {
checkbox.addEventListener("change", checkForCommunityContainerChanges);
});
// Custom behaviors for specific options
function handleTalkVisibility() {
const talkRecording = document.getElementById("talk-recording");
if (document.getElementById("talk").checked) {
talkRecording.disabled = false;
} else {
talkRecording.checked = false;
talkRecording.disabled = true;
}
checkForOptionContainerChanges(); // Check changes after toggling Talk Recording
}
function handleDockerSocketProxyWarning() {
if (document.getElementById("docker-socket-proxy").checked) {
alert('⚠️ Warning! Enabling this container comes with possible Security problems since you are exposing the docker socket and all its privileges to the Nextcloud container. Enable this only if you are sure what you are doing!');
}
}
// Initialize event listeners for specific behaviors
document.getElementById("talk").addEventListener('change', handleTalkVisibility);
document.getElementById("docker-socket-proxy").addEventListener('change', handleDockerSocketProxyWarning);
// Initialize talk-recording visibility on page load
handleTalkVisibility(); // Ensure talk-recording is correctly initialized
// Initial call to check for changes
checkForOptionContainerChanges();
checkForCommunityContainerChanges();
});

View file

@ -128,7 +128,9 @@ $app->get('/containers', function (Request $request, Response $response, array $
'is_nvidia_gpu_enabled' => $configurationManager->isNvidiaGpuEnabled(),
'is_talk_recording_enabled' => $configurationManager->isTalkRecordingEnabled(),
'is_docker_socket_proxy_enabled' => $configurationManager->isDockerSocketProxyEnabled(),
'is_whiteboard_enabled' => $configurationManager->isWhiteboardEnabled(),
'is_whiteboard_enabled' => $configurationManager->isWhiteboardEnabled(),
'community_containers' => $configurationManager->listAvailableCommunityContainers(),
'community_containers_enabled' => $configurationManager->GetEnabledCommunityContainers(),
]);
})->setName('profile');
$app->get('/login', function (Request $request, Response $response, array $args) use ($container) {

View file

@ -1,60 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
// Hide submit button initially
const optionsFormSubmit = document.getElementById("options-form-submit");
optionsFormSubmit.style.display = 'none';
// Store initial states for all checkboxes
const initialState = {};
const checkboxes = document.querySelectorAll("#options-form input[type='checkbox']");
checkboxes.forEach(checkbox => {
initialState[checkbox.id] = checkbox.checked; // Use checked property to capture actual initial state
});
// Function to compare current states to initial states
function checkForChanges() {
let hasChanges = false;
checkboxes.forEach(checkbox => {
if (checkbox.checked !== initialState[checkbox.id]) {
hasChanges = true;
}
});
// Show or hide submit button based on changes
optionsFormSubmit.style.display = hasChanges ? 'block' : 'none';
}
// Event listener to trigger visibility check on each change
checkboxes.forEach(checkbox => {
checkbox.addEventListener("change", checkForChanges);
});
// Custom behaviors for specific options
function handleTalkVisibility() {
const talkRecording = document.getElementById("talk-recording");
if (document.getElementById("talk").checked) {
talkRecording.disabled = false;
} else {
talkRecording.checked = false;
talkRecording.disabled = true;
}
checkForChanges(); // Check changes after toggling Talk Recording
}
function handleDockerSocketProxyWarning() {
if (document.getElementById("docker-socket-proxy").checked) {
alert('⚠️ Warning! Enabling this container comes with possible Security problems since you are exposing the docker socket and all its privileges to the Nextcloud container. Enable this only if you are sure what you are doing!');
}
}
// Initialize event listeners for specific behaviors
document.getElementById("talk").addEventListener('change', handleTalkVisibility);
document.getElementById("docker-socket-proxy").addEventListener('change', handleDockerSocketProxyWarning);
// Initialize talk-recording visibility on page load
handleTalkVisibility(); // Ensure talk-recording is correctly initialized
// Initial call to check for changes
checkForChanges();
});

View file

@ -0,0 +1,12 @@
<?php
namespace AIO\Container;
readonly class CommunityContainer {
public function __construct(
string $id,
string $name,
string $documentation,
) {
}
}

View file

@ -15,7 +15,7 @@ readonly class ConfigurationController {
) {
}
public function SetConfig(Request $request, Response $response, array $args) : Response {
public function SetConfig(Request $request, Response $response, array $args): Response {
try {
if (isset($request->getParsedBody()['domain'])) {
$domain = $request->getParsedBody()['domain'] ?? '';
@ -125,6 +125,10 @@ readonly class ConfigurationController {
}
}
if (isset($request->getParsedBody()['community-form'])) {
$this->configurationManager->SetEnabledCommunityContainers($request->getParsedBody()['enabled-community'] ?? []);
}
if (isset($request->getParsedBody()['delete_collabora_dictionaries'])) {
$this->configurationManager->DeleteCollaboraDictionaries();
}

View file

@ -3,6 +3,7 @@
namespace AIO\Data;
use AIO\Auth\PasswordGenerator;
use AIO\Container\CommunityContainer;
use AIO\Controller\DockerController;
class ConfigurationManager
@ -75,7 +76,7 @@ class ConfigurationManager
if (!file_exists(DataConst::GetBackupArchivesList())) {
return '';
}
$content = file_get_contents(DataConst::GetBackupArchivesList());
if ($content === '') {
return '';
@ -95,7 +96,7 @@ class ConfigurationManager
if ($lastBackupTime === "") {
return '';
}
return $lastBackupTime;
}
@ -103,7 +104,7 @@ class ConfigurationManager
if (!file_exists(DataConst::GetBackupArchivesList())) {
return [];
}
$content = file_get_contents(DataConst::GetBackupArchivesList());
if ($content === '') {
return [];
@ -114,7 +115,7 @@ class ConfigurationManager
foreach($backupLines as $lines) {
if ($lines !== "") {
$backupTimesTemp = explode(',', $lines);
$backupTimes[] = $backupTimesTemp[1];
$backupTimes[] = $backupTimesTemp[1];
}
}
@ -140,7 +141,7 @@ class ConfigurationManager
}
}
public function isClamavEnabled() : bool {
public function isClamavEnabled() : bool {
$config = $this->GetConfig();
if (isset($config['isClamavEnabled']) && $config['isClamavEnabled'] === 1) {
return true;
@ -375,7 +376,7 @@ class ConfigurationManager
$testUrl = $protocol . $domain . ':443';
curl_setopt($ch, CURLOPT_URL, $testUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = (string)curl_exec($ch);
# Get rid of trailing \n
@ -474,7 +475,7 @@ class ConfigurationManager
} elseif ($location !== '' && $repo !== '') {
throw new InvalidSettingConfigurationException("Location and remote repo url are mutually exclusive!");
}
if ($location !== '') {
$isValidPath = false;
if (str_starts_with($location, '/') && !str_ends_with($location, '/')) {
@ -629,7 +630,7 @@ class ConfigurationManager
if (!file_exists(DataConst::GetBackupPublicKey())) {
return "";
}
return trim(file_get_contents(DataConst::GetBackupPublicKey()));
}
@ -771,7 +772,7 @@ class ConfigurationManager
if (!preg_match("#^[0-1][0-9]:[0-5][0-9]$#", $time) && !preg_match("#^2[0-3]:[0-5][0-9]$#", $time)) {
throw new InvalidSettingConfigurationException("You did not enter a correct time! One correct example is '04:00'!");
}
if ($enableAutomaticUpdates === false) {
$time .= PHP_EOL . 'automaticUpdatesAreNotEnabled';
} else {
@ -1008,16 +1009,59 @@ class ConfigurationManager
}
private function GetCommunityContainers() : string {
$envVariableName = 'AIO_COMMUNITY_CONTAINERS';
$configName = 'aio_community_containers';
$defaultValue = '';
return $this->GetEnvironmentalVariableOrConfig($envVariableName, $configName, $defaultValue);
$config = $this->GetConfig();
if(!isset($config['aio_community_containers'])) {
$config['aio_community_containers'] = '';
}
return $config['aio_community_containers'];
}
public function GetEnabledCommunityContainers() : array {
/** @return list<CommunityContainer> */
public function listAvailableCommunityContainers() : array {
$cc = [];
$dir = scandir(DataConst::GetCommunityContainersDirectory());
if ($dir === false) {
return $cc;
}
foreach ($dir as $id) {
$filePath = DataConst::GetCommunityContainersDirectory() . '/' . $id . '/' . $id . '.json';
$fileContents = apcu_fetch($filePath);
if (!is_string($fileContents)) {
$fileContents = file_get_contents($filePath);
if (is_string($fileContents)) {
apcu_add($filePath, $fileContents);
}
}
$json = is_string($fileContents) ? json_decode($fileContents) : false;
if(is_array($json) && is_array($json['aio_services_v1'])) {
foreach ($json['aio_services_v1'] as $service) {
$documentation = is_string($service['documentation']) ? $service['documentation'] : '';
if (is_string($service['display_name'])) {
$cc[] = new CommunityContainer(
$id,
$service['display_name'],
$documentation);
}
break;
}
}
}
return $cc;
}
/** @return list<string> */
public function GetEnabledCommunityContainers(): array {
return explode(' ', $this->GetCommunityContainers());
}
public function SetEnabledCommunityContainers(array $enabledCommunityContainers) : void {
$config = $this->GetConfig();
$config['aio_community_containers'] = implode(' ', $enabledCommunityContainers);
$this->WriteConfig($config);
}
private function GetEnabledDriDevice() : string {
$envVariableName = 'NEXTCLOUD_ENABLE_DRI_DEVICE';
$configName = 'nextcloud_enable_dri_device';

View file

@ -606,6 +606,10 @@
{% endif %}
{% endif %}
{{ include('includes/community-containers.twig') }}
<script type="text/javascript" src="containers-form-submit.js?v4"></script>
{% if isApacheStarting == true or is_backup_container_running == true or isWatchtowerRunning == true or is_daily_backup_running == true %}
<script type="text/javascript" src="automatic_reload.js"></script>
{% else %}

View file

@ -0,0 +1,42 @@
<h2>Community Containers</h2>
<p>In this section you can enable or disable optional Community Containers that are not included by default in the main installation. These containers are provided by the community and can be useful for various purposes and are automatically integrated in AIOs backup solution and update mechanisms.</p>
<p><strong>⚠️ Caution: </strong>Community Containers are maintained by the community and not officially by Nextcloud. Some containers may not be compatible with your system, may not work as expected or may discontinue. Use them at your own risk. Please read the documentation for each container first before adding any as some are also incompatible between each other! Never add all of them at the same time!</p>
<details>
<summary>Show/Hide available Community Containers</summary>
{% if isAnyRunning == true %}
<p><strong>Please note:</strong> You can enable or disable the options below only when your containers are stopped.</p>
{% else %}
<p><strong>Please note:</strong> Make sure to save your changes by clicking <strong>Save changes</strong> below the list of Community Containers. The changes will not be auto-saved.</p>
{% endif %}
<form id="community-form" method="POST" action="/api/configuration" class="xhr">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="hidden" name="community-form" value="community-form">
{% for cc in community_containers %}
<p>
<input
type="checkbox"
id="enabled-community[]"
value="{{ cc.id }}"
name="{{ cc.id }}"
{% if cc.id in community_containers_enabled %}
checked="checked"
data-initial-state="true"
{% else %}
data-initial-state="false"
{% endif %}
{% if isAnyRunning == true %}
disabled="disabled"
{% endif %}
>
<label for="{{ cc.id }}">{{ cc.name }}
{% if cc.documentation != '' %}
<a href="{{ cc.documentation }}" target="_blank">(Documentation)</a>
{% endif %}
</label>
</p>
{% endfor %}
<input id="community-form-submit" type="submit" value="Save changes" onclick="return confirm('Are you sure that you read the documentation of all community containers that you enabled? If no, please do not continue as this might break your instance!')" />
</form>
</details>

View file

@ -1,11 +1,11 @@
<h2>Optional containers</h2>
<p>In this section you can enable or disable optional containers. There are further community containers available that are not listed below. See <strong><a target="_blank" href="https://github.com/nextcloud/all-in-one/tree/main/community-containers#community-containers">this documentation</a></strong> how to add them.</p>
<p>In this section you can enable or disable optional containers.</p>
{% if isAnyRunning == true %}
<p><strong>Please note:</strong> You can enable or disable the options below only when your containers are stopped.</p>
{% else %}
<p><strong>Please note:</strong> Make sure to save your changes by clicking <strong>Save changes</strong> below the list of optional containers. The changes will not be auto-saved.</p>
{% endif %}
<form id="options-form" method="POST" action="/api/configuration" class="xhr">
<form id="container-form" method="POST" action="/api/configuration" class="xhr">
<input type="hidden" name="{{csrf.keys.name}}" value="{{csrf.name}}">
<input type="hidden" name="{{csrf.keys.value}}" value="{{csrf.value}}">
<input type="hidden" name="options-form" value="options-form">
@ -143,7 +143,6 @@
<label for="whiteboard">Whiteboard</label>
</p>
<input id="options-form-submit" type="submit" value="Save changes" />
<script type="text/javascript" src="options-form-submit.js?v3"></script>
</form>
<p><strong>Minimal system requirements:</strong> When any optional container is enabled, at least 2GB RAM, a dual-core CPU and 40GB system storage are required. When enabling ClamAV, Nextcloud Talk Recording-server or Fulltextsearch, at least 3GB RAM are required. For Talk Recording-server additional 2 vCPUs are required. When enabling everything, at least 5GB RAM and a quad-core CPU are required. Recommended are at least 1GB more RAM than the minimal requirement. For further advice and recommendations see <strong><a target="_blank" href="https://github.com/nextcloud/all-in-one/discussions/1335">this documentation</a></strong></p>
{% if isAnyRunning == true %}