瀏覽代碼

Advanced services status management

Miraty 2 年之前
父節點
當前提交
abb9aabf5b
共有 13 個文件被更改,包括 143 次插入109 次删除
  1. 9 12
      DOCS/configuration.md
  2. 6 6
      DOCS/translation.md
  3. 3 4
      config.ini
  4. 6 5
      db/schema.sql
  5. 19 0
      fn/ht.php
  6. 18 9
      locales/fr/C/LC_MESSAGES/messages.po
  7. 17 8
      locales/messages.pot
  8. 1 17
      pg-act/auth/register.php
  9. 23 13
      pg-act/auth/unregister.php
  10. 1 1
      pg-view/auth/index.php
  11. 13 22
      pg-view/index.php
  12. 24 12
      router.php
  13. 3 0
      view.php

+ 9 - 12
DOCS/configuration.md

@@ -32,6 +32,15 @@ String defining the displayed identity of the service.
 
 Pretty string sometimes prefixed to the service name. Can be empty.
 
+### `services[]`
+
+Keys `reg`, `ns` and `ht` are required.
+
+Values can be:
+* `enabled`: the service is provided as usual
+* `error`: the service is temporarily unavailable for maintenance/debugging
+* `disabled`: the service is ignored everywhere ; this installation never provides it
+
 ## `[dns]`
 
 This configuration section is used by both the registry (`reg`) and the public name server (`ns`).
@@ -46,10 +55,6 @@ Filesystem path to the `kdig` binary. Used to authenticate resources possession
 
 ## `[reg]`
 
-### `enabled`
-
-Defines whether the web interface for this service is accessible.
-
 ### `suffixes[]`
 
 Lists the suffixes that the registry manages.
@@ -78,10 +83,6 @@ Host where the Knot DNS server answers the registry values. Should be a secure (
 
 ## `[ns]`
 
-### `enabled`
-
-Defines whether the web interface for this service is accessible.
-
 ### `knot_zones_path`
 
 Filesystem path to the zones directory. The full path to created zonefiles will be `knot_zones_path/<zone-apex-domain>.zone`.
@@ -102,10 +103,6 @@ Administrator email address published in every SOA record. Ends with a `.`, `@`
 
 ## `[ht]`
 
-### `enabled`
-
-Defines whether the web interface for this service is accessible. (Users can still use SFTP.)
-
 ### `ht_path`
 
 Filesystem path to the users files base directory. Files of a user are located inside `ht_path/<their-internal-user-id>/`

+ 6 - 6
DOCS/translation.md

@@ -3,13 +3,13 @@
 ## As a developer
 
 Extract messages to be translated from the source files and into a Portable Object Template file:
-```
-xgettext --from-code=UTF-8 --no-wrap -d messages -p locales/ --from-code=UTF-8 *.php */*.php */*/*.php
+```shell
+xgettext --from-code=UTF-8 --no-wrap -d messages -p locales/ *.php */*.php */*/*.php
 mv locales/messages.po locales/messages.pot
 ```
 
 Merge messages into existing Portable Objects:
-```
+```shell
 msgmerge --no-wrap locales/fr/C/LC_MESSAGES/messages.po locales/messages.pot -o locales/fr/C/LC_MESSAGES/messages.po
 ```
 
@@ -17,7 +17,7 @@ msgmerge --no-wrap locales/fr/C/LC_MESSAGES/messages.po locales/messages.pot -o
 
 ### To start a new translation
 
-```
+```shell
 mkdir -p locales/fr/C/LC_MESSAGES/
 msginit -i locales/messages.pot -o locales/fr/C/LC_MESSAGES/messages.po
 ```
@@ -26,12 +26,12 @@ msginit -i locales/messages.pot -o locales/fr/C/LC_MESSAGES/messages.po
 
 Edit `locales/fr/C/LC_MESSAGES/messages.po` using either
 * any text editor
-* a dedicated translation software like [Poedit](https://poedit.net/), [KDE's Lokalize](https://apps.kde.org/lokalize/) or [GNOME Translation Editor](https://wiki.gnome.org/Apps/Gtranslator).
+* dedicated translation software like [Poedit](https://poedit.net/), [KDE's Lokalize](https://apps.kde.org/lokalize/) or [GNOME Translation Editor](https://wiki.gnome.org/Apps/Gtranslator).
 
 ## As an administrator
 
 To compile Portable Objects into Machine Objects:
-```
+```shell
 msgfmt locales/fr/C/LC_MESSAGES/messages.po -o locales/fr/C/LC_MESSAGES/messages.mo
 ```
 

+ 3 - 4
config.ini

@@ -6,13 +6,15 @@ public_domains[] = "servnest.test"
 prefix = ""
 service_name = "ServNest"
 service_emoji = "🪺"
+services[reg] = "enabled"
+services[ns] = "enabled"
+services[ht] = "enabled"
 
 [dns]
 knotc_path = "/usr/sbin/knotc"
 kdig_path = "/usr/bin/kdig"
 
 [reg]
-enabled = true
 suffixes[servnest.test.] = "approved"
 suffixes[test.servnest.test.] = "all"
 suffixes[old.sernnest.test.] = "none"
@@ -21,7 +23,6 @@ ttl = 86400
 address = "[::1]:42053"
 
 [ns]
-enabled = true
 knot_zones_path = "/srv/servnest/ns"
 servers[] = "ns1.servnest.test."
 servers[] = "ns2.servnest.test."
@@ -29,8 +30,6 @@ kzonecheck_path = "/usr/bin/kzonecheck"
 public_soa_email = "hostmaster.invalid."
 
 [ht]
-enabled = true
-
 ht_path = "/srv/servnest/ht"
 
 subpath_domain = "ht.servnest.test"

+ 6 - 5
db/schema.sql

@@ -4,11 +4,11 @@ CREATE TABLE IF NOT EXISTS "params" (
 	"value" TEXT NOT NULL,
 	PRIMARY KEY("name")
 );
-INSERT INTO "params"("name", "value") VALUES("instance_bucket_tokens", "0");
-INSERT INTO "params"("name", "value") VALUES("instance_bucket_last_update", "0");
-INSERT INTO "params"("name", "value") VALUES("secret_key", "0");
-INSERT INTO "params"("name", "value") VALUES("secret_key_last_change", "0");
-INSERT INTO "params"("name", "value") VALUES("username_salt", "00000000000000000000000000000000"); -- Should be unique and secret ; generate one using `openssl rand -hex 16` ; can't be changed without breaking current accounts login
+INSERT INTO "params"("name", "value") VALUES('instance_bucket_tokens', '0');
+INSERT INTO "params"("name", "value") VALUES('instance_bucket_last_update', '0');
+INSERT INTO "params"("name", "value") VALUES('secret_key', '0');
+INSERT INTO "params"("name", "value") VALUES('secret_key_last_change', '0');
+INSERT INTO "params"("name", "value") VALUES('username_salt', '00000000000000000000000000000000'); -- Should be unique and secret ; generate one using `openssl rand -hex 16` ; can't be changed without breaking current accounts login
 CREATE TABLE IF NOT EXISTS "users" (
 	"id"                 TEXT    NOT NULL UNIQUE,
 	"username"           TEXT    NOT NULL UNIQUE,
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS "users" (
 	"bucket_tokens"      INTEGER NOT NULL,
 	"bucket_last_update" INTEGER NOT NULL,
 	"type"               TEXT    NOT NULL,
+	"services"           TEXT    NOT NULL,
 	PRIMARY KEY("id")
 );
 CREATE TABLE IF NOT EXISTS "approval-keys" (

+ 19 - 0
fn/ht.php

@@ -1,5 +1,24 @@
 <?php
 
+function htSetupUserFs($id) {
+	// Setup SFTP directory
+	umask(0002);
+	if (mkdir(CONF['ht']['ht_path'] . '/' . $id, 0775) !== true)
+		output(500, 'Can\'t create user directory.');
+	exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['chgrp_path'] . ' ' . CONF['ht']['sftpgo_group'] . ' ' . CONF['ht']['ht_path'] . '/' . $id . ' --no-dereference', result_code: $code);
+	if ($code !== 0)
+		output(500, 'Can\'t change user directory group.');
+
+	// Setup Tor config directory
+	if (mkdir(CONF['ht']['tor_config_path'] . '/' . $id, 0755) !== true)
+		output(500, 'Can\'t create Tor config directory.');
+
+	// Setup Tor keys directory
+	exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['mkdir_path'] . ' --mode=0700 ' . CONF['ht']['tor_keys_path'] . '/' . $id, result_code: $code);
+	if ($code !== 0)
+		output(500, 'Can\'t create Tor keys directory.');
+}
+
 function checkDomainFormat($domain) {
 	// If the domain must end without a dot
 	if (!filter_var($domain, FILTER_VALIDATE_DOMAIN) OR !preg_match('/^([a-z0-9_-]{1,63}\.){1,126}[a-z0-9]{1,63}$/D', $domain))

+ 18 - 9
locales/fr/C/LC_MESSAGES/messages.po

@@ -1,7 +1,7 @@
 msgid ""
 msgstr ""
-"Content-Type: text/plain; charset=UTF-8\n"
 "Language: fr\n"
+"Content-Type: text/plain; charset=UTF-8\n"
 
 #: pages.php:9
 msgid "Authentication"
@@ -272,11 +272,15 @@ msgstr "Supprimer un accès"
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr "Retirer un accès HTTP existant d'un sous-dossier de l'espace SFTP"
 
-#: router.php:134
+#: router.php:137 view.php:39
+msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
+msgstr "Ce service est en cours de maintenance. Aucune action ne peut être effectuée avant qu'ane administrataire termine de le réparer."
+
+#: router.php:147
 msgid "You need to be logged in to do this."
 msgstr "Vous devez être connecté·e à un compte pour faire cela."
 
-#: router.php:136
+#: router.php:149
 msgid "This account doesn't exist anymore. Log out to end this ghost session."
 msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
 
@@ -284,12 +288,12 @@ msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session f
 msgid "Anonymous"
 msgstr "Anonyme"
 
-#: view.php:41
+#: view.php:44
 #, php-format
 msgid "This form won't be accepted because you need to %slog in%s first."
 msgstr "Ce forumulaire ne sera pas accepté car il faut %sse connecter%s d'abord."
 
-#: view.php:48
+#: view.php:51
 #, php-format
 msgid "%sSource code%s available under %s."
 msgstr "%sCode source%s disponible sous %s."
@@ -314,7 +318,7 @@ msgstr "<strong>Erreur de l'utilisataire</strong>&nbsp;: "
 msgid "<strong>Server error</strong>: "
 msgstr "<strong>Erreur du serveur</strong>&nbsp;: "
 
-#: fn/common.php:133
+#: fn/common.php:129
 msgid "Wrong proof."
 msgstr "Preuve incorrecte."
 
@@ -322,7 +326,7 @@ msgstr "Preuve incorrecte."
 msgid "IP address malformed."
 msgstr "Adresse IP malformée."
 
-#: fn/dns.php:67 fn/ht.php:6
+#: fn/dns.php:67 fn/ht.php:25
 msgid "Domain malformed."
 msgstr "Domaine malformé."
 
@@ -372,7 +376,12 @@ msgstr "Cet identifiant est déjà pris."
 msgid "Account deletion must be confirmed."
 msgstr "La suppression du compte doit être confirmée."
 
-#: pg-act/auth/unregister.php:29
+#: pg-act/auth/unregister.php:10
+#, php-format
+msgid "Your account can't be deleted because the %s service is currently unavailable."
+msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
+
+#: pg-act/auth/unregister.php:39
 msgid "Account deleted."
 msgstr "Compte supprimé."
 
@@ -566,7 +575,7 @@ msgid "Approved"
 msgstr "Approuvé"
 
 #: pg-view/auth/index.php:27
-msgid "It was originally a testing account, but has been approved by an administrator, and is suited for stable usecases:"
+msgid "It was originally a testing account, but has been approved by an administrator, and is suitable for stable use cases:"
 msgstr "C'est originellement un compte de test, mais qui a été approuvé par ane administrataire, et qui permet une utilisation stable&nbsp;:"
 
 #: pg-view/auth/index.php:30

+ 17 - 8
locales/messages.pot

@@ -271,11 +271,15 @@ msgstr ""
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr ""
 
-#: router.php:134
+#: router.php:137 view.php:39
+msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
+msgstr ""
+
+#: router.php:147
 msgid "You need to be logged in to do this."
 msgstr ""
 
-#: router.php:136
+#: router.php:149
 msgid "This account doesn't exist anymore. Log out to end this ghost session."
 msgstr ""
 
@@ -283,12 +287,12 @@ msgstr ""
 msgid "Anonymous"
 msgstr ""
 
-#: view.php:41
+#: view.php:44
 #, php-format
 msgid "This form won't be accepted because you need to %slog in%s first."
 msgstr ""
 
-#: view.php:48
+#: view.php:51
 #, php-format
 msgid "%sSource code%s available under %s."
 msgstr ""
@@ -313,7 +317,7 @@ msgstr ""
 msgid "<strong>Server error</strong>: "
 msgstr ""
 
-#: fn/common.php:133
+#: fn/common.php:129
 msgid "Wrong proof."
 msgstr ""
 
@@ -321,7 +325,7 @@ msgstr ""
 msgid "IP address malformed."
 msgstr ""
 
-#: fn/dns.php:67 fn/ht.php:6
+#: fn/dns.php:67 fn/ht.php:25
 msgid "Domain malformed."
 msgstr ""
 
@@ -371,7 +375,12 @@ msgstr ""
 msgid "Account deletion must be confirmed."
 msgstr ""
 
-#: pg-act/auth/unregister.php:29
+#: pg-act/auth/unregister.php:10
+#, php-format
+msgid "Your account can't be deleted because the %s service is currently unavailable."
+msgstr ""
+
+#: pg-act/auth/unregister.php:39
 msgid "Account deleted."
 msgstr ""
 
@@ -565,7 +574,7 @@ msgid "Approved"
 msgstr ""
 
 #: pg-view/auth/index.php:27
-msgid "It was originally a testing account, but has been approved by an administrator, and is suited for stable usecases:"
+msgid "It was originally a testing account, but has been approved by an administrator, and is suitable for stable use cases:"
 msgstr ""
 
 #: pg-view/auth/index.php:30

+ 1 - 17
pg-act/auth/register.php

@@ -21,25 +21,9 @@ insert('users', [
 	'bucket_tokens' => 0,
 	'bucket_last_update' => 0,
 	'type' => 'testing',
+	'services' => '',
 ]);
 
-// Setup SFTP directory
-umask(0002);
-if (mkdir(CONF['ht']['ht_path'] . '/' . $id, 0775) !== true)
-	output(500, 'Can\'t create user directory.');
-exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['chgrp_path'] . ' ' . CONF['ht']['sftpgo_group'] . ' ' . CONF['ht']['ht_path'] . '/' . $id . ' --no-dereference', result_code: $code);
-if ($code !== 0)
-	output(500, 'Can\'t change user directory group.');
-
-// Setup Tor config directory
-if (mkdir(CONF['ht']['tor_config_path'] . '/' . $id, 0755) !== true)
-	output(500, 'Can\'t create Tor config directory.');
-
-// Setup Tor keys directory
-exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['mkdir_path'] . ' --mode=0700 ' . CONF['ht']['tor_keys_path'] . '/' . $id, result_code: $code);
-if ($code !== 0)
-	output(500, 'Can\'t create Tor keys directory.');
-
 stopSession();
 startSession();
 

+ 23 - 13
pg-act/auth/unregister.php

@@ -3,24 +3,34 @@
 if (!isset($_POST['delete']))
 	output(403, _('Account deletion must be confirmed.'));
 
-foreach (query('select', 'registry', ['username' => $_SESSION['id']], 'domain') as $domain)
-	regDeleteDomain($domain);
+$user_services = explode(',', query('select', 'users', ['username' => $_SESSION['id']], 'services')[0]);
 
-foreach (query('select', 'zones', ['username' => $_SESSION['id']], 'zone') as $zone)
-	nsDeleteZone($zone);
+foreach (SERVICES_USER as $service)
+	if (in_array($service, $user_services, true) AND CONF['common']['services'][$service] !== 'enabled')
+		output(503, sprintf(_('Your account can\'t be deleted because the %s service is currently unavailable.'), '<em>' . PAGES[$service]['index']['title'] . '</em>'));
 
-foreach (query('select', 'sites', ['username' => $_SESSION['id']]) as $site)
-	htDeleteSite($site['address'], $site['type']);
+if (in_array('reg', $user_services, true))
+	foreach (query('select', 'registry', ['username' => $_SESSION['id']], 'domain') as $domain)
+		regDeleteDomain($domain);
 
-exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['rm_path'] . ' --recursive ' . CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'], result_code: $code);
-if ($code !== 0)
-	output(500, 'Can\'t remove Tor keys directory.');
+if (in_array('ns', $user_services, true))
+	foreach (query('select', 'zones', ['username' => $_SESSION['id']], 'zone') as $zone)
+		nsDeleteZone($zone);
 
-removeDirectory(CONF['ht']['tor_config_path'] . '/' . $_SESSION['id']);
+if (in_array('ht', $user_services, true)) {
+	foreach (query('select', 'sites', ['username' => $_SESSION['id']]) as $site)
+		htDeleteSite($site['address'], $site['type']);
 
-exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['sftpgo_user'] .  ' ' . CONF['ht']['rm_path'] . ' --recursive ' . CONF['ht']['ht_path'] . '/' . $_SESSION['id'], result_code: $code);
-if ($code !== 0)
-	output(500, 'Can\'t remove user\'s directory.');
+	exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['rm_path'] . ' --recursive ' . CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'], result_code: $code);
+	if ($code !== 0)
+		output(500, 'Can\'t remove Tor keys directory.');
+
+	removeDirectory(CONF['ht']['tor_config_path'] . '/' . $_SESSION['id']);
+
+	exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['sftpgo_user'] .  ' ' . CONF['ht']['rm_path'] . ' --recursive ' . CONF['ht']['ht_path'] . '/' . $_SESSION['id'], result_code: $code);
+	if ($code !== 0)
+		output(500, 'Can\'t remove user\'s directory.');
+}
 
 query('delete', 'users', ['id' => $_SESSION['id']]);
 

+ 1 - 1
pg-view/auth/index.php

@@ -24,7 +24,7 @@
 	</dd>
 	<dt><span aria-hidden="true">👤 </span><em><?= _('Approved') ?></em></dt>
 	<dd>
-		<?= _('It was originally a testing account, but has been approved by an administrator, and is suited for stable usecases:') ?>
+		<?= _('It was originally a testing account, but has been approved by an administrator, and is suitable for stable use cases:') ?>
 		<ul>
 			<li><?= sprintf(_('%s of SFTP quota'), ((CONF['ht']['user_quota_approved'] >> 30) >= 1) ? CONF['ht']['user_quota_approved'] >> 30 . ' ' . _('<abbr title="gibibyte">GiB</abbr>') : CONF['ht']['user_quota_approved'] >> 20 . ' ' . _('<abbr title="mebibyte">MiB</abbr>')) ?></li>
 			<li><?= _('Stable Let\'s Encrypt certificates') ?></li>

+ 13 - 22
pg-view/index.php

@@ -1,26 +1,17 @@
 <nav>
 	<dl>
-		<dt><a class="auth" href="auth/"><?= PAGES['auth']['index']['title'] ?></a></dt>
-		<dd>
-			<?= PAGES['auth']['index']['description'] ?>
-		</dd>
-	<?php if (CONF['reg']['enabled'] === true) { ?>
-		<dt><a class="reg" href="reg/"><?= PAGES['reg']['index']['title'] ?></code></a></dt>
-		<dd>
-			<?= PAGES['reg']['index']['description'] ?>
-		</dd>
-	<?php } ?>
-	<?php if (CONF['ns']['enabled'] === true) { ?>
-		<dt><a class="ns" href="ns/"><?= PAGES['ns']['index']['title'] ?></a></dt>
-		<dd>
-			<?= PAGES['ns']['index']['description'] ?>
-		</dd>
-	<?php } ?>
-	<?php if (CONF['ht']['enabled'] === true) { ?>
-		<dt><a class="ht" href="ht/"><?= PAGES['ht']['index']['title'] ?></a></dt>
-		<dd>
-			<?= PAGES['ht']['index']['description'] ?>
-		</dd>
-	<?php } ?>
+<?php
+
+foreach (array_merge(['auth' => 'enabled'], CONF['common']['services']) as $service => $status) {
+	if ($status !== 'enabled' AND $status !== 'error')
+		continue;
+?>
+		<?= ($status === 'error') ? '<s>' : '' ?>
+			<dt><a class="<?= $service ?>" href="<?= $service ?>/"><?= PAGES[$service]['index']['title'] ?></a></dt>
+			<dd>
+				<?= PAGES[$service]['index']['description'] ?>
+			</dd>
+		<?= ($status === 'error') ? '</s>' : '' ?>
+<?php } ?>
 	</dl>
 </nav>

+ 24 - 12
router.php

@@ -15,13 +15,15 @@ setlocale(LC_MESSAGES, 'C.UTF-8');
 bindtextdomain('messages', 'locales/' . LOCALE);
 header('Content-Language: ' . LOCALE);
 
+const SERVICES_USER = ['reg', 'ns', 'ht'];
+
 const LF = "\n";
 
 const PLACEHOLDER_DOMAIN = 'example'; // From RFC2606: Reserved Top Level DNS Names > 2. TLDs for Testing, & Documentation Examples
 const PLACEHOLDER_IPV6 = '2001:db8::3'; // From RFC3849: IPv6 Address Prefix Reserved for Documentation
 const PLACEHOLDER_IPV4 = '203.0.113.42'; // From RFC5737: IPv4 Address Blocks Reserved for Documentation
 
-foreach (array_diff(scandir(CONF['common']['root_path'] . '/fn'), array('..', '.')) as $file)
+foreach (array_diff(scandir(CONF['common']['root_path'] . '/fn'), ['..', '.']) as $file)
 	require CONF['common']['root_path'] . '/fn/' . $file;
 
 require 'pages.php';
@@ -38,7 +40,6 @@ define('PAGE_URL', $pageAddress);
 define('PAGE_ADDRESS', $pageAddress . ((substr($pageAddress, -1) === '/' OR $pageAddress === '') ? 'index' : ''));
 define('PAGE_LINEAGE', explode('/', PAGE_ADDRESS));
 define('SERVICE', dirname(PAGE_ADDRESS));
-define('PAGE', basename(PAGE_ADDRESS, '.php'));
 
 function getPageInformations($pages, $pageElements) {
 	if (!isset($pages['index']) OR $pageElements[0] === 'index')
@@ -87,6 +88,7 @@ if (isset($_COOKIE[SESSION_COOKIE_NAME]))
 	startSession(); // Resume session
 
 if (isset($_SESSION['id'])) {
+	// Decrypt display username
 	if (!isset($_COOKIE['display-username-decryption-key']))
 		output(403, 'The display username decryption key has not been sent.');
 	$decryption_result = htmlspecialchars(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
@@ -98,17 +100,18 @@ if (isset($_SESSION['id'])) {
 	if ($decryption_result === false)
 		output(403, 'Unable to decrypt display username.');
 	define('DISPLAY_USERNAME', $decryption_result);
-}
 
-if (in_array(SERVICE, ['reg', 'ns', 'ht']) AND CONF[SERVICE]['enabled'] !== true)
-	output(403, 'Ce service est désactivé.');
+	// Enable not already enabled services for this user
+	$user_services = array_filter(explode(',', query('select', 'users', ['id' => $_SESSION['id']], 'services')[0]));
+	if (in_array(SERVICE, SERVICES_USER, true) AND !in_array(SERVICE, $user_services, true) AND CONF['common']['services'][SERVICE] === 'enabled') {
+		$user_services[] = SERVICE;
 
-// Protect against cross-site request forgery if a POST request is received
-if ($_POST !== []) {
-	if (isset($_SERVER['HTTP_SEC_FETCH_SITE']) !== true)
-		output(403, 'The <code>Sec-Fetch-Site</code> HTTP header is required when submitting a POST request to prevent Cross-Site Request Forgery (<abbr>CSRF</abbr>).');
-	if ($_SERVER['HTTP_SEC_FETCH_SITE'] !== 'same-origin')
-		output(403, 'The <code>Sec-Fetch-Site</code> HTTP header must be <code>same-origin</code> when submitting a POST request to prevent Cross-Site Request Forgery (<abbr>CSRF</abbr>).');
+		DB->prepare('UPDATE users SET services = :services WHERE id = :id')
+		->execute([':services' => implode(',', $user_services), ':id' => $_SESSION['id']]);
+
+		if (SERVICE === 'ht')
+			htSetupUserFs($_SESSION['id']);
+	}
 }
 
 if (isset($_SERVER['SERVER_NAME']) !== true)
@@ -125,18 +128,27 @@ function displayFinalMessage($data) {
 }
 
 if ($_POST !== []) {
+	if (in_array(SERVICE, SERVICES_USER, true) AND CONF['common']['services'][SERVICE] !== 'enabled')
+		output(503, _('This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it.'));
+
+	// Protect against cross-site request forgery if a POST request is received
+	if (isset($_SERVER['HTTP_SEC_FETCH_SITE']) !== true)
+		output(403, 'The <code>Sec-Fetch-Site</code> HTTP header is required when submitting a POST request to prevent Cross-Site Request Forgery (<abbr>CSRF</abbr>).');
+	if ($_SERVER['HTTP_SEC_FETCH_SITE'] !== 'same-origin')
+		output(403, 'The <code>Sec-Fetch-Site</code> HTTP header must be <code>same-origin</code> when submitting a POST request to prevent Cross-Site Request Forgery (<abbr>CSRF</abbr>).');
+
 	if (PAGE_METADATA['require-login'] ?? true !== false) {
 		if (isset($_SESSION['id']) !== true)
 			output(403, _('You need to be logged in to do this.'));
 		if (isset(query('select', 'users', ['id' => $_SESSION['id']], 'id')[0]) !== true)
 			output(403, _('This account doesn\'t exist anymore. Log out to end this ghost session.'));
 	}
+
 	if (file_exists('pg-act/' . PAGE_ADDRESS . '.php'))
 		require 'pg-act/' . PAGE_ADDRESS . '.php';
 }
 
 function displayPage($data) {
-
 	require 'view.php';
 	exit();
 }

+ 3 - 0
view.php

@@ -35,6 +35,9 @@
 		<main>
 <?php
 
+if (in_array(SERVICE, SERVICES_USER, true) AND CONF['common']['services'][SERVICE] === 'error')
+	echo '<p><strong>' . _('This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it.') . '</strong></p>';
+
 	require 'pg-view/' . PAGE_ADDRESS . '.php';
 
 	if ($_POST === [] AND PAGE_METADATA['require-login'] ?? true !== false AND !isset($_SESSION['id']) AND PAGE_TERMINAL)