solved conflict main merge
commit
5af70cd6ea
31
README.md
31
README.md
|
@ -87,3 +87,34 @@ resources to aid you further:
|
||||||
- **User handbook**: [https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/](https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/)
|
- **User handbook**: [https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/](https://dd.digitalitzacio-democratica.xnet-x.net/manual-usuari/)
|
||||||
- **Admin/developer docs**: [https://dd.digitalitzacio-democratica.xnet-x.net/docs/](https://dd.digitalitzacio-democratica.xnet-x.net/docs/)
|
- **Admin/developer docs**: [https://dd.digitalitzacio-democratica.xnet-x.net/docs/](https://dd.digitalitzacio-democratica.xnet-x.net/docs/)
|
||||||
- **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD)
|
- **Source code**: [https://gitlab.com/DD-workspace/DD](https://gitlab.com/DD-workspace/DD)
|
||||||
|
|
||||||
|
# Why does git history start here?
|
||||||
|
|
||||||
|
<details><summary>Why does git history start here?</summary>
|
||||||
|
|
||||||
|
A lot of work went into stabilising the code and cleaning the repo before the
|
||||||
|
public announcement on the
|
||||||
|
[1st International Congress on Democratic Digital Education and Open Edtech](https://congress.democratic-digitalisation.xnet-x.net/).
|
||||||
|
|
||||||
|
Using that version as a clean slate got us to the repo you see here, where
|
||||||
|
changes will be reviewed before going in and anyone is welcome.
|
||||||
|
|
||||||
|
When in doubt about authorship, please check each file's license headers.
|
||||||
|
|
||||||
|
The authorship of the previous commits is from:
|
||||||
|
|
||||||
|
- Josep Maria Viñolas Auquer
|
||||||
|
- Simó Albert i Beltran
|
||||||
|
- Alberto Larraz Dalmases
|
||||||
|
- Yoselin Ribero
|
||||||
|
- Elena Barrios Galán
|
||||||
|
- Melina Gamboa
|
||||||
|
- Antonio Manzano
|
||||||
|
- Cecilia Bayo
|
||||||
|
- Naomi Hidalgo
|
||||||
|
- Joan Cervan Andreu
|
||||||
|
- Jose Antonio Exposito Garcia
|
||||||
|
- Raúl FS
|
||||||
|
- Unai Tolosa Pontesta
|
||||||
|
- Evilham
|
||||||
|
</details>
|
||||||
|
|
|
@ -21,3 +21,6 @@ version: '3.7'
|
||||||
networks:
|
networks:
|
||||||
dd_net:
|
dd_net:
|
||||||
name: dd_net
|
name: dd_net
|
||||||
|
driver: bridge
|
||||||
|
driver_opts:
|
||||||
|
com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
# Generate .orig and .patch files with ./dd-ctl genpatches
|
# Generate .orig and .patch files with ./dd-ctl genpatches
|
||||||
# file license author source
|
# file license author source
|
||||||
nginx.conf AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/docker/522559eefdd56d2e49259c3b0f4a0e92882cdb87/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf
|
nginx.conf AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/docker/522559eefdd56d2e49259c3b0f4a0e92882cdb87/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf
|
||||||
|
#nc_mail/appinfo.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/appinfo/info.xml
|
||||||
|
#nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Command/UpdateAccount.php
|
||||||
|
#nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/MaadixNet/mail/feature/occ-account-update-command/lib/Db/MailAccountMapper.php
|
||||||
|
nc_mail/appinfo/info.xml AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/appinfo/info.xml
|
||||||
|
nc_mail/lib/Command/UpdateAccount.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Command/UpdateAccount.php
|
||||||
|
nc_mail/lib/Db/MailAccountMapper.php AGPL-3.0-or-later https://github.com/nextcloud/ https://raw.githubusercontent.com/nextcloud/mail/v1.12.8/lib/Db/MailAccountMapper.php
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||||
|
<id>mail</id>
|
||||||
|
<name>Mail</name>
|
||||||
|
<summary>💌 A mail app for Nextcloud</summary>
|
||||||
|
<description><![CDATA[**💌 A mail app for Nextcloud**
|
||||||
|
|
||||||
|
- **🚀 Integration with other Nextcloud apps!** Currently Contacts, Calendar & Files – more to come.
|
||||||
|
- **📥 Multiple mail accounts!** Personal and company account? No problem, and a nice unified inbox. Connect any IMAP account.
|
||||||
|
- **🔒 Send & receive encrypted mails!** Using the great [Mailvelope](https://mailvelope.com) browser extension.
|
||||||
|
- **🙈 We’re not reinventing the wheel!** Based on the great [Horde](https://horde.org) libraries.
|
||||||
|
- **📬 Want to host your own mail server?** We do not have to reimplement this as you could set up [Mail-in-a-Box](https://mailinabox.email)!
|
||||||
|
]]></description>
|
||||||
|
<version>1.12.8</version>
|
||||||
|
<licence>agpl</licence>
|
||||||
|
<author>Greta Doçi</author>
|
||||||
|
<author homepage="https://github.com/nextcloud/groupware">Nextcloud Groupware Team</author>
|
||||||
|
<namespace>Mail</namespace>
|
||||||
|
<documentation>
|
||||||
|
<user>https://github.com/nextcloud/mail/blob/main/doc/user.md</user>
|
||||||
|
<admin>https://github.com/nextcloud/mail/blob/main/doc/admin.md</admin>
|
||||||
|
<developer>https://github.com/nextcloud/mail/blob/main/doc/developer.md</developer>
|
||||||
|
</documentation>
|
||||||
|
<category>social</category>
|
||||||
|
<category>office</category>
|
||||||
|
<website>https://github.com/nextcloud/mail#readme</website>
|
||||||
|
<bugs>https://github.com/nextcloud/mail/issues</bugs>
|
||||||
|
<repository type="git">https://github.com/nextcloud/mail.git</repository>
|
||||||
|
<screenshot>https://user-images.githubusercontent.com/1374172/79554966-278e1600-809f-11ea-82ea-7a0d72a2704f.png</screenshot>
|
||||||
|
<dependencies>
|
||||||
|
<php min-version="7.3" max-version="8.0" />
|
||||||
|
<nextcloud min-version="21" max-version="24" />
|
||||||
|
</dependencies>
|
||||||
|
<background-jobs>
|
||||||
|
<job>OCA\Mail\BackgroundJob\CleanupJob</job>
|
||||||
|
<job>OCA\Mail\BackgroundJob\OutboxWorkerJob</job>
|
||||||
|
</background-jobs>
|
||||||
|
<repair-steps>
|
||||||
|
<post-migration>
|
||||||
|
<step>OCA\Mail\Migration\AddMissingDefaultTags</step>
|
||||||
|
<step>OCA\Mail\Migration\AddMissingMessageIds</step>
|
||||||
|
<step>OCA\Mail\Migration\FixCollectedAddresses</step>
|
||||||
|
<step>OCA\Mail\Migration\FixBackgroundJobs</step>
|
||||||
|
<step>OCA\Mail\Migration\MakeItineraryExtractorExecutable</step>
|
||||||
|
<step>OCA\Mail\Migration\ProvisionAccounts</step>
|
||||||
|
<step>OCA\Mail\Migration\RepairMailTheads</step>
|
||||||
|
</post-migration>
|
||||||
|
</repair-steps>
|
||||||
|
<commands>
|
||||||
|
<command>OCA\Mail\Command\AddMissingTags</command>
|
||||||
|
<command>OCA\Mail\Command\CleanUp</command>
|
||||||
|
<command>OCA\Mail\Command\CreateAccount</command>
|
||||||
|
<command>OCA\Mail\Command\CreateTagMigrationJobEntry</command>
|
||||||
|
<command>OCA\Mail\Command\DeleteAccount</command>
|
||||||
|
<command>OCA\Mail\Command\DiagnoseAccount</command>
|
||||||
|
<command>OCA\Mail\Command\ExportAccount</command>
|
||||||
|
<command>OCA\Mail\Command\ExportAccountThreads</command>
|
||||||
|
<command>OCA\Mail\Command\SyncAccount</command>
|
||||||
|
<command>OCA\Mail\Command\TrainAccount</command>
|
||||||
|
<command>OCA\Mail\Command\Thread</command>
|
||||||
|
<command>OCA\Mail\Command\UpdateAccount</command>
|
||||||
|
</commands>
|
||||||
|
<settings>
|
||||||
|
<admin>OCA\Mail\Settings\AdminSettings</admin>
|
||||||
|
</settings>
|
||||||
|
<navigations>
|
||||||
|
<navigation>
|
||||||
|
<name>Mail</name>
|
||||||
|
<route>mail.page.index</route>
|
||||||
|
<icon>mail.svg</icon>
|
||||||
|
<order>3</order>
|
||||||
|
</navigation>
|
||||||
|
</navigations>
|
||||||
|
</info>
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||||
|
* @author Maadix
|
||||||
|
*
|
||||||
|
* Mail
|
||||||
|
*
|
||||||
|
* This code is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, version 3,
|
||||||
|
* as published by the Free Software Foundation.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace OCA\Mail\Command;
|
||||||
|
|
||||||
|
use OCA\Mail\Db\MailAccountMapper;
|
||||||
|
use OCP\Security\ICrypto;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class UpdateAccount extends Command {
|
||||||
|
public const ARGUMENT_USER_ID = 'user-id';
|
||||||
|
public const ARGUMENT_EMAIL = 'email';
|
||||||
|
public const ARGUMENT_IMAP_HOST = 'imap-host';
|
||||||
|
public const ARGUMENT_IMAP_PORT = 'imap-port';
|
||||||
|
public const ARGUMENT_IMAP_SSL_MODE = 'imap-ssl-mode';
|
||||||
|
public const ARGUMENT_IMAP_USER = 'imap-user';
|
||||||
|
public const ARGUMENT_IMAP_PASSWORD = 'imap-password';
|
||||||
|
public const ARGUMENT_SMTP_HOST = 'smtp-host';
|
||||||
|
public const ARGUMENT_SMTP_PORT = 'smtp-port';
|
||||||
|
public const ARGUMENT_SMTP_SSL_MODE = 'smtp-ssl-mode';
|
||||||
|
public const ARGUMENT_SMTP_USER = 'smtp-user';
|
||||||
|
public const ARGUMENT_SMTP_PASSWORD = 'smtp-password';
|
||||||
|
|
||||||
|
/** @var mapper */
|
||||||
|
private $mapper;
|
||||||
|
|
||||||
|
/** @var ICrypto */
|
||||||
|
private $crypto;
|
||||||
|
|
||||||
|
public function __construct(MailAccountMapper $mapper, ICrypto $crypto) {
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$this->mapper = $mapper;
|
||||||
|
$this->crypto = $crypto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function configure() {
|
||||||
|
$this->setName('mail:account:update');
|
||||||
|
$this->setDescription('Update a user\'s IMAP account');
|
||||||
|
$this->addArgument(self::ARGUMENT_USER_ID, InputArgument::REQUIRED);
|
||||||
|
$this->addArgument(self::ARGUMENT_EMAIL, InputArgument::REQUIRED);
|
||||||
|
|
||||||
|
$this->addOption(self::ARGUMENT_IMAP_HOST, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_IMAP_PORT, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_IMAP_SSL_MODE, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_IMAP_USER, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_IMAP_PASSWORD, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
|
||||||
|
$this->addOption(self::ARGUMENT_SMTP_HOST, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_SMTP_PORT, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_SMTP_SSL_MODE, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_SMTP_USER, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
$this->addOption(self::ARGUMENT_SMTP_PASSWORD, '', InputOption::VALUE_OPTIONAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$userId = $input->getArgument(self::ARGUMENT_USER_ID);
|
||||||
|
$email = $input->getArgument(self::ARGUMENT_EMAIL);
|
||||||
|
|
||||||
|
$imapHost = $input->getOption(self::ARGUMENT_IMAP_HOST);
|
||||||
|
$imapPort = $input->getOption(self::ARGUMENT_IMAP_PORT);
|
||||||
|
$imapSslMode = $input->getOption(self::ARGUMENT_IMAP_SSL_MODE);
|
||||||
|
$imapUser = $input->getOption(self::ARGUMENT_IMAP_USER);
|
||||||
|
$imapPassword = $input->getOption(self::ARGUMENT_IMAP_PASSWORD);
|
||||||
|
|
||||||
|
$smtpHost = $input->getOption(self::ARGUMENT_SMTP_HOST);
|
||||||
|
$smtpPort = $input->getOption(self::ARGUMENT_SMTP_PORT);
|
||||||
|
$smtpSslMode = $input->getOption(self::ARGUMENT_SMTP_SSL_MODE);
|
||||||
|
$smtpUser = $input->getOption(self::ARGUMENT_SMTP_USER);
|
||||||
|
$smtpPassword = $input->getOption(self::ARGUMENT_SMTP_PASSWORD);
|
||||||
|
|
||||||
|
$mailAccount = $this->mapper->findByUserIdAndEmail($userId, $email);
|
||||||
|
|
||||||
|
if ($mailAccount) {
|
||||||
|
//INBOUND
|
||||||
|
if ($input->getOption(self::ARGUMENT_IMAP_HOST)) {
|
||||||
|
$mailAccount->setInboundHost($imapHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_IMAP_PORT)) {
|
||||||
|
$mailAccount->setInboundPort((int) $imapPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_IMAP_SSL_MODE)) {
|
||||||
|
$mailAccount->setInboundSslMode($imapSslMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_IMAP_PASSWORD)) {
|
||||||
|
$mailAccount->setInboundPassword($this->crypto->encrypt($imapPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_USER)) {
|
||||||
|
$mailAccount->setInboundUser($imapUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OUTBOUND
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_HOST)) {
|
||||||
|
$mailAccount->setOutboundHost($smtpHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_PORT)) {
|
||||||
|
$mailAccount->setOutboundPort((int) $smtpPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_SSL_MODE)) {
|
||||||
|
$mailAccount->setOutboundSslMode($smtpSslMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_PASSWORD)) {
|
||||||
|
$mailAccount->setOutboundPassword($this->crypto->encrypt($smtpPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($input->getOption(self::ARGUMENT_SMTP_USER)) {
|
||||||
|
$mailAccount->setOutboundUser($smtpUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mapper->save($mailAccount);
|
||||||
|
|
||||||
|
$output->writeln("<info>Account $email for user $userId succesfully updated </info>");
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
$output->writeln("<info>No Email Account $email found for user $userId </info>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||||
|
* @author Christoph Wurst <wurst.christoph@gmail.com>
|
||||||
|
* @author Lukas Reschke <lukas@owncloud.com>
|
||||||
|
* @author Thomas Müller <thomas.mueller@tmit.eu>
|
||||||
|
*
|
||||||
|
* Mail
|
||||||
|
*
|
||||||
|
* This code is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, version 3,
|
||||||
|
* as published by the Free Software Foundation.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace OCA\Mail\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
|
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||||
|
use OCP\AppFramework\Db\QBMapper;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\IUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template-extends QBMapper<MailAccount>
|
||||||
|
*/
|
||||||
|
class MailAccountMapper extends QBMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param IDBConnection $db
|
||||||
|
*/
|
||||||
|
public function __construct(IDBConnection $db) {
|
||||||
|
parent::__construct($db, 'mail_accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds an Mail Account by id
|
||||||
|
*
|
||||||
|
* @param string $userId
|
||||||
|
* @param int $accountId
|
||||||
|
*
|
||||||
|
* @return MailAccount
|
||||||
|
*
|
||||||
|
* @throws DoesNotExistException
|
||||||
|
*/
|
||||||
|
public function find(string $userId, int $accountId): MailAccount {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
|
||||||
|
->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($accountId)));
|
||||||
|
|
||||||
|
return $this->findEntity($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an mail account by id
|
||||||
|
*
|
||||||
|
* @return MailAccount
|
||||||
|
* @throws DoesNotExistException
|
||||||
|
*/
|
||||||
|
public function findById(int $id): MailAccount {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
|
||||||
|
|
||||||
|
return $this->findEntity($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all Mail Accounts by user id existing for this user
|
||||||
|
*
|
||||||
|
* @param string $userId the id of the user that we want to find
|
||||||
|
*
|
||||||
|
* @return MailAccount[]
|
||||||
|
*/
|
||||||
|
public function findByUserId(string $userId): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
|
||||||
|
|
||||||
|
return $this->findEntities($query);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Finds an mail account by user id and email address
|
||||||
|
*
|
||||||
|
* @return MailAccount
|
||||||
|
* @throws DoesNotExistException
|
||||||
|
*/
|
||||||
|
public function findByUserIdAndEmail(string $userId, string $email): MailAccount {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
|
||||||
|
->andWhere($qb->expr()->eq('email', $qb->createNamedParameter($email, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
|
||||||
|
|
||||||
|
return $this->findEntity($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws DoesNotExistException
|
||||||
|
* @throws MultipleObjectsReturnedException
|
||||||
|
*/
|
||||||
|
public function findProvisionedAccount(IUser $user): MailAccount {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName())
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('user_id', $qb->createNamedParameter($user->getUID())),
|
||||||
|
$qb->expr()->isNotNull('provisioning_id')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->findEntity($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an User Account into the database
|
||||||
|
*
|
||||||
|
* @param MailAccount $account
|
||||||
|
*
|
||||||
|
* @return MailAccount
|
||||||
|
*/
|
||||||
|
public function save(MailAccount $account): MailAccount {
|
||||||
|
if ($account->getId() === null) {
|
||||||
|
return $this->insert($account);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->update($account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteProvisionedAccounts(int $provisioningId): void {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
|
||||||
|
$delete = $qb->delete($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('provisioning_id', $qb->createNamedParameter($provisioningId, IQueryBuilder::PARAM_INT)));
|
||||||
|
|
||||||
|
$delete->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteProvisionedAccountsByUid(string $uid): void {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
|
||||||
|
$delete = $qb->delete($this->getTableName())
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('user_id', $qb->createNamedParameter($uid)),
|
||||||
|
$qb->expr()->isNotNull('provisioning_id')
|
||||||
|
);
|
||||||
|
|
||||||
|
$delete->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllAccounts(): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->select('*')
|
||||||
|
->from($this->getTableName());
|
||||||
|
|
||||||
|
return $this->findEntities($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllUserIdsWithAccounts(): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$query = $qb
|
||||||
|
->selectDistinct('user_id')
|
||||||
|
->from($this->getTableName());
|
||||||
|
|
||||||
|
return $this->findEntities($query);
|
||||||
|
}
|
||||||
|
}
|
14
dd-ctl
14
dd-ctl
|
@ -306,6 +306,12 @@ setup_nextcloud(){
|
||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Temporary patch while upstream lands our changes
|
||||||
|
# See: https://github.com/nextcloud/mail/pull/6908
|
||||||
|
for f in appinfo/info.xml lib/Command/UpdateAccount.php lib/Db/MailAccountMapper.php; do
|
||||||
|
install -m 0644 -o 82 -g 82 "dd-apps/docker/nextcloud/nc_mail/$f" "${SRC_FOLDER}/nextcloud/custom_apps/mail/$f"
|
||||||
|
done
|
||||||
|
|
||||||
# Custom forms
|
# Custom forms
|
||||||
docker exec dd-apps-nextcloud-app apk add git npm composer
|
docker exec dd-apps-nextcloud-app apk add git npm composer
|
||||||
docker exec -u www-data dd-apps-nextcloud-app rm -rf /var/www/html/custom_apps/forms
|
docker exec -u www-data dd-apps-nextcloud-app rm -rf /var/www/html/custom_apps/forms
|
||||||
|
@ -476,11 +482,9 @@ saml_certificates(){
|
||||||
echo " --> Setting up SAML for wordpress"
|
echo " --> Setting up SAML for wordpress"
|
||||||
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py"
|
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py"
|
||||||
|
|
||||||
# SAML PLUGIN MOODLE
|
# SAML PLUGIN EMAIL
|
||||||
# echo "To add SAML to moodle:"
|
echo " --> Setting up SAML for email"
|
||||||
# echo "1.-Activate SAML plugin in moodle extensions, regenerate certificate, lock certificate"
|
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py"
|
||||||
# echo "2.-Then run: docker exec -ti dd-sso-admin python3 /admin/nextcloud_saml.py"
|
|
||||||
# echo "3.-"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_moodle(){
|
wait_for_moodle(){
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
secret
|
||||||
|
.dmypy.json
|
|
@ -0,0 +1,37 @@
|
||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
flask = "*"
|
||||||
|
flask-login = "*"
|
||||||
|
psycopg2 = "*"
|
||||||
|
minio = "*"
|
||||||
|
diceware = "*"
|
||||||
|
cerberus = "*"
|
||||||
|
pillow = "*"
|
||||||
|
schema = "*"
|
||||||
|
python-jose = "*"
|
||||||
|
flask-socketio = "*"
|
||||||
|
mysql-connector-python = "*"
|
||||||
|
eventlet = "*"
|
||||||
|
pyyaml = "*"
|
||||||
|
requests = "*"
|
||||||
|
python-keycloak = "*"
|
||||||
|
attrs = "*"
|
||||||
|
cryptography = "*"
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
mypy = "*"
|
||||||
|
black = "*"
|
||||||
|
isort = "*"
|
||||||
|
types-flask = "*"
|
||||||
|
types-requests = "*"
|
||||||
|
types-psycopg2 = "*"
|
||||||
|
types-pyyaml = "*"
|
||||||
|
types-python-jose = "*"
|
||||||
|
types-pillow = "*"
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.8"
|
|
@ -0,0 +1,877 @@
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"hash": {
|
||||||
|
"sha256": "8a5f88b027753cb1145b10e191326d6e9cfaa1c3333a773ac91071c3e7b7008c"
|
||||||
|
},
|
||||||
|
"pipfile-spec": 6,
|
||||||
|
"requires": {
|
||||||
|
"python_version": "3.8"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "pypi",
|
||||||
|
"url": "https://pypi.org/simple",
|
||||||
|
"verify_ssl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"attrs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6",
|
||||||
|
"sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==22.1.0"
|
||||||
|
},
|
||||||
|
"bidict": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:415126d23a0c81e1a8c584a8fb1f6905ea090c772571803aeee0a2242e8e7ba0",
|
||||||
|
"sha256:5c826b3e15e97cc6e615de295756847c282a79b79c5430d3bfc909b1ac9f5bd8"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==0.22.0"
|
||||||
|
},
|
||||||
|
"cerberus": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.3.4"
|
||||||
|
},
|
||||||
|
"certifi": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d",
|
||||||
|
"sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==2022.6.15"
|
||||||
|
},
|
||||||
|
"cffi": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5",
|
||||||
|
"sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef",
|
||||||
|
"sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104",
|
||||||
|
"sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426",
|
||||||
|
"sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405",
|
||||||
|
"sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375",
|
||||||
|
"sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a",
|
||||||
|
"sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e",
|
||||||
|
"sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc",
|
||||||
|
"sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf",
|
||||||
|
"sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185",
|
||||||
|
"sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497",
|
||||||
|
"sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3",
|
||||||
|
"sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35",
|
||||||
|
"sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c",
|
||||||
|
"sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83",
|
||||||
|
"sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21",
|
||||||
|
"sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca",
|
||||||
|
"sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984",
|
||||||
|
"sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac",
|
||||||
|
"sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd",
|
||||||
|
"sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee",
|
||||||
|
"sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a",
|
||||||
|
"sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2",
|
||||||
|
"sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192",
|
||||||
|
"sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7",
|
||||||
|
"sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585",
|
||||||
|
"sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f",
|
||||||
|
"sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e",
|
||||||
|
"sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27",
|
||||||
|
"sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b",
|
||||||
|
"sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e",
|
||||||
|
"sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e",
|
||||||
|
"sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d",
|
||||||
|
"sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c",
|
||||||
|
"sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415",
|
||||||
|
"sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82",
|
||||||
|
"sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02",
|
||||||
|
"sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314",
|
||||||
|
"sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325",
|
||||||
|
"sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c",
|
||||||
|
"sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3",
|
||||||
|
"sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914",
|
||||||
|
"sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045",
|
||||||
|
"sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d",
|
||||||
|
"sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9",
|
||||||
|
"sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5",
|
||||||
|
"sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2",
|
||||||
|
"sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c",
|
||||||
|
"sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3",
|
||||||
|
"sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2",
|
||||||
|
"sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8",
|
||||||
|
"sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d",
|
||||||
|
"sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d",
|
||||||
|
"sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9",
|
||||||
|
"sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162",
|
||||||
|
"sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76",
|
||||||
|
"sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4",
|
||||||
|
"sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e",
|
||||||
|
"sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9",
|
||||||
|
"sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6",
|
||||||
|
"sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b",
|
||||||
|
"sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01",
|
||||||
|
"sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"
|
||||||
|
],
|
||||||
|
"version": "==1.15.1"
|
||||||
|
},
|
||||||
|
"charset-normalizer": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5",
|
||||||
|
"sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==2.1.0"
|
||||||
|
},
|
||||||
|
"click": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
|
||||||
|
"sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==8.1.3"
|
||||||
|
},
|
||||||
|
"contextlib2": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f",
|
||||||
|
"sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==21.6.0"
|
||||||
|
},
|
||||||
|
"cryptography": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59",
|
||||||
|
"sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596",
|
||||||
|
"sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3",
|
||||||
|
"sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5",
|
||||||
|
"sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab",
|
||||||
|
"sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884",
|
||||||
|
"sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82",
|
||||||
|
"sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b",
|
||||||
|
"sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441",
|
||||||
|
"sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa",
|
||||||
|
"sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d",
|
||||||
|
"sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b",
|
||||||
|
"sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a",
|
||||||
|
"sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6",
|
||||||
|
"sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157",
|
||||||
|
"sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280",
|
||||||
|
"sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282",
|
||||||
|
"sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67",
|
||||||
|
"sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8",
|
||||||
|
"sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046",
|
||||||
|
"sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327",
|
||||||
|
"sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==37.0.4"
|
||||||
|
},
|
||||||
|
"diceware": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:09b62e491cc98ed569bdb51459e4523bbc3fa71b031a9c4c97f6dc93cab8c321",
|
||||||
|
"sha256:b2b4cc9b59f568d2ef51bfdf9f7e1af941d25fb8f5c25f170191dbbabce96569"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.10"
|
||||||
|
},
|
||||||
|
"dnspython": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e",
|
||||||
|
"sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6' and python_version < '4.0'",
|
||||||
|
"version": "==2.2.1"
|
||||||
|
},
|
||||||
|
"ecdsa": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49",
|
||||||
|
"sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==0.18.0"
|
||||||
|
},
|
||||||
|
"eventlet": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:a085922698e5029f820cf311a648ac324d73cec0e4792877609d978a4b5bbf31",
|
||||||
|
"sha256:afbe17f06a58491e9aebd7a4a03e70b0b63fd4cf76d8307bae07f280479b1515"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.33.1"
|
||||||
|
},
|
||||||
|
"flask": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:15972e5017df0575c3d6c090ba168b6db90259e620ac8d7ea813a396bad5b6cb",
|
||||||
|
"sha256:9013281a7402ad527f8fd56375164f3aa021ecfaff89bfe3825346c24f87e04c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.1.3"
|
||||||
|
},
|
||||||
|
"flask-login": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1ef79843f5eddd0f143c2cd994c1b05ac83c0401dc6234c143495af9a939613f",
|
||||||
|
"sha256:c0a7baa9fdc448cdd3dd6f0939df72eec5177b2f7abe6cb82fc934d29caac9c3"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.6.2"
|
||||||
|
},
|
||||||
|
"flask-socketio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:19c3d0cea49c53505fa457fedc133b32cb6eeaaa30d28cdab9d6ca8f16045427",
|
||||||
|
"sha256:c82672b843fa271ec2961d356c608bc94a730660ac73a623bddb66c4b3d72215"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==5.2.0"
|
||||||
|
},
|
||||||
|
"greenlet": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3",
|
||||||
|
"sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711",
|
||||||
|
"sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd",
|
||||||
|
"sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073",
|
||||||
|
"sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708",
|
||||||
|
"sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67",
|
||||||
|
"sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23",
|
||||||
|
"sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1",
|
||||||
|
"sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08",
|
||||||
|
"sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd",
|
||||||
|
"sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2",
|
||||||
|
"sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa",
|
||||||
|
"sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8",
|
||||||
|
"sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40",
|
||||||
|
"sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab",
|
||||||
|
"sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6",
|
||||||
|
"sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc",
|
||||||
|
"sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b",
|
||||||
|
"sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e",
|
||||||
|
"sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963",
|
||||||
|
"sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3",
|
||||||
|
"sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d",
|
||||||
|
"sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d",
|
||||||
|
"sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe",
|
||||||
|
"sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28",
|
||||||
|
"sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3",
|
||||||
|
"sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e",
|
||||||
|
"sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c",
|
||||||
|
"sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d",
|
||||||
|
"sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0",
|
||||||
|
"sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497",
|
||||||
|
"sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee",
|
||||||
|
"sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713",
|
||||||
|
"sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58",
|
||||||
|
"sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a",
|
||||||
|
"sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06",
|
||||||
|
"sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88",
|
||||||
|
"sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965",
|
||||||
|
"sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f",
|
||||||
|
"sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4",
|
||||||
|
"sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5",
|
||||||
|
"sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c",
|
||||||
|
"sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a",
|
||||||
|
"sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1",
|
||||||
|
"sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43",
|
||||||
|
"sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627",
|
||||||
|
"sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b",
|
||||||
|
"sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168",
|
||||||
|
"sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d",
|
||||||
|
"sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5",
|
||||||
|
"sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478",
|
||||||
|
"sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf",
|
||||||
|
"sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce",
|
||||||
|
"sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c",
|
||||||
|
"sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==1.1.2"
|
||||||
|
},
|
||||||
|
"idna": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||||
|
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==3.3"
|
||||||
|
},
|
||||||
|
"importlib-metadata": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670",
|
||||||
|
"sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"
|
||||||
|
],
|
||||||
|
"markers": "python_version < '3.10'",
|
||||||
|
"version": "==4.12.0"
|
||||||
|
},
|
||||||
|
"itsdangerous": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44",
|
||||||
|
"sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==2.1.2"
|
||||||
|
},
|
||||||
|
"jinja2": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
|
||||||
|
"sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==3.1.2"
|
||||||
|
},
|
||||||
|
"markupsafe": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
|
||||||
|
"sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88",
|
||||||
|
"sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5",
|
||||||
|
"sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7",
|
||||||
|
"sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a",
|
||||||
|
"sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603",
|
||||||
|
"sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1",
|
||||||
|
"sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135",
|
||||||
|
"sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247",
|
||||||
|
"sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6",
|
||||||
|
"sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601",
|
||||||
|
"sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77",
|
||||||
|
"sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02",
|
||||||
|
"sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e",
|
||||||
|
"sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63",
|
||||||
|
"sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f",
|
||||||
|
"sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980",
|
||||||
|
"sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b",
|
||||||
|
"sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812",
|
||||||
|
"sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff",
|
||||||
|
"sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96",
|
||||||
|
"sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1",
|
||||||
|
"sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925",
|
||||||
|
"sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a",
|
||||||
|
"sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6",
|
||||||
|
"sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e",
|
||||||
|
"sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f",
|
||||||
|
"sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4",
|
||||||
|
"sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f",
|
||||||
|
"sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3",
|
||||||
|
"sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c",
|
||||||
|
"sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a",
|
||||||
|
"sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417",
|
||||||
|
"sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a",
|
||||||
|
"sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a",
|
||||||
|
"sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37",
|
||||||
|
"sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452",
|
||||||
|
"sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933",
|
||||||
|
"sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a",
|
||||||
|
"sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==2.1.1"
|
||||||
|
},
|
||||||
|
"minio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:12ac2d1d4fd3cea159d625847445e1bfceba3fbc2f4ab692c2d2bf716f82246c",
|
||||||
|
"sha256:1cab424275749b8b5b8bb0c6cc856d667305ef549796ae56f3237fe55306a1fc"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==7.1.11"
|
||||||
|
},
|
||||||
|
"mysql-connector-python": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1d9d3af14594aceda2c3096564b4c87ffac21e375806a802daeaf7adcd18d36b",
|
||||||
|
"sha256:234c6b156a1989bebca6eb564dc8f2e9d352f90a51bd228ccd68eb66fcd5fd7a",
|
||||||
|
"sha256:33c4e567547a9a1868462fda8f2b19ea186a7b1afe498171dca39c0f3aa43a75",
|
||||||
|
"sha256:36e763f21e62b3c9623a264f2513ee11924ea1c9cc8640c115a279d3087064be",
|
||||||
|
"sha256:41a04d1900e366bf6c2a645ead89ab9a567806d5ada7d417a3a31f170321dd14",
|
||||||
|
"sha256:47deb8c3324db7eb2bfb720ec8084d547b1bce457672ea261bc21836024249db",
|
||||||
|
"sha256:59a8592e154c874c299763bb8aa12c518384c364bcfd0d193e85c869ea81a895",
|
||||||
|
"sha256:611c6945805216104575f7143ff6497c87396ce82d3257e6da7257b65406f13e",
|
||||||
|
"sha256:62266d1b18cb4e286a05df0e1c99163a4955c82d41045305bcf0ab2aac107843",
|
||||||
|
"sha256:712cdfa97f35fec715e8d7aaa15ed9ce04f3cf71b3c177fcca273047040de9f2",
|
||||||
|
"sha256:7f771bd5cba3ade6d9f7a649e65d7c030f69f0e69980632b5cbbd3d19c39cee5",
|
||||||
|
"sha256:8876b1d51cae33cdfe7021d68206661e94dcd2666e5e14a743f8321e2b068e84",
|
||||||
|
"sha256:8b7d50c221320b0e609dce9ca8801ab2f2a748dfee65cd76b1e4c6940757734a",
|
||||||
|
"sha256:954a1fc2e9a811662c5b17cea24819c020ff9d56b2ff8e583dd0a233fb2399f6",
|
||||||
|
"sha256:a130c5489861c7ff2990e5b503c37beb2fb7b32211b92f9107ad864ee90654c0",
|
||||||
|
"sha256:b5dc0f3295e404f93b674bfaff7589a9fbb8b5ae6c1c134112a1d1beb2f664b2",
|
||||||
|
"sha256:ce23ca9c27e1f7b4707b3299ce515125f312736d86a7e5b2aa778484fa3ffa10",
|
||||||
|
"sha256:d8f74c9388176635f75c01d47d0abc783a47e58d7f36d04fb6ee40ab6fb35c9b",
|
||||||
|
"sha256:f1d40cac9c786e292433716c1ade7a8968cbc3ea177026697b86a63188ddba34",
|
||||||
|
"sha256:f1eb74eb30bb04ff314f5e19af5421d23b504e41d16ddcee2603b4100d18fd68",
|
||||||
|
"sha256:f5d812245754d4759ebc8c075662fef65397e1e2a438a3c391eac9d545077b8b"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==8.0.30"
|
||||||
|
},
|
||||||
|
"pillow": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927",
|
||||||
|
"sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14",
|
||||||
|
"sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc",
|
||||||
|
"sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58",
|
||||||
|
"sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60",
|
||||||
|
"sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76",
|
||||||
|
"sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c",
|
||||||
|
"sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac",
|
||||||
|
"sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490",
|
||||||
|
"sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1",
|
||||||
|
"sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f",
|
||||||
|
"sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d",
|
||||||
|
"sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f",
|
||||||
|
"sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069",
|
||||||
|
"sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402",
|
||||||
|
"sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885",
|
||||||
|
"sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e",
|
||||||
|
"sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be",
|
||||||
|
"sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8",
|
||||||
|
"sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff",
|
||||||
|
"sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da",
|
||||||
|
"sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004",
|
||||||
|
"sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f",
|
||||||
|
"sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20",
|
||||||
|
"sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d",
|
||||||
|
"sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c",
|
||||||
|
"sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544",
|
||||||
|
"sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9",
|
||||||
|
"sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3",
|
||||||
|
"sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04",
|
||||||
|
"sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c",
|
||||||
|
"sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5",
|
||||||
|
"sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4",
|
||||||
|
"sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb",
|
||||||
|
"sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4",
|
||||||
|
"sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c",
|
||||||
|
"sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467",
|
||||||
|
"sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e",
|
||||||
|
"sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421",
|
||||||
|
"sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b",
|
||||||
|
"sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8",
|
||||||
|
"sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb",
|
||||||
|
"sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3",
|
||||||
|
"sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf",
|
||||||
|
"sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1",
|
||||||
|
"sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a",
|
||||||
|
"sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28",
|
||||||
|
"sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0",
|
||||||
|
"sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1",
|
||||||
|
"sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8",
|
||||||
|
"sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd",
|
||||||
|
"sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4",
|
||||||
|
"sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8",
|
||||||
|
"sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f",
|
||||||
|
"sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013",
|
||||||
|
"sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59",
|
||||||
|
"sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc",
|
||||||
|
"sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==9.2.0"
|
||||||
|
},
|
||||||
|
"protobuf": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf",
|
||||||
|
"sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f",
|
||||||
|
"sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f",
|
||||||
|
"sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7",
|
||||||
|
"sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996",
|
||||||
|
"sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067",
|
||||||
|
"sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c",
|
||||||
|
"sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7",
|
||||||
|
"sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9",
|
||||||
|
"sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c",
|
||||||
|
"sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739",
|
||||||
|
"sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91",
|
||||||
|
"sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c",
|
||||||
|
"sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153",
|
||||||
|
"sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9",
|
||||||
|
"sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388",
|
||||||
|
"sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e",
|
||||||
|
"sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab",
|
||||||
|
"sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde",
|
||||||
|
"sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531",
|
||||||
|
"sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8",
|
||||||
|
"sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7",
|
||||||
|
"sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20",
|
||||||
|
"sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==3.20.1"
|
||||||
|
},
|
||||||
|
"psycopg2": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c",
|
||||||
|
"sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf",
|
||||||
|
"sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362",
|
||||||
|
"sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7",
|
||||||
|
"sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461",
|
||||||
|
"sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126",
|
||||||
|
"sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981",
|
||||||
|
"sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56",
|
||||||
|
"sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305",
|
||||||
|
"sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2",
|
||||||
|
"sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.9.3"
|
||||||
|
},
|
||||||
|
"pyasn1": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
|
||||||
|
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
|
||||||
|
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
|
||||||
|
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
|
||||||
|
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
|
||||||
|
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
|
||||||
|
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
|
||||||
|
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
|
||||||
|
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
|
||||||
|
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
|
||||||
|
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
|
||||||
|
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
|
||||||
|
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
|
||||||
|
],
|
||||||
|
"version": "==0.4.8"
|
||||||
|
},
|
||||||
|
"pycparser": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
|
||||||
|
"sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
|
||||||
|
],
|
||||||
|
"version": "==2.21"
|
||||||
|
},
|
||||||
|
"python-engineio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:18474c452894c60590b2d2339d6c81b93fb9857f1be271a2e91fb2707eb4095d",
|
||||||
|
"sha256:e660fcbac7497f105310d933987d3a82d2e677240a6b493c0a514aa7f91d3b07"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==4.3.3"
|
||||||
|
},
|
||||||
|
"python-jose": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a",
|
||||||
|
"sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==3.3.0"
|
||||||
|
},
|
||||||
|
"python-keycloak": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:140d95ca08a2acae1f4cdaf6130bbba3ba3309f059326f7bebd87f150561c4b4",
|
||||||
|
"sha256:3b504ef117f2192197732a01041f4c2c14d324baf2069f5a97533e11191cf3e6"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.1.1"
|
||||||
|
},
|
||||||
|
"python-socketio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:5011a0cd2545c954d7df09eef7489ec424c93b001cc146599cd72f1dd20f0d46",
|
||||||
|
"sha256:86ee93591c1e781d339d9a61940e62fd6cbc838390653b52a7bcc4f7ce89fe47"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==5.7.1"
|
||||||
|
},
|
||||||
|
"pyyaml": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
|
||||||
|
"sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b",
|
||||||
|
"sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57",
|
||||||
|
"sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b",
|
||||||
|
"sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4",
|
||||||
|
"sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07",
|
||||||
|
"sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba",
|
||||||
|
"sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9",
|
||||||
|
"sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287",
|
||||||
|
"sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513",
|
||||||
|
"sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0",
|
||||||
|
"sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0",
|
||||||
|
"sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92",
|
||||||
|
"sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f",
|
||||||
|
"sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2",
|
||||||
|
"sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc",
|
||||||
|
"sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c",
|
||||||
|
"sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86",
|
||||||
|
"sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4",
|
||||||
|
"sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c",
|
||||||
|
"sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34",
|
||||||
|
"sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b",
|
||||||
|
"sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c",
|
||||||
|
"sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb",
|
||||||
|
"sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737",
|
||||||
|
"sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3",
|
||||||
|
"sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d",
|
||||||
|
"sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53",
|
||||||
|
"sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78",
|
||||||
|
"sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803",
|
||||||
|
"sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a",
|
||||||
|
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
|
||||||
|
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==6.0"
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983",
|
||||||
|
"sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.28.1"
|
||||||
|
},
|
||||||
|
"requests-toolbelt": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
|
||||||
|
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
|
||||||
|
],
|
||||||
|
"version": "==0.9.1"
|
||||||
|
},
|
||||||
|
"rsa": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7",
|
||||||
|
"sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6' and python_version < '4.0'",
|
||||||
|
"version": "==4.9"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197",
|
||||||
|
"sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.7.5"
|
||||||
|
},
|
||||||
|
"setuptools": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0d33c374d41c7863419fc8f6c10bfe25b7b498aa34164d135c622e52580c6b16",
|
||||||
|
"sha256:c04b44a57a6265fe34a4a444e965884716d34bae963119a76353434d6f18e450"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==63.2.0"
|
||||||
|
},
|
||||||
|
"six": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||||
|
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==1.16.0"
|
||||||
|
},
|
||||||
|
"urllib3": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc",
|
||||||
|
"sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4.0'",
|
||||||
|
"version": "==1.26.11"
|
||||||
|
},
|
||||||
|
"werkzeug": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4d7013ef96fd197d1cdeb03e066c6c5a491ccb44758a5b2b91137319383e5a5a",
|
||||||
|
"sha256:7e1db6a5ba6b9a8be061e47e900456355b8714c0f238b0313f53afce1a55a79a"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==2.2.1"
|
||||||
|
},
|
||||||
|
"zipp": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2",
|
||||||
|
"sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==3.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"develop": {
|
||||||
|
"black": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90",
|
||||||
|
"sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c",
|
||||||
|
"sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78",
|
||||||
|
"sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4",
|
||||||
|
"sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee",
|
||||||
|
"sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e",
|
||||||
|
"sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e",
|
||||||
|
"sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6",
|
||||||
|
"sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9",
|
||||||
|
"sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c",
|
||||||
|
"sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256",
|
||||||
|
"sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f",
|
||||||
|
"sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2",
|
||||||
|
"sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c",
|
||||||
|
"sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b",
|
||||||
|
"sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807",
|
||||||
|
"sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf",
|
||||||
|
"sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def",
|
||||||
|
"sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad",
|
||||||
|
"sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d",
|
||||||
|
"sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849",
|
||||||
|
"sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69",
|
||||||
|
"sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==22.6.0"
|
||||||
|
},
|
||||||
|
"click": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
|
||||||
|
"sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==8.1.3"
|
||||||
|
},
|
||||||
|
"isort": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7",
|
||||||
|
"sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==5.10.1"
|
||||||
|
},
|
||||||
|
"mypy": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655",
|
||||||
|
"sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9",
|
||||||
|
"sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3",
|
||||||
|
"sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6",
|
||||||
|
"sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0",
|
||||||
|
"sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58",
|
||||||
|
"sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103",
|
||||||
|
"sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09",
|
||||||
|
"sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417",
|
||||||
|
"sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56",
|
||||||
|
"sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2",
|
||||||
|
"sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856",
|
||||||
|
"sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0",
|
||||||
|
"sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8",
|
||||||
|
"sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27",
|
||||||
|
"sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5",
|
||||||
|
"sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71",
|
||||||
|
"sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27",
|
||||||
|
"sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe",
|
||||||
|
"sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca",
|
||||||
|
"sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf",
|
||||||
|
"sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9",
|
||||||
|
"sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.971"
|
||||||
|
},
|
||||||
|
"mypy-extensions": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
|
||||||
|
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
|
||||||
|
],
|
||||||
|
"version": "==0.4.3"
|
||||||
|
},
|
||||||
|
"pathspec": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a",
|
||||||
|
"sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"
|
||||||
|
],
|
||||||
|
"version": "==0.9.0"
|
||||||
|
},
|
||||||
|
"platformdirs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788",
|
||||||
|
"sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==2.5.2"
|
||||||
|
},
|
||||||
|
"tomli": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
|
||||||
|
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
|
||||||
|
],
|
||||||
|
"markers": "python_version < '3.11'",
|
||||||
|
"version": "==2.0.1"
|
||||||
|
},
|
||||||
|
"types-click": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81",
|
||||||
|
"sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"
|
||||||
|
],
|
||||||
|
"version": "==7.1.8"
|
||||||
|
},
|
||||||
|
"types-flask": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087",
|
||||||
|
"sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.1.6"
|
||||||
|
},
|
||||||
|
"types-jinja2": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2",
|
||||||
|
"sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"
|
||||||
|
],
|
||||||
|
"version": "==2.11.9"
|
||||||
|
},
|
||||||
|
"types-markupsafe": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1",
|
||||||
|
"sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"
|
||||||
|
],
|
||||||
|
"version": "==1.1.10"
|
||||||
|
},
|
||||||
|
"types-pillow": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:9781104ee2176f680576523fa2a2b83b134957aec6f4d62582cc9e74c93a60b4",
|
||||||
|
"sha256:d63743ef631e47f8d8669590ea976162321a9a7604588b424b6306533453fb63"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==9.2.1"
|
||||||
|
},
|
||||||
|
"types-psycopg2": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:14c779dcab18c31453fa1cad3cf4b1601d33540a344adead3c47a6b8091cd2fa",
|
||||||
|
"sha256:9b0e9e1f097b15cd9fa8aad2596a9e3082fd72f8d9cfe52b190cfa709105b6c0"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.9.18"
|
||||||
|
},
|
||||||
|
"types-python-jose": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:04fb427bc906e864e8c313db469920f71ffa85839b43fe355f7dc861330c6da3",
|
||||||
|
"sha256:a8331dd72da5adf03853d4c003d08d0621dd9eb7b7333571b04d81058c55fac6"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==3.3.4"
|
||||||
|
},
|
||||||
|
"types-pyyaml": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7f7da2fd11e9bc1e5e9eb3ea1be84f4849747017a59fc2eee0ea34ed1147c2e0",
|
||||||
|
"sha256:8f890028123607379c63550179ddaec4517dc751f4c527a52bb61934bf495989"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==6.0.11"
|
||||||
|
},
|
||||||
|
"types-requests": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:98ab647ae88b5e2c41d6d20cfcb5117da1bea561110000b6fdeeea07b3e89877",
|
||||||
|
"sha256:ac618bfefcb3742eaf97c961e13e9e5a226e545eda4a3dbe293b898d40933ad1"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.28.5"
|
||||||
|
},
|
||||||
|
"types-urllib3": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:0d027fcd27dbb3cb532453b4d977e05bc1e13aefd70519866af211b3003d895d",
|
||||||
|
"sha256:73fd274524c3fc7cd8cd9ceb0cb67ed99b45f9cb2831013e46d50c1451044800"
|
||||||
|
],
|
||||||
|
"version": "==1.26.17"
|
||||||
|
},
|
||||||
|
"types-werkzeug": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec",
|
||||||
|
"sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"
|
||||||
|
],
|
||||||
|
"version": "==1.0.9"
|
||||||
|
},
|
||||||
|
"typing-extensions": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02",
|
||||||
|
"sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==4.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,19 +17,23 @@
|
||||||
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
Flask==2.0.1
|
attrs==22.1.0
|
||||||
Flask-Login==0.5.0
|
cryptography==37.0.4
|
||||||
eventlet==0.33.0
|
Flask==2.1.3
|
||||||
Flask-SocketIO==5.1.0
|
Flask-Login==0.6.2
|
||||||
bcrypt==3.2.0
|
eventlet==0.33.1
|
||||||
|
Flask-SocketIO==5.2.0
|
||||||
|
bcrypt==3.2.2
|
||||||
|
# diceware can't be upgraded without issues
|
||||||
diceware==0.9.6
|
diceware==0.9.6
|
||||||
mysql-connector-python==8.0.25
|
mysql-connector-python==8.0.30
|
||||||
psycopg2==2.8.6
|
psycopg2==2.9.3
|
||||||
|
# python-keycloak can't be upgraded without issues
|
||||||
python-keycloak==0.26.1
|
python-keycloak==0.26.1
|
||||||
minio==7.0.3
|
minio==7.1.11
|
||||||
urllib3==1.26.6
|
urllib3==1.26.11
|
||||||
schema==0.7.5
|
schema==0.7.5
|
||||||
Werkzeug~=2.0.0
|
Werkzeug==2.2.1
|
||||||
python-jose==3.3.0
|
python-jose==3.3.0
|
||||||
Cerberus==1.3.4
|
Cerberus==1.3.4
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
[mypy]
|
||||||
|
namespace_packages=True
|
||||||
|
#explicit_package_bases=True
|
||||||
|
#TODO: progress so we can enable this
|
||||||
|
#strict=True
|
||||||
|
#
|
||||||
|
check_untyped_defs=True
|
||||||
|
disallow_untyped_defs=True
|
||||||
|
warn_redundant_casts=True
|
||||||
|
warn_unused_configs= True
|
||||||
|
warn_unused_ignores = True
|
||||||
|
warn_no_return=True
|
||||||
|
warn_return_any=True
|
||||||
|
warn_unreachable=True
|
||||||
|
enable_error_code=unused-awaitable
|
||||||
|
|
||||||
|
[mypy-cerberus.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-diceware.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-eventlet.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-flask_login.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-flask_socketio.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-keycloak.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-minio.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-mysql.*]
|
||||||
|
ignore_missing_imports=True
|
||||||
|
[mypy-schema.*]
|
||||||
|
ignore_missing_imports=True
|
|
@ -0,0 +1,2 @@
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -20,107 +21,23 @@
|
||||||
|
|
||||||
import logging as log
|
import logging as log
|
||||||
import os
|
import os
|
||||||
|
import os.path
|
||||||
|
|
||||||
from flask import Flask, render_template, send_from_directory
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
app = Flask(__name__, static_url_path="")
|
def get_app() -> AdminFlaskApp:
|
||||||
app = Flask(__name__, template_folder="static/templates")
|
app = AdminFlaskApp(__name__, template_folder="static/templates")
|
||||||
app.url_map.strict_slashes = False
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
App secret key for encrypting cookies
|
Debug should be removed on production!
|
||||||
You can generate one with:
|
"""
|
||||||
import os
|
if app.debug:
|
||||||
os.urandom(24)
|
log.warning("Debug mode: {}".format(app.debug))
|
||||||
And paste it here.
|
else:
|
||||||
"""
|
log.info("Debug mode: {}".format(app.debug))
|
||||||
app.secret_key = "Change this key!/\xf7\x83\xbe\x17\xfa\xa3zT\n\\]m\xa6\x8bF\xdd\r\xf7\x9e\x1d\x1f\x14'"
|
|
||||||
|
|
||||||
print("Starting dd-sso api...")
|
return app
|
||||||
|
|
||||||
from admin.lib.load_config import loadConfig
|
|
||||||
|
|
||||||
try:
|
|
||||||
loadConfig(app)
|
|
||||||
except:
|
|
||||||
print("Could not get environment variables...")
|
|
||||||
|
|
||||||
from admin.lib.postup import Postup
|
|
||||||
|
|
||||||
Postup()
|
|
||||||
|
|
||||||
from admin.lib.admin import Admin
|
|
||||||
|
|
||||||
app.admin = Admin()
|
|
||||||
|
|
||||||
app.ready = False
|
|
||||||
|
|
||||||
"""
|
|
||||||
Debug should be removed on production!
|
|
||||||
"""
|
|
||||||
if app.debug:
|
|
||||||
log.warning("Debug mode: {}".format(app.debug))
|
|
||||||
else:
|
|
||||||
log.info("Debug mode: {}".format(app.debug))
|
|
||||||
|
|
||||||
"""
|
|
||||||
Serve static files
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/build/<path:path>")
|
|
||||||
def send_build(path):
|
|
||||||
return send_from_directory(
|
|
||||||
os.path.join(app.root_path, "node_modules/gentelella/build"), path
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/vendors/<path:path>")
|
|
||||||
def send_vendors(path):
|
|
||||||
return send_from_directory(
|
|
||||||
os.path.join(app.root_path, "node_modules/gentelella/vendors"), path
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/node_modules/<path:path>")
|
|
||||||
def send_nodes(path):
|
|
||||||
return send_from_directory(os.path.join(app.root_path, "node_modules"), path)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/templates/<path:path>")
|
|
||||||
def send_templates(path):
|
|
||||||
return send_from_directory(os.path.join(app.root_path, "templates"), path)
|
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/templates/<path:path>')
|
|
||||||
# def send_templates(path):
|
|
||||||
# return send_from_directory(os.path.join(app.root_path, 'static/templates'), path)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def send_static_js(path):
|
|
||||||
return send_from_directory(os.path.join(app.root_path, "static"), path)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/avatars/<path:path>")
|
|
||||||
def send_avatars_img(path):
|
|
||||||
return send_from_directory(
|
|
||||||
os.path.join(app.root_path, "../avatars/master-avatars"), path
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/custom/<path:path>")
|
|
||||||
def send_custom(path):
|
|
||||||
return send_from_directory(os.path.join(app.root_path, "../custom"), path)
|
|
||||||
|
|
||||||
|
|
||||||
# @app.errorhandler(404)
|
|
||||||
# def not_found_error(error):
|
|
||||||
# return render_template('page_404.html'), 404
|
|
||||||
|
|
||||||
# @app.errorhandler(500)
|
|
||||||
# def internal_error(error):
|
|
||||||
# return render_template('page_500.html'), 500
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Import all views
|
Import all views
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -21,31 +22,9 @@ import os
|
||||||
|
|
||||||
from flask_login import LoginManager, UserMixin
|
from flask_login import LoginManager, UserMixin
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, Dict
|
||||||
|
if TYPE_CHECKING:
|
||||||
""" OIDC TESTS """
|
from admin.flaskapp import AdminFlaskApp
|
||||||
# from flask_oidc import OpenIDConnect
|
|
||||||
# app.config.update({
|
|
||||||
# 'SECRET_KEY': 'u\x91\xcf\xfa\x0c\xb9\x95\xe3t\xba2K\x7f\xfd\xca\xa3\x9f\x90\x88\xb8\xee\xa4\xd6\xe4',
|
|
||||||
# 'TESTING': True,
|
|
||||||
# 'DEBUG': True,
|
|
||||||
# 'OIDC_CLIENT_SECRETS': 'client_secrets.json',
|
|
||||||
# 'OIDC_ID_TOKEN_COOKIE_SECURE': False,
|
|
||||||
# 'OIDC_REQUIRE_VERIFIED_EMAIL': False,
|
|
||||||
# 'OIDC_VALID_ISSUERS': ['https://sso.mydomain.duckdns.org:8080/auth/realms/master'],
|
|
||||||
# 'OIDC_OPENID_REALM': 'https://sso.mydomain.duckdns.org//custom_callback',
|
|
||||||
# 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
|
|
||||||
# })
|
|
||||||
# # 'OVERWRITE_REDIRECT_URI': 'https://sso.mydomain.duckdns.org//custom_callback',
|
|
||||||
# # 'OIDC_CALLBACK_ROUTE': '//custom_callback'
|
|
||||||
# oidc = OpenIDConnect(app)
|
|
||||||
""" OIDC TESTS """
|
|
||||||
|
|
||||||
|
|
||||||
login_manager = LoginManager()
|
|
||||||
login_manager.init_app(app)
|
|
||||||
login_manager.login_view = "login"
|
|
||||||
|
|
||||||
|
|
||||||
ram_users = {
|
ram_users = {
|
||||||
os.environ["ADMINAPP_USER"]: {
|
os.environ["ADMINAPP_USER"]: {
|
||||||
|
@ -67,13 +46,19 @@ ram_users = {
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin):
|
class User(UserMixin):
|
||||||
def __init__(self, dict):
|
def __init__(self, id : str, password : str, role : str, active : bool = True) -> None:
|
||||||
self.id = dict["id"]
|
self.id = id
|
||||||
self.username = dict["id"]
|
self.username = id
|
||||||
self.password = dict["password"]
|
self.password = password
|
||||||
self.role = dict["role"]
|
self.role = role
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def setup_auth(app : "AdminFlaskApp") -> None:
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = "login"
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def user_loader(username):
|
def user_loader(username : str) -> User:
|
||||||
return User(ram_users[username])
|
u = ram_users[username]
|
||||||
|
return User(id = u["id"], password = u["password"], role = u["role"])
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2022 MaadiX
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from functools import wraps
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
from jose import jwt
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
from admin.lib.api_exceptions import Error
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
|
|
||||||
|
def has_jws_token(
|
||||||
|
app: "AdminFlaskApp", *args: Any, **kwargs: Any
|
||||||
|
) -> Callable[..., Response]:
|
||||||
|
@wraps
|
||||||
|
def decorated(fn: Callable[..., Response]) -> Response:
|
||||||
|
get_jws_payload(app)
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def get_jws_payload(app: "AdminFlaskApp") -> Tuple[str, Dict]:
|
||||||
|
"""
|
||||||
|
Try to parse the Authorization header into a JWT.
|
||||||
|
By getting the key ID from the unverified headers, try to verify the token
|
||||||
|
with the associated key.
|
||||||
|
|
||||||
|
For pragmatism it returns a tuple of the (key_id, jwt_claims).
|
||||||
|
"""
|
||||||
|
kid: str = ""
|
||||||
|
try:
|
||||||
|
t: str = request.headers.get("Authorization", "")
|
||||||
|
# Get which KeyId we have to use.
|
||||||
|
# We default to 'empty', so a missing 'kid' is not an issue in an on
|
||||||
|
# itself.
|
||||||
|
# This enables using an empty key in app.api_3p as the default.
|
||||||
|
# That default is better managed in AdminFlaskApp and not here.
|
||||||
|
kid = jwt.get_unverified_headers(t).get("kid", "")
|
||||||
|
except:
|
||||||
|
raise Error(
|
||||||
|
"unauthorized", "Token is missing or malformed", traceback.format_stack()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Try to get payload
|
||||||
|
return (kid, app.api_3p[kid].verify_incoming_data(t))
|
||||||
|
except:
|
||||||
|
raise Error("forbidden", "Data verification failed", traceback.format_stack())
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -30,17 +31,18 @@ from functools import wraps
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
import jose.exceptions
|
||||||
|
|
||||||
from admin import app
|
from typing import Any
|
||||||
|
|
||||||
from ..lib.api_exceptions import Error
|
from admin.lib.api_exceptions import Error
|
||||||
|
|
||||||
|
|
||||||
def get_header_jwt_payload():
|
def get_header_jwt_payload() -> Any:
|
||||||
return get_token_payload(get_token_auth_header())
|
return get_token_payload(get_token_auth_header())
|
||||||
|
|
||||||
|
|
||||||
def get_token_header(header):
|
def get_token_header(header : str) -> str:
|
||||||
"""Obtains the Access Token from the a Header"""
|
"""Obtains the Access Token from the a Header"""
|
||||||
auth = request.headers.get(header, None)
|
auth = request.headers.get(header, None)
|
||||||
if not auth:
|
if not auth:
|
||||||
|
@ -70,15 +72,15 @@ def get_token_header(header):
|
||||||
return parts[1] # Token
|
return parts[1] # Token
|
||||||
|
|
||||||
|
|
||||||
def get_token_auth_header():
|
def get_token_auth_header() -> str:
|
||||||
return get_token_header("Authorization")
|
return get_token_header("Authorization")
|
||||||
|
|
||||||
|
|
||||||
def get_token_payload(token):
|
def get_token_payload(token : str) -> Any:
|
||||||
# log.warning("The received token in get_token_payload is: " + str(token))
|
# log.warning("The received token in get_token_payload is: " + str(token))
|
||||||
try:
|
try:
|
||||||
claims = jwt.get_unverified_claims(token)
|
claims = jwt.get_unverified_claims(token)
|
||||||
secret = app.config["API_SECRET"]
|
secret = os.environ["API_SECRET"]
|
||||||
|
|
||||||
except:
|
except:
|
||||||
log.warning(
|
log.warning(
|
||||||
|
@ -97,11 +99,11 @@ def get_token_payload(token):
|
||||||
algorithms=["HS256"],
|
algorithms=["HS256"],
|
||||||
options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
|
options=dict(verify_aud=False, verify_sub=False, verify_exp=True),
|
||||||
)
|
)
|
||||||
except jwt.ExpiredSignatureError:
|
except jose.exceptions.ExpiredSignatureError:
|
||||||
log.warning("Token expired")
|
log.warning("Token expired")
|
||||||
raise Error("unauthorized", "Token is expired", traceback.format_stack())
|
raise Error("unauthorized", "Token is expired", traceback.format_stack())
|
||||||
|
|
||||||
except jwt.JWTClaimsError:
|
except jose.exceptions.JWTClaimsError:
|
||||||
raise Error(
|
raise Error(
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
"Incorrect claims, please check the audience and issuer",
|
"Incorrect claims, please check the audience and issuer",
|
||||||
|
|
|
@ -0,0 +1,255 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import logging as log
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import secrets
|
||||||
|
import traceback
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from cerberus import Validator
|
||||||
|
from flask import Flask, Response, jsonify, render_template, send_from_directory
|
||||||
|
|
||||||
|
from admin.lib.api_exceptions import Error
|
||||||
|
from admin.lib.keys import ThirdPartyIntegrationKeys
|
||||||
|
from admin.views.decorators import OptionalJsonResponse
|
||||||
|
from admin.views.ApiViews import setup_api_views
|
||||||
|
from admin.views.AppViews import setup_app_views
|
||||||
|
from admin.views.LoginViews import setup_login_views
|
||||||
|
from admin.views.WebViews import setup_web_views
|
||||||
|
from admin.views.WpViews import setup_wp_views
|
||||||
|
from admin.auth.authentication import setup_auth
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.lib.admin import Admin
|
||||||
|
from admin.lib.postup import Postup
|
||||||
|
|
||||||
|
|
||||||
|
class AdminValidator(Validator): # type: ignore # cerberus type hints MIA
|
||||||
|
# TODO: What's the point of this class?
|
||||||
|
None
|
||||||
|
# def _normalize_default_setter_genid(self, document):
|
||||||
|
# return _parse_string(document["name"])
|
||||||
|
|
||||||
|
# def _normalize_default_setter_genidlower(self, document):
|
||||||
|
# return _parse_string(document["name"]).lower()
|
||||||
|
|
||||||
|
# def _normalize_default_setter_gengroupid(self, document):
|
||||||
|
# return _parse_string(
|
||||||
|
# document["parent_category"] + "-" + document["uid"]
|
||||||
|
# ).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminFlaskApp(Flask):
|
||||||
|
"""
|
||||||
|
Subclass Flask app to ease customisation and type checking.
|
||||||
|
|
||||||
|
In order for an instance of this class to be useful,
|
||||||
|
the setup method should be called after instantiating.
|
||||||
|
"""
|
||||||
|
|
||||||
|
admin: "Admin"
|
||||||
|
api_3p : Dict[str, ThirdPartyIntegrationKeys]
|
||||||
|
custom_dir: str
|
||||||
|
data_dir: str
|
||||||
|
domain : str
|
||||||
|
ready: bool = False
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.api_3p = {}
|
||||||
|
self.domain = os.environ["DOMAIN"]
|
||||||
|
self.url_map.strict_slashes = False
|
||||||
|
self._load_config()
|
||||||
|
# Minor setup tasks
|
||||||
|
self._load_validators()
|
||||||
|
self._setup_routes()
|
||||||
|
self._setup_api_3p()
|
||||||
|
setup_api_views(self)
|
||||||
|
setup_app_views(self)
|
||||||
|
setup_login_views(self)
|
||||||
|
setup_web_views(self)
|
||||||
|
setup_wp_views(self)
|
||||||
|
setup_auth(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def legal_path(self) -> str:
|
||||||
|
return os.path.join(self.root_path, "static/templates/pages/legal/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avatars_path(self) -> str:
|
||||||
|
return os.path.join(self.custom_dir, "avatars/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secrets_dir(self) -> str:
|
||||||
|
return os.path.join(self.data_dir, "secrets")
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
"""
|
||||||
|
Perform setup tasks that might do network
|
||||||
|
"""
|
||||||
|
from admin.lib.postup import Postup
|
||||||
|
Postup(self)
|
||||||
|
# This must happen after Postup since it, e.g. fetches moodle secrets
|
||||||
|
from admin.lib.admin import Admin
|
||||||
|
self.admin = Admin(self)
|
||||||
|
# We now must setup the third-party callbacks
|
||||||
|
from admin.lib.callbacks import ThirdPartyCallbacks
|
||||||
|
if "correu" in self.api_3p:
|
||||||
|
tp = self.api_3p["correu"]
|
||||||
|
self.admin.third_party_cbs.append(ThirdPartyCallbacks(tp))
|
||||||
|
|
||||||
|
def json_route(self, rule: str, **options: Any) -> Callable[..., OptionalJsonResponse]:
|
||||||
|
return self.route(rule, **options) # type: ignore # mypy issue #7187
|
||||||
|
|
||||||
|
def _load_validators(self, purge_unknown: bool = True) -> Dict[str, Validator]:
|
||||||
|
validators = {}
|
||||||
|
schema_path = os.path.join(self.root_path, "schemas")
|
||||||
|
for schema_filename in os.listdir(schema_path):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(schema_path, schema_filename)) as file:
|
||||||
|
schema_yml = file.read()
|
||||||
|
schema = yaml.load(schema_yml, Loader=yaml.FullLoader)
|
||||||
|
validators[schema_filename.split(".")[0]] = AdminValidator(
|
||||||
|
schema, purge_unknown=purge_unknown
|
||||||
|
)
|
||||||
|
except IsADirectoryError:
|
||||||
|
None
|
||||||
|
return validators
|
||||||
|
|
||||||
|
def _load_config(self) -> None:
|
||||||
|
try:
|
||||||
|
self.data_dir = os.environ.get("DATA_FOLDER", ".")
|
||||||
|
self.custom_dir = os.environ.get("CUSTOM_FOLDER", ".")
|
||||||
|
# Handle secrets like Flask's session key
|
||||||
|
secret_key_file = os.path.join(self.secrets_dir, "secret_key")
|
||||||
|
if not os.path.exists(self.secrets_dir):
|
||||||
|
os.mkdir(self.secrets_dir, mode=0o700)
|
||||||
|
if not os.path.exists(secret_key_file):
|
||||||
|
# Generate as needed
|
||||||
|
# https://flask.palletsprojects.com/en/2.1.x/config/#SECRET_KEY
|
||||||
|
with os.fdopen(
|
||||||
|
os.open(secret_key_file, os.O_WRONLY | os.O_CREAT, 0o600), "w"
|
||||||
|
) as f:
|
||||||
|
f.write(secrets.token_hex())
|
||||||
|
self.secret_key = open(secret_key_file, "r").read()
|
||||||
|
|
||||||
|
# Move on with settings from the environment
|
||||||
|
self.config.update({
|
||||||
|
"DOMAIN": self.domain,
|
||||||
|
"KEYCLOAK_POSTGRES_USER": os.environ["KEYCLOAK_DB_USER"],
|
||||||
|
"KEYCLOAK_POSTGRES_PASSWORD": os.environ["KEYCLOAK_DB_PASSWORD"],
|
||||||
|
"MOODLE_POSTGRES_USER": os.environ["MOODLE_POSTGRES_USER"],
|
||||||
|
"MOODLE_POSTGRES_PASSWORD": os.environ["MOODLE_POSTGRES_PASSWORD"],
|
||||||
|
"NEXTCLOUD_POSTGRES_USER": os.environ["NEXTCLOUD_POSTGRES_USER"],
|
||||||
|
"NEXTCLOUD_POSTGRES_PASSWORD": os.environ["NEXTCLOUD_POSTGRES_PASSWORD"],
|
||||||
|
"VERIFY": os.environ["VERIFY"] == "true",
|
||||||
|
"API_SECRET": os.environ.get("API_SECRET"),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.error(traceback.format_exc())
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _setup_api_3p(self) -> None:
|
||||||
|
# Register third parties if / as requested
|
||||||
|
email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "")
|
||||||
|
integrations : List[Tuple[str, str]] = []
|
||||||
|
if email_domain:
|
||||||
|
integrations.append(("correu", f"correu.{self.domain}"))
|
||||||
|
|
||||||
|
api_3p_secrets_dir = os.path.join(self.secrets_dir, "api_3p")
|
||||||
|
if not os.path.exists(api_3p_secrets_dir):
|
||||||
|
os.mkdir(api_3p_secrets_dir, mode=0o700)
|
||||||
|
for integration, int_domain in integrations:
|
||||||
|
ks = os.path.join(api_3p_secrets_dir, integration)
|
||||||
|
if not os.path.exists(ks):
|
||||||
|
os.mkdir(ks, mode=0o700)
|
||||||
|
self.api_3p[integration] = ThirdPartyIntegrationKeys(key_store=ks, our_name="DD", their_name=integration, their_service_domain=int_domain)
|
||||||
|
|
||||||
|
if "correu" in self.api_3p:
|
||||||
|
from admin.views.MailViews import setup_mail_views
|
||||||
|
setup_mail_views(self)
|
||||||
|
# Temporary work-around while we are not receiving the 'kid'
|
||||||
|
# This effectively uses 'correu' as the default
|
||||||
|
# TODO: remove when dd-email-panel has changed this
|
||||||
|
self.api_3p[""] = self.api_3p["correu"]
|
||||||
|
|
||||||
|
def _setup_routes(self) -> None:
|
||||||
|
"""
|
||||||
|
Setup routes to Serve static files
|
||||||
|
"""
|
||||||
|
|
||||||
|
@self.route("/build/<path:path>")
|
||||||
|
def send_build(path: str) -> Response:
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.join(self.root_path, "node_modules/gentelella/build"), path
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route("/vendors/<path:path>")
|
||||||
|
def send_vendors(path: str) -> Response:
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.join(self.root_path, "node_modules/gentelella/vendors"), path
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route("/node_modules/<path:path>")
|
||||||
|
def send_nodes(path: str) -> Response:
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.join(self.root_path, "node_modules"), path
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route("/templates/<path:path>")
|
||||||
|
def send_templates(path: str) -> Response:
|
||||||
|
return send_from_directory(os.path.join(self.root_path, "templates"), path)
|
||||||
|
|
||||||
|
# @self.route('/templates/<path:path>')
|
||||||
|
# def send_templates(path):
|
||||||
|
# return send_from_directory(os.path.join(self.root_path, 'static/templates'), path)
|
||||||
|
|
||||||
|
@self.route("/static/<path:path>")
|
||||||
|
def send_static_js(path: str) -> Response:
|
||||||
|
return send_from_directory(os.path.join(self.root_path, "static"), path)
|
||||||
|
|
||||||
|
@self.route("/avatars/<path:path>")
|
||||||
|
def send_avatars_img(path: str) -> Response:
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.join(self.root_path, "../avatars/master-avatars"), path
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route("/custom/<path:path>")
|
||||||
|
def send_custom(path: str) -> Response:
|
||||||
|
return send_from_directory(self.custom_dir, path)
|
||||||
|
|
||||||
|
# @self.errorhandler(404)
|
||||||
|
# def not_found_error(error):
|
||||||
|
# return render_template('page_404.html'), 404
|
||||||
|
|
||||||
|
# @self.errorhandler(500)
|
||||||
|
# def internal_error(error):
|
||||||
|
# return render_template('page_500.html'), 500
|
||||||
|
|
||||||
|
@self.errorhandler(Error)
|
||||||
|
def handle_user_error(ex : Error) -> Response:
|
||||||
|
response : Response = jsonify(ex.error)
|
||||||
|
response.status_code = ex.status_code
|
||||||
|
response.headers.extend(ex.content_type) # type: ignore # werkzeug type hint MIA
|
||||||
|
return response
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -26,8 +27,6 @@ from time import sleep
|
||||||
|
|
||||||
import diceware
|
import diceware
|
||||||
|
|
||||||
from admin import app
|
|
||||||
|
|
||||||
from .avatars import Avatars
|
from .avatars import Avatars
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
filter_roles_list,
|
filter_roles_list,
|
||||||
|
@ -61,20 +60,37 @@ from .helpers import (
|
||||||
rand_password,
|
rand_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
from admin.lib.callbacks import ThirdPartyCallbacks
|
||||||
|
|
||||||
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
|
MANAGER = os.environ["CUSTOM_ROLE_MANAGER"]
|
||||||
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
|
TEACHER = os.environ["CUSTOM_ROLE_TEACHER"]
|
||||||
STUDENT = os.environ["CUSTOM_ROLE_STUDENT"]
|
STUDENT = os.environ["CUSTOM_ROLE_STUDENT"]
|
||||||
|
|
||||||
|
DDUser = Dict[str, Any]
|
||||||
|
DDGroup = Dict[str, Any]
|
||||||
|
DDRole = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class Admin:
|
class Admin:
|
||||||
def __init__(self):
|
app : "AdminFlaskApp"
|
||||||
self.check_connections()
|
internal : Dict[str, Any]
|
||||||
|
external : Dict[str, Any]
|
||||||
|
third_party_cbs : List["ThirdPartyCallbacks"]
|
||||||
|
|
||||||
|
def __init__(self, app : "AdminFlaskApp") -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.check_connections(app)
|
||||||
|
|
||||||
self.set_custom_roles()
|
self.set_custom_roles()
|
||||||
self.overwrite_admins()
|
self.overwrite_admins()
|
||||||
|
|
||||||
self.default_setup()
|
self.default_setup()
|
||||||
self.internal = {}
|
self.internal = {}
|
||||||
|
self.third_party_cbs = []
|
||||||
|
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
|
@ -90,13 +106,39 @@ class Admin:
|
||||||
self.external = {"users": [], "groups": [], "roles": []}
|
self.external = {"users": [], "groups": [], "roles": []}
|
||||||
|
|
||||||
log.warning(" Updating missing user avatars with defaults")
|
log.warning(" Updating missing user avatars with defaults")
|
||||||
self.av = Avatars()
|
self.av = Avatars(app.avatars_path)
|
||||||
# av.minio_delete_all_objects() # This will reset all avatars on usres
|
# av.minio_delete_all_objects() # This will reset all avatars on usres
|
||||||
self.av.update_missing_avatars(self.internal["users"])
|
self.av.update_missing_avatars(self.internal["users"])
|
||||||
|
|
||||||
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS")
|
log.warning(" SYSTEM READY TO HANDLE CONNECTIONS")
|
||||||
|
|
||||||
def check_connections(self):
|
def third_party_add_user(self, user_id : str, user : DDUser) -> bool:
|
||||||
|
res = True
|
||||||
|
for tp in self.third_party_cbs:
|
||||||
|
res = res and tp.add_user(user_id, user)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def third_party_update_user(self, user_id : str, user : DDUser) -> bool:
|
||||||
|
res = True
|
||||||
|
for tp in self.third_party_cbs:
|
||||||
|
res = res and tp.update_user(user_id, user)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def third_party_delete_user(self, user_id : str) -> bool:
|
||||||
|
res = True
|
||||||
|
for tp in self.third_party_cbs:
|
||||||
|
res = res and tp.delete_user(user_id)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def nextcloud_mail_set(self, users : List[DDUser], extra_data : Dict) -> Dict:
|
||||||
|
# TODO: implement
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def nextcloud_mail_delete(self, users : List[DDUser], extra_data : Dict) -> Dict:
|
||||||
|
# TODO: implement
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def check_connections(self, app : "AdminFlaskApp") -> None:
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
try:
|
try:
|
||||||
|
@ -111,7 +153,7 @@ class Admin:
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
try:
|
try:
|
||||||
self.moodle = Moodle(verify=app.config["VERIFY"])
|
self.moodle = Moodle(app)
|
||||||
ready = True
|
ready = True
|
||||||
except:
|
except:
|
||||||
log.error("Could not connect to moodle, waiting to be online...")
|
log.error("Could not connect to moodle, waiting to be online...")
|
||||||
|
@ -136,18 +178,18 @@ class Admin:
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
try:
|
try:
|
||||||
self.nextcloud = Nextcloud(verify=app.config["VERIFY"])
|
self.nextcloud = Nextcloud(verify=app.config["VERIFY"], app=app)
|
||||||
ready = True
|
ready = True
|
||||||
except:
|
except:
|
||||||
log.error("Could not connect to nextcloud, waiting to be online...")
|
log.error("Could not connect to nextcloud, waiting to be online...")
|
||||||
sleep(2)
|
sleep(2)
|
||||||
log.warning("Nextcloud connected.")
|
log.warning("Nextcloud connected.")
|
||||||
|
|
||||||
def set_custom_roles(self):
|
def set_custom_roles(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
## This function should be moved to postup.py
|
## This function should be moved to postup.py
|
||||||
def overwrite_admins(self):
|
def overwrite_admins(self) -> None:
|
||||||
log.warning("Setting defaults...")
|
log.warning("Setting defaults...")
|
||||||
dduser = os.environ["DDADMIN_USER"]
|
dduser = os.environ["DDADMIN_USER"]
|
||||||
ddpassword = os.environ["DDADMIN_PASSWORD"]
|
ddpassword = os.environ["DDADMIN_PASSWORD"]
|
||||||
|
@ -223,7 +265,7 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
def default_setup(self):
|
def default_setup(self) -> None:
|
||||||
### Add default roles
|
### Add default roles
|
||||||
try:
|
try:
|
||||||
log.warning("KEYCLOAK: Adding default roles")
|
log.warning("KEYCLOAK: Adding default roles")
|
||||||
|
@ -324,7 +366,7 @@ class Admin:
|
||||||
# except:
|
# except:
|
||||||
# log.warning("KEYCLOAK: Seems to be there already")
|
# log.warning("KEYCLOAK: Seems to be there already")
|
||||||
|
|
||||||
def resync_data(self):
|
def resync_data(self) -> bool:
|
||||||
self.internal = {
|
self.internal = {
|
||||||
"users": self._get_mix_users(),
|
"users": self._get_mix_users(),
|
||||||
"groups": self._get_mix_groups(),
|
"groups": self._get_mix_groups(),
|
||||||
|
@ -332,7 +374,7 @@ class Admin:
|
||||||
}
|
}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_moodle_users(self):
|
def get_moodle_users(self) -> List[Any]:
|
||||||
return [
|
return [
|
||||||
u
|
u
|
||||||
for u in self.moodle.get_users_with_groups_and_roles()
|
for u in self.moodle.get_users_with_groups_and_roles()
|
||||||
|
@ -353,7 +395,7 @@ class Admin:
|
||||||
# "roles": u['roles']}
|
# "roles": u['roles']}
|
||||||
# for u in users]
|
# for u in users]
|
||||||
|
|
||||||
def get_keycloak_users(self):
|
def get_keycloak_users(self) -> List[DDUser]:
|
||||||
# log.warning('Loading keycloak users... can take a long time...')
|
# log.warning('Loading keycloak users... can take a long time...')
|
||||||
|
|
||||||
users = self.keycloak.get_users_with_groups_and_roles()
|
users = self.keycloak.get_users_with_groups_and_roles()
|
||||||
|
@ -372,7 +414,7 @@ class Admin:
|
||||||
if not system_username(u["username"])
|
if not system_username(u["username"])
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_nextcloud_users(self):
|
def get_nextcloud_users(self) -> List[DDUser]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": u["username"],
|
"id": u["username"],
|
||||||
|
@ -414,11 +456,11 @@ class Admin:
|
||||||
# "roles": []})
|
# "roles": []})
|
||||||
# return users_list
|
# return users_list
|
||||||
|
|
||||||
def get_mix_users(self):
|
def get_mix_users(self) -> Any:
|
||||||
sio_event_send("get_users", {"you_win": "you got the users!"})
|
sio_event_send(self.app, "get_users", {"you_win": "you got the users!"})
|
||||||
return self.internal["users"]
|
return self.internal["users"]
|
||||||
|
|
||||||
def _get_mix_users(self):
|
def _get_mix_users(self) -> List[DDUser]:
|
||||||
kgroups = self.keycloak.get_groups()
|
kgroups = self.keycloak.get_groups()
|
||||||
|
|
||||||
kusers = self.get_keycloak_users()
|
kusers = self.get_keycloak_users()
|
||||||
|
@ -481,32 +523,32 @@ class Admin:
|
||||||
users.append(theuser)
|
users.append(theuser)
|
||||||
return users
|
return users
|
||||||
|
|
||||||
def get_roles(self):
|
def get_roles(self) -> Any:
|
||||||
return self.internal["roles"]
|
return self.internal["roles"]
|
||||||
|
|
||||||
def _get_roles(self):
|
def _get_roles(self) -> List[DDRole]:
|
||||||
return filter_roles_listofdicts(self.keycloak.get_roles())
|
return filter_roles_listofdicts(self.keycloak.get_roles())
|
||||||
|
|
||||||
def get_group_by_name(self, group_name):
|
def get_group_by_name(self, group_name : str) -> Any:
|
||||||
group = [g for g in self.internal["groups"] if g["name"] == group_name]
|
group = [g for g in self.internal["groups"] if g["name"] == group_name]
|
||||||
return group[0] if len(group) else False
|
return group[0] if len(group) else False
|
||||||
|
|
||||||
def get_keycloak_groups(self):
|
def get_keycloak_groups(self) -> Any:
|
||||||
log.warning("Loading keycloak groups...")
|
log.warning("Loading keycloak groups...")
|
||||||
return self.keycloak.get_groups()
|
return self.keycloak.get_groups()
|
||||||
|
|
||||||
def get_moodle_groups(self):
|
def get_moodle_groups(self) -> Any:
|
||||||
log.warning("Loading moodle groups...")
|
log.warning("Loading moodle groups...")
|
||||||
return self.moodle.get_cohorts()
|
return self.moodle.get_cohorts()
|
||||||
|
|
||||||
def get_nextcloud_groups(self):
|
def get_nextcloud_groups(self) -> Any:
|
||||||
log.warning("Loading nextcloud groups...")
|
log.warning("Loading nextcloud groups...")
|
||||||
return self.nextcloud.get_groups_list()
|
return self.nextcloud.get_groups_list()
|
||||||
|
|
||||||
def get_mix_groups(self):
|
def get_mix_groups(self) -> Any:
|
||||||
return self.internal["groups"]
|
return self.internal["groups"]
|
||||||
|
|
||||||
def _get_mix_groups(self):
|
def _get_mix_groups(self) -> List[Dict[str, Any]]:
|
||||||
kgroups = self.get_keycloak_groups()
|
kgroups = self.get_keycloak_groups()
|
||||||
mgroups = self.get_moodle_groups()
|
mgroups = self.get_moodle_groups()
|
||||||
ngroups = self.get_nextcloud_groups()
|
ngroups = self.get_nextcloud_groups()
|
||||||
|
@ -564,7 +606,7 @@ class Admin:
|
||||||
groups.append(thegroup)
|
groups.append(thegroup)
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
def sync_groups_from_keycloak(self):
|
def sync_groups_from_keycloak(self) -> None:
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
for group in self.internal["groups"]:
|
for group in self.internal["groups"]:
|
||||||
if not group["keycloak"]:
|
if not group["keycloak"]:
|
||||||
|
@ -586,22 +628,22 @@ class Admin:
|
||||||
self.nextcloud.add_group(group["name"])
|
self.nextcloud.add_group(group["name"])
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def get_external_users(self):
|
def get_external_users(self) -> Any:
|
||||||
return self.external["users"]
|
return self.external["users"]
|
||||||
|
|
||||||
def get_external_groups(self):
|
def get_external_groups(self) -> Any:
|
||||||
return self.external["groups"]
|
return self.external["groups"]
|
||||||
|
|
||||||
def get_external_roles(self):
|
def get_external_roles(self) -> Any:
|
||||||
return self.external["roles"]
|
return self.external["roles"]
|
||||||
|
|
||||||
def upload_csv_ug(self, data):
|
def upload_csv_ug(self, data : Dict[str, Any]) -> bool:
|
||||||
log.warning("Processing uploaded users...")
|
log.warning("Processing uploaded users...")
|
||||||
users = []
|
users = []
|
||||||
total = len(data["data"])
|
total = len(data["data"])
|
||||||
item = 1
|
item = 1
|
||||||
ev = Events("Processing uploaded users", total=len(data["data"]))
|
ev = Events(self.app, "Processing uploaded users", total=len(data["data"]))
|
||||||
groups = []
|
groups : List[str] = []
|
||||||
for u in data["data"]:
|
for u in data["data"]:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Processing ("
|
"Processing ("
|
||||||
|
@ -680,18 +722,18 @@ class Admin:
|
||||||
self.external["groups"] = sysgroups
|
self.external["groups"] = sysgroups
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_dice_pwd(self):
|
def get_dice_pwd(self) -> str:
|
||||||
return diceware.get_passphrase(options=options)
|
return cast(str, diceware.get_passphrase(options=options))
|
||||||
|
|
||||||
def reset_external(self):
|
def reset_external(self) -> bool:
|
||||||
self.external = {"users": [], "groups": [], "roles": []}
|
self.external = {"users": [], "groups": [], "roles": []}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def upload_json_ga(self, data):
|
def upload_json_ga(self, data : Dict[str, Any]) -> bool:
|
||||||
groups = []
|
groups = []
|
||||||
log.warning("Processing uploaded groups...")
|
log.warning("Processing uploaded groups...")
|
||||||
try:
|
try:
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Processing uploaded groups",
|
"Processing uploaded groups",
|
||||||
"Group:",
|
"Group:",
|
||||||
total=len(data["data"]["groups"]),
|
total=len(data["data"]["groups"]),
|
||||||
|
@ -718,7 +760,7 @@ class Admin:
|
||||||
users = []
|
users = []
|
||||||
total = len(data["data"]["users"])
|
total = len(data["data"]["users"])
|
||||||
item = 1
|
item = 1
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Processing uploaded users",
|
"Processing uploaded users",
|
||||||
"User:",
|
"User:",
|
||||||
total=len(data["data"]["users"]),
|
total=len(data["data"]["users"]),
|
||||||
|
@ -757,7 +799,8 @@ class Admin:
|
||||||
u["groups"] = u["groups"] + [g["name"]]
|
u["groups"] = u["groups"] + [g["name"]]
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sync_external(self, ids):
|
def sync_external(self, ids : Any) -> None:
|
||||||
|
# TODO: What is this endpoint for? When is it called?
|
||||||
# self.resync_data()
|
# self.resync_data()
|
||||||
log.warning("Starting sync to keycloak")
|
log.warning("Starting sync to keycloak")
|
||||||
self.sync_to_keycloak_external()
|
self.sync_to_keycloak_external()
|
||||||
|
@ -769,10 +812,10 @@ class Admin:
|
||||||
log.warning("All syncs finished. Resyncing from apps...")
|
log.warning("All syncs finished. Resyncing from apps...")
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def add_keycloak_groups(self, groups):
|
def add_keycloak_groups(self, groups : List[Any]) -> None:
|
||||||
total = len(groups)
|
total = len(groups)
|
||||||
i = 0
|
i = 0
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing import groups to keycloak", "Adding group:", total=len(groups)
|
"Syncing import groups to keycloak", "Adding group:", total=len(groups)
|
||||||
)
|
)
|
||||||
for g in groups:
|
for g in groups:
|
||||||
|
@ -790,8 +833,8 @@ class Admin:
|
||||||
|
|
||||||
def sync_to_keycloak_external(
|
def sync_to_keycloak_external(
|
||||||
self,
|
self,
|
||||||
): ### This one works from the external, moodle and nextcloud from the internal
|
) -> None: ### This one works from the external, moodle and nextcloud from the internal
|
||||||
groups = []
|
groups : List[DDGroup] = []
|
||||||
for u in self.external["users"]:
|
for u in self.external["users"]:
|
||||||
groups = groups + u["groups"]
|
groups = groups + u["groups"]
|
||||||
groups = list(dict.fromkeys(groups))
|
groups = list(dict.fromkeys(groups))
|
||||||
|
@ -800,7 +843,7 @@ class Admin:
|
||||||
|
|
||||||
total = len(self.external["users"])
|
total = len(self.external["users"])
|
||||||
index = 0
|
index = 0
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing import users to keycloak",
|
"Syncing import users to keycloak",
|
||||||
"Adding user:",
|
"Adding user:",
|
||||||
total=len(self.external["users"]),
|
total=len(self.external["users"]),
|
||||||
|
@ -855,11 +898,11 @@ class Admin:
|
||||||
u["groups"].append(u["roles"][0])
|
u["groups"].append(u["roles"][0])
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def add_moodle_groups(self, groups):
|
def add_moodle_groups(self, groups : List[Any]) -> None:
|
||||||
### Create all groups. Skip / in system groups
|
### Create all groups. Skip / in system groups
|
||||||
total = len(groups)
|
total = len(groups)
|
||||||
log.warning(groups)
|
log.warning(groups)
|
||||||
ev = Events("Syncing groups from external to moodle", total=len(groups))
|
ev = Events(self.app, "Syncing groups from external to moodle", total=len(groups))
|
||||||
i = 1
|
i = 1
|
||||||
for g in groups:
|
for g in groups:
|
||||||
moodle_groups = kpath2gids(g)
|
moodle_groups = kpath2gids(g)
|
||||||
|
@ -880,9 +923,9 @@ class Admin:
|
||||||
)
|
)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
|
|
||||||
def sync_to_moodle_external(self): # works from the internal (keycloak)
|
def sync_to_moodle_external(self) -> None: # works from the internal (keycloak)
|
||||||
### Process all groups from the users keycloak_groups key
|
### Process all groups from the users keycloak_groups key
|
||||||
groups = []
|
groups : List[DDGroup] = []
|
||||||
for u in self.external["users"]:
|
for u in self.external["users"]:
|
||||||
groups = groups + u["groups"]
|
groups = groups + u["groups"]
|
||||||
groups = list(dict.fromkeys(groups))
|
groups = list(dict.fromkeys(groups))
|
||||||
|
@ -893,7 +936,7 @@ class Admin:
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
|
|
||||||
### Create users in moodle
|
### Create users in moodle
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users from external to moodle", total=len(self.internal["users"])
|
"Syncing users from external to moodle", total=len(self.internal["users"])
|
||||||
)
|
)
|
||||||
for u in self.external["users"]:
|
for u in self.external["users"]:
|
||||||
|
@ -920,7 +963,7 @@ class Admin:
|
||||||
|
|
||||||
# self.resync_data()
|
# self.resync_data()
|
||||||
### Add user to their cohorts (groups)
|
### Add user to their cohorts (groups)
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users groups from external to moodle cohorts",
|
"Syncing users groups from external to moodle cohorts",
|
||||||
total=len(self.internal["users"]),
|
total=len(self.internal["users"]),
|
||||||
)
|
)
|
||||||
|
@ -938,16 +981,16 @@ class Admin:
|
||||||
log.error(self.moodle.get_user_by("username", u["username"]))
|
log.error(self.moodle.get_user_by("username", u["username"]))
|
||||||
# self.resync_data()
|
# self.resync_data()
|
||||||
|
|
||||||
def delete_all_moodle_cohorts(self):
|
def delete_all_moodle_cohorts(self) -> None:
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
ids = [c["id"] for c in cohorts]
|
ids = [c["id"] for c in cohorts]
|
||||||
self.moodle.delete_cohorts(ids)
|
self.moodle.delete_cohorts(ids)
|
||||||
|
|
||||||
def add_nextcloud_groups(self, groups):
|
def add_nextcloud_groups(self, groups : List[Any]) -> None:
|
||||||
### Create all groups. Skip / in system groups
|
### Create all groups. Skip / in system groups
|
||||||
total = len(groups)
|
total = len(groups)
|
||||||
log.warning(groups)
|
log.warning(groups)
|
||||||
ev = Events("Syncing groups from external to nextcloud", total=len(groups))
|
ev = Events(self.app, "Syncing groups from external to nextcloud", total=len(groups))
|
||||||
i = 1
|
i = 1
|
||||||
for g in groups:
|
for g in groups:
|
||||||
nextcloud_groups = kpath2gids(g)
|
nextcloud_groups = kpath2gids(g)
|
||||||
|
@ -968,15 +1011,15 @@ class Admin:
|
||||||
)
|
)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
|
|
||||||
def sync_to_nextcloud_external(self):
|
def sync_to_nextcloud_external(self) -> None:
|
||||||
groups = []
|
groups : List[DDGroup] = []
|
||||||
for u in self.external["users"]:
|
for u in self.external["users"]:
|
||||||
groups = groups + u["gids"]
|
groups = groups + u["gids"]
|
||||||
groups = list(dict.fromkeys(groups))
|
groups = list(dict.fromkeys(groups))
|
||||||
|
|
||||||
self.add_nextcloud_groups(groups)
|
self.add_nextcloud_groups(groups)
|
||||||
|
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users from external to nextcloud",
|
"Syncing users from external to nextcloud",
|
||||||
total=len(self.internal["users"]),
|
total=len(self.internal["users"]),
|
||||||
)
|
)
|
||||||
|
@ -1009,14 +1052,14 @@ class Admin:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
def sync_to_moodle(self): # works from the internal (keycloak)
|
def sync_to_moodle(self) -> None: # works from the internal (keycloak)
|
||||||
### Process all groups from the users keycloak_groups key
|
### Process all groups from the users keycloak_groups key
|
||||||
groups = []
|
groups : List[str] = []
|
||||||
for u in self.internal["users"]:
|
for u in self.internal["users"]:
|
||||||
groups = groups + u["keycloak_groups"]
|
groups = groups + u["keycloak_groups"]
|
||||||
groups = list(dict.fromkeys(groups))
|
groups = list(dict.fromkeys(groups))
|
||||||
|
|
||||||
ev = Events("Syncing groups from keycloak to moodle", total=len(groups))
|
ev = Events(self.app, "Syncing groups from keycloak to moodle", total=len(groups))
|
||||||
pathslist = []
|
pathslist = []
|
||||||
for group in groups:
|
for group in groups:
|
||||||
pathpart = ""
|
pathpart = ""
|
||||||
|
@ -1040,7 +1083,7 @@ class Admin:
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
|
|
||||||
### Create users in moodle
|
### Create users in moodle
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users from keycloak to moodle", total=len(self.internal["users"])
|
"Syncing users from keycloak to moodle", total=len(self.internal["users"])
|
||||||
)
|
)
|
||||||
for u in self.internal["users"]:
|
for u in self.internal["users"]:
|
||||||
|
@ -1067,7 +1110,7 @@ class Admin:
|
||||||
|
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users with moodle cohorts", total=len(self.internal["users"])
|
"Syncing users with moodle cohorts", total=len(self.internal["users"])
|
||||||
)
|
)
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
|
@ -1106,15 +1149,15 @@ class Admin:
|
||||||
|
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def sync_to_nextcloud(self):
|
def sync_to_nextcloud(self) -> None:
|
||||||
groups = []
|
groups : List[str] = []
|
||||||
for u in self.internal["users"]:
|
for u in self.internal["users"]:
|
||||||
groups = groups + u["keycloak_groups"]
|
groups = groups + u["keycloak_groups"]
|
||||||
groups = list(dict.fromkeys(groups))
|
groups = list(dict.fromkeys(groups))
|
||||||
|
|
||||||
total = len(groups)
|
total = len(groups)
|
||||||
i = 0
|
i = 0
|
||||||
ev = Events("Syncing groups from keycloak to nextcloud", total=len(groups))
|
ev = Events(self.app, "Syncing groups from keycloak to nextcloud", total=len(groups))
|
||||||
for g in groups:
|
for g in groups:
|
||||||
parts = g.split("/")
|
parts = g.split("/")
|
||||||
subpath = ""
|
subpath = ""
|
||||||
|
@ -1137,7 +1180,7 @@ class Admin:
|
||||||
)
|
)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
|
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Syncing users from keycloak to nextcloud",
|
"Syncing users from keycloak to nextcloud",
|
||||||
total=len(self.internal["users"]),
|
total=len(self.internal["users"]),
|
||||||
)
|
)
|
||||||
|
@ -1167,13 +1210,13 @@ class Admin:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
def delete_keycloak_user(self, userid):
|
def delete_keycloak_user(self, userid : str) -> None:
|
||||||
user = [u for u in self.internal["users"] if u["id"] == userid]
|
users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
|
||||||
if len(user) and user[0]["keycloak"]:
|
if len(users) and users[0]["keycloak"]:
|
||||||
user = user[0]
|
user = users[0]
|
||||||
keycloak_id = user["id"]
|
keycloak_id = user["id"]
|
||||||
else:
|
else:
|
||||||
return False
|
return
|
||||||
log.warning("Removing keycloak user: " + user["username"])
|
log.warning("Removing keycloak user: " + user["username"])
|
||||||
try:
|
try:
|
||||||
self.keycloak.delete_user(keycloak_id)
|
self.keycloak.delete_user(keycloak_id)
|
||||||
|
@ -1183,10 +1226,10 @@ class Admin:
|
||||||
|
|
||||||
self.av.delete_user_avatar(userid)
|
self.av.delete_user_avatar(userid)
|
||||||
|
|
||||||
def delete_keycloak_users(self):
|
def delete_keycloak_users(self) -> None:
|
||||||
total = len(self.internal["users"])
|
total = len(self.internal["users"])
|
||||||
i = 0
|
i = 0
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Deleting users from keycloak",
|
"Deleting users from keycloak",
|
||||||
"Deleting user:",
|
"Deleting user:",
|
||||||
total=len(self.internal["users"]),
|
total=len(self.internal["users"]),
|
||||||
|
@ -1217,13 +1260,13 @@ class Admin:
|
||||||
)
|
)
|
||||||
self.av.minio_delete_all_objects()
|
self.av.minio_delete_all_objects()
|
||||||
|
|
||||||
def delete_nextcloud_user(self, userid):
|
def delete_nextcloud_user(self, userid : str) -> None:
|
||||||
user = [u for u in self.internal["users"] if u["id"] == userid]
|
users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
|
||||||
if len(user) and user[0]["nextcloud"]:
|
if len(users) and users[0]["nextcloud"]:
|
||||||
user = user[0]
|
user = users[0]
|
||||||
nextcloud_id = user["nextcloud_id"]
|
nextcloud_id = user["nextcloud_id"]
|
||||||
else:
|
else:
|
||||||
return False
|
return
|
||||||
log.warning("Removing nextcloud user: " + user["username"])
|
log.warning("Removing nextcloud user: " + user["username"])
|
||||||
try:
|
try:
|
||||||
self.nextcloud.delete_user(nextcloud_id)
|
self.nextcloud.delete_user(nextcloud_id)
|
||||||
|
@ -1231,8 +1274,8 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
log.warning("Could not remove users: " + user["username"])
|
log.warning("Could not remove users: " + user["username"])
|
||||||
|
|
||||||
def delete_nextcloud_users(self):
|
def delete_nextcloud_users(self) -> None:
|
||||||
ev = Events("Deleting users from nextcloud", total=len(self.internal["users"]))
|
ev = Events(self.app, "Deleting users from nextcloud", total=len(self.internal["users"]))
|
||||||
for u in self.internal["users"]:
|
for u in self.internal["users"]:
|
||||||
|
|
||||||
if u["nextcloud"] and not u["keycloak"]:
|
if u["nextcloud"] and not u["keycloak"]:
|
||||||
|
@ -1246,13 +1289,13 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
log.warning("Could not remove user: " + u["username"])
|
log.warning("Could not remove user: " + u["username"])
|
||||||
|
|
||||||
def delete_moodle_user(self, userid):
|
def delete_moodle_user(self, userid : str) -> None:
|
||||||
user = [u for u in self.internal["users"] if u["id"] == userid]
|
users : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
|
||||||
if len(user) and user[0]["moodle"]:
|
if len(users) and users[0]["moodle"]:
|
||||||
user = user[0]
|
user = users[0]
|
||||||
moodle_id = user["moodle_id"]
|
moodle_id = user["moodle_id"]
|
||||||
else:
|
else:
|
||||||
return False
|
return
|
||||||
log.warning("Removing moodle user: " + user["username"])
|
log.warning("Removing moodle user: " + user["username"])
|
||||||
try:
|
try:
|
||||||
self.moodle.delete_users([moodle_id])
|
self.moodle.delete_users([moodle_id])
|
||||||
|
@ -1260,7 +1303,7 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
log.warning("Could not remove users: " + user["username"])
|
log.warning("Could not remove users: " + user["username"])
|
||||||
|
|
||||||
def delete_moodle_users(self):
|
def delete_moodle_users(self, app : "AdminFlaskApp") -> None:
|
||||||
userids = []
|
userids = []
|
||||||
usernames = []
|
usernames = []
|
||||||
for u in self.internal["users"]:
|
for u in self.internal["users"]:
|
||||||
|
@ -1288,7 +1331,7 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
log.warning("Could not remove users: " + ",".join(usernames))
|
log.warning("Could not remove users: " + ",".join(usernames))
|
||||||
|
|
||||||
def delete_keycloak_groups(self):
|
def delete_keycloak_groups(self) -> None:
|
||||||
for g in self.internal["groups"]:
|
for g in self.internal["groups"]:
|
||||||
if not g["keycloak"]:
|
if not g["keycloak"]:
|
||||||
continue
|
continue
|
||||||
|
@ -1302,7 +1345,7 @@ class Admin:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
log.warning("Could not remove group: " + g["name"])
|
log.warning("Could not remove group: " + g["name"])
|
||||||
|
|
||||||
def external_roleassign(self, data):
|
def external_roleassign(self, data : Dict[str, Any]) -> bool:
|
||||||
for newuserid in data["ids"]:
|
for newuserid in data["ids"]:
|
||||||
for externaluser in self.external["users"]:
|
for externaluser in self.external["users"]:
|
||||||
if externaluser["id"] == newuserid:
|
if externaluser["id"] == newuserid:
|
||||||
|
@ -1316,10 +1359,10 @@ class Admin:
|
||||||
externaluser["gids"].append(data["action"])
|
externaluser["gids"].append(data["action"])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def user_update_password(self, userid, password, password_temporary):
|
def user_update_password(self, userid : str, password : str, password_temporary : bool) -> Any:
|
||||||
return self.keycloak.update_user_pwd(userid, password, password_temporary)
|
return self.keycloak.update_user_pwd(userid, password, password_temporary)
|
||||||
|
|
||||||
def update_users_from_keycloak(self):
|
def update_users_from_keycloak(self) -> None:
|
||||||
kgroups = self.keycloak.get_groups()
|
kgroups = self.keycloak.get_groups()
|
||||||
users = [
|
users = [
|
||||||
{
|
{
|
||||||
|
@ -1339,15 +1382,15 @@ class Admin:
|
||||||
]
|
]
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
ev = Events(
|
ev = Events(self.app,
|
||||||
"Updating users from keycloak", "User:", total=len(users), table="users"
|
"Updating users from keycloak", "User:", total=len(users), table="users"
|
||||||
)
|
)
|
||||||
self.user_update(user)
|
self.user_update(user)
|
||||||
ev.increment({"name": user["username"], "data": user["groups"]})
|
ev.increment({"name": user["username"], "data": user["groups"]})
|
||||||
|
|
||||||
def user_update(self, user):
|
def user_update(self, user : DDUser) -> bool:
|
||||||
log.warning("Updating user moodle, nextcloud keycloak")
|
log.warning("Updating user moodle, nextcloud keycloak")
|
||||||
ev = Events("Updating user", "Updating user in keycloak")
|
ev = Events(self.app, "Updating user", "Updating user in keycloak")
|
||||||
|
|
||||||
## Get actual user role
|
## Get actual user role
|
||||||
try:
|
try:
|
||||||
|
@ -1505,6 +1548,9 @@ class Admin:
|
||||||
ev.update_text("Updating user in nextcloud")
|
ev.update_text("Updating user in nextcloud")
|
||||||
self.update_nextcloud_user(internaluser["id"], user, ndelete, nadd)
|
self.update_nextcloud_user(internaluser["id"], user, ndelete, nadd)
|
||||||
|
|
||||||
|
ev.update_text("Updating user in other apps")
|
||||||
|
self.third_party_update_user(internaluser["id"], user)
|
||||||
|
|
||||||
ev.update_text("User updated")
|
ev.update_text("User updated")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -1533,7 +1579,7 @@ class Admin:
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update_keycloak_user(self, user_id, user, kdelete, kadd):
|
def update_keycloak_user(self, user_id : str, user : DDUser, kdelete : List[Any], kadd : List[Any]) -> bool:
|
||||||
# pprint(self.keycloak.get_user_realm_roles(user_id))
|
# pprint(self.keycloak.get_user_realm_roles(user_id))
|
||||||
self.keycloak.remove_user_realm_roles(user_id, "student")
|
self.keycloak.remove_user_realm_roles(user_id, "student")
|
||||||
self.keycloak.assign_realm_roles(user_id, user["roles"][0])
|
self.keycloak.assign_realm_roles(user_id, user["roles"][0])
|
||||||
|
@ -1549,27 +1595,27 @@ class Admin:
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def enable_users(self, data):
|
def enable_users(self, data : List[DDUser]) -> None:
|
||||||
# data={'id':'','username':''}
|
# data={'id':'','username':''}
|
||||||
ev = Events("Bulk actions", "Enabling user:", total=len(data))
|
ev = Events(self.app, "Bulk actions", "Enabling user:", total=len(data))
|
||||||
for user in data:
|
for user in data:
|
||||||
ev.increment({"name": user["username"], "data": user["username"]})
|
ev.increment({"name": user["username"], "data": user["username"]})
|
||||||
self.keycloak.user_enable(user["id"])
|
self.keycloak.user_enable(user["id"])
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def disable_users(self, data):
|
def disable_users(self, data : List[DDUser]) -> None:
|
||||||
# data={'id':'','username':''}
|
# data={'id':'','username':''}
|
||||||
ev = Events("Bulk actions", "Disabling user:", total=len(data))
|
ev = Events(self.app, "Bulk actions", "Disabling user:", total=len(data))
|
||||||
for user in data:
|
for user in data:
|
||||||
ev.increment({"name": user["username"], "data": user["username"]})
|
ev.increment({"name": user["username"], "data": user["username"]})
|
||||||
self.keycloak.user_disable(user["id"])
|
self.keycloak.user_disable(user["id"])
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def update_moodle_user(self, user_id, user, mdelete, madd):
|
def update_moodle_user(self, user_id : str, user : DDUser, mdelete : Iterable[Any], madd : Iterable[Any]) -> bool:
|
||||||
internaluser = [u for u in self.internal["users"] if u["id"] == user_id][0]
|
internaluser : DDUser = [u for u in self.internal["users"] if u["id"] == user_id][0]
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
for group in mdelete:
|
for group in mdelete:
|
||||||
cohort = [c for c in cohorts if c["name"] == group]
|
cohort = [c for c in cohorts if c["name"] == group[0]][0]
|
||||||
try:
|
try:
|
||||||
self.moodle.delete_user_in_cohort(
|
self.moodle.delete_user_in_cohort(
|
||||||
internaluser["moodle_id"], cohort["id"]
|
internaluser["moodle_id"], cohort["id"]
|
||||||
|
@ -1604,29 +1650,29 @@ class Admin:
|
||||||
|
|
||||||
def add_moodle_user(
|
def add_moodle_user(
|
||||||
self,
|
self,
|
||||||
username,
|
username : str,
|
||||||
email,
|
email : str,
|
||||||
first_name,
|
first_name : str,
|
||||||
last_name,
|
last_name : str,
|
||||||
password="*12" + secrets.token_urlsafe(16),
|
password : str="*12" + secrets.token_urlsafe(16),
|
||||||
):
|
) -> None:
|
||||||
log.warning("Creating moodle user: " + username)
|
log.warning("Creating moodle user: " + username)
|
||||||
ev = Events("Add user", username)
|
ev = Events(self.app, "Add user", username)
|
||||||
try:
|
try:
|
||||||
self.moodle.create_user(email, username, password, first_name, last_name)
|
self.moodle.create_user(email, username, password, first_name, last_name)
|
||||||
ev.update_text({"name": "Added to moodle", "data": []})
|
ev.update_text(str({"name": "Added to moodle", "data": []}))
|
||||||
except UserExists:
|
except UserExists as ex:
|
||||||
log.error(" -->> User already exists")
|
log.error(" -->> User already exists")
|
||||||
error = Events("User already exists.", str(se), type="error")
|
error = Events(self.app, "User already exists.", str(ex), type="error")
|
||||||
except SystemError as se:
|
except SystemError as ex:
|
||||||
log.error("Moodle create user error: " + str(se))
|
log.error("Moodle create user error: " + str(ex))
|
||||||
error = Events("Moodle create user error", str(se), type="error")
|
error = Events(self.app, "Moodle create user error", str(ex), type="error")
|
||||||
except:
|
except:
|
||||||
log.error(" -->> Error creating on moodle the user: " + username)
|
log.error(" -->> Error creating on moodle the user: " + username)
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
error = Events("Internal error", "Check logs", type="error")
|
error = Events(self.app, "Internal error", "Check logs", type="error")
|
||||||
|
|
||||||
def update_nextcloud_user(self, user_id, user, ndelete, nadd):
|
def update_nextcloud_user(self, user_id : str, user : DDUser, ndelete : Iterable[Any], nadd : Iterable[Any]) -> None:
|
||||||
|
|
||||||
## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again
|
## TODO: Disable de user? Is really needed? it is disabled in keycloak, so can't login again
|
||||||
## ocs/v1.php/cloud/users/{userid}/disable
|
## ocs/v1.php/cloud/users/{userid}/disable
|
||||||
|
@ -1676,21 +1722,21 @@ class Admin:
|
||||||
|
|
||||||
def add_nextcloud_user(
|
def add_nextcloud_user(
|
||||||
self,
|
self,
|
||||||
username,
|
username : str,
|
||||||
email,
|
email : str,
|
||||||
quota,
|
quota : Any,
|
||||||
first_name,
|
first_name : str,
|
||||||
last_name,
|
last_name : str,
|
||||||
groups,
|
groups : str,
|
||||||
password="*12" + secrets.token_urlsafe(16),
|
password : str = "*12" + secrets.token_urlsafe(16),
|
||||||
):
|
) -> None:
|
||||||
log.warning(
|
log.warning(
|
||||||
" NEXTCLOUD USERS: Creating nextcloud user: "
|
" NEXTCLOUD USERS: Creating nextcloud user: "
|
||||||
+ username
|
+ username
|
||||||
+ " in groups "
|
+ " in groups "
|
||||||
+ str(groups)
|
+ str(groups)
|
||||||
)
|
)
|
||||||
ev = Events("Add user", username)
|
ev = Events(self.app, "Add user", username)
|
||||||
try:
|
try:
|
||||||
# Quota is "1 GB", "500 MB"
|
# Quota is "1 GB", "500 MB"
|
||||||
self.nextcloud.add_user_with_groups(
|
self.nextcloud.add_user_with_groups(
|
||||||
|
@ -1704,41 +1750,44 @@ class Admin:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
def delete_users(self, data):
|
def delete_users(self, data : List[DDUser]) -> None:
|
||||||
ev = Events("Bulk actions", "Deleting users:", total=len(data))
|
ev = Events(self.app, "Bulk actions", "Deleting users:", total=len(data))
|
||||||
for user in data:
|
for user in data:
|
||||||
ev.increment({"name": user["username"], "data": user["username"]})
|
ev.increment({"name": user["username"], "data": user["username"]})
|
||||||
self.delete_user(user["id"])
|
self.delete_user(user["id"])
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def delete_user(self, userid):
|
def delete_user(self, userid : str) -> bool:
|
||||||
log.warning("Deleting user moodle, nextcloud keycloak")
|
log.warning("Deleting user moodle, nextcloud keycloak")
|
||||||
ev = Events("Deleting user", "Deleting from moodle")
|
ev = Events(self.app, "Deleting user", "Deleting from moodle")
|
||||||
self.delete_moodle_user(userid)
|
self.delete_moodle_user(userid)
|
||||||
ev.update_text("Deleting from nextcloud")
|
ev.update_text("Deleting from nextcloud")
|
||||||
self.delete_nextcloud_user(userid)
|
self.delete_nextcloud_user(userid)
|
||||||
ev.update_text("Deleting from keycloak")
|
ev.update_text("Deleting from keycloak")
|
||||||
self.delete_keycloak_user(userid)
|
self.delete_keycloak_user(userid)
|
||||||
|
|
||||||
|
ev.update_text("Deleting in other apps")
|
||||||
|
self.third_party_delete_user(userid)
|
||||||
|
|
||||||
ev.update_text("Syncing data from applications...")
|
ev.update_text("Syncing data from applications...")
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
ev.update_text("User deleted")
|
ev.update_text("User deleted")
|
||||||
sio_event_send("delete_user", {"userid": userid})
|
sio_event_send(self.app, "delete_user", {"userid": userid})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_user(self, userid):
|
def get_user(self, userid : str) -> Optional[DDUser]:
|
||||||
user = [u for u in self.internal["users"] if u["id"] == userid]
|
user : List[DDUser] = [u for u in self.internal["users"] if u["id"] == userid]
|
||||||
if not len(user):
|
if not len(user):
|
||||||
return False
|
return None
|
||||||
return user[0]
|
return user[0]
|
||||||
|
|
||||||
def get_user_username(self, username):
|
def get_user_username(self, username : str) -> Optional[DDUser]:
|
||||||
user = [u for u in self.internal["users"] if u["username"] == username]
|
user : List[DDUser] = [u for u in self.internal["users"] if u["username"] == username]
|
||||||
if not len(user):
|
if not len(user):
|
||||||
return False
|
return None
|
||||||
return user[0]
|
return user[0]
|
||||||
|
|
||||||
def add_user(self, u):
|
def add_user(self, u : DDUser) -> Any:
|
||||||
|
|
||||||
pathslist = []
|
pathslist = []
|
||||||
for group in u["groups"]:
|
for group in u["groups"]:
|
||||||
pathpart = ""
|
pathpart = ""
|
||||||
|
@ -1767,7 +1816,7 @@ class Admin:
|
||||||
|
|
||||||
### KEYCLOAK
|
### KEYCLOAK
|
||||||
#######################
|
#######################
|
||||||
ev = Events("Add user", u["username"], total=5)
|
ev = Events(self.app, "Add user", u["username"], total=5)
|
||||||
log.warning(" KEYCLOAK USERS: Adding user: " + u["username"])
|
log.warning(" KEYCLOAK USERS: Adding user: " + u["username"])
|
||||||
uid = self.keycloak.add_user(
|
uid = self.keycloak.add_user(
|
||||||
u["username"],
|
u["username"],
|
||||||
|
@ -1810,16 +1859,16 @@ class Admin:
|
||||||
u["last"],
|
u["last"],
|
||||||
)[0]["id"]
|
)[0]["id"]
|
||||||
ev.increment({"name": "Added to moodle", "data": []})
|
ev.increment({"name": "Added to moodle", "data": []})
|
||||||
except UserExists:
|
except UserExists as ex:
|
||||||
log.error(" -->> User already exists")
|
log.error(" -->> User already exists")
|
||||||
error = Events("User already exists.", str(se), type="error")
|
error = Events(self.app, "User already exists.", str(ex), type="error")
|
||||||
except SystemError as se:
|
except SystemError as ex:
|
||||||
log.error("Moodle create user error: " + str(se))
|
log.error("Moodle create user error: " + str(ex))
|
||||||
error = Events("Moodle create user error", str(se), type="error")
|
error = Events(self.app, "Moodle create user error", str(ex), type="error")
|
||||||
except:
|
except:
|
||||||
log.error(" -->> Error creating on moodle the user: " + u["username"])
|
log.error(" -->> Error creating on moodle the user: " + u["username"])
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
error = Events("Internal error", "Check logs", type="error")
|
error = Events(self.app, "Internal error", "Check logs", type="error")
|
||||||
|
|
||||||
# Add user to cohort
|
# Add user to cohort
|
||||||
## Get all existing moodle cohorts
|
## Get all existing moodle cohorts
|
||||||
|
@ -1874,31 +1923,32 @@ class Admin:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
|
|
||||||
|
self.third_party_add_user(uid, u)
|
||||||
|
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
sio_event_send("new_user", u)
|
sio_event_send(self.app, "new_user", u)
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
def add_group(self, g):
|
def add_group(self, g : DDGroup) -> str:
|
||||||
# TODO: Check if exists
|
# TODO: Check if exists
|
||||||
|
|
||||||
# We add in keycloak with his name, will be shown in app with full path with dots
|
# We add in keycloak with his name, will be shown in app with full path with dots
|
||||||
if g["parent"] != None:
|
if g["parent"] != None:
|
||||||
g["parent"] = gid2kpath(g["parent"])
|
g["parent"] = gid2kpath(g["parent"])
|
||||||
|
|
||||||
new_path = self.keycloak.add_group(g["name"], g["parent"])
|
new_path_kc = self.keycloak.add_group(g["name"], g["parent"])
|
||||||
|
|
||||||
|
new_path : str = g["name"]
|
||||||
if g["parent"] != None:
|
if g["parent"] != None:
|
||||||
new_path = kpath2gid(new_path["path"])
|
new_path = kpath2gid(new_path_kc["path"])
|
||||||
else:
|
|
||||||
new_path = g["name"]
|
|
||||||
|
|
||||||
self.moodle.add_system_cohort(new_path, description=g["description"])
|
self.moodle.add_system_cohort(new_path, description=g["description"])
|
||||||
self.nextcloud.add_group(new_path)
|
self.nextcloud.add_group(new_path)
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
return new_path
|
return new_path
|
||||||
|
|
||||||
def delete_group_by_id(self, group_id):
|
def delete_group_by_id(self, group_id : str) -> None:
|
||||||
ev = Events("Deleting group", "Deleting from keycloak")
|
ev = Events(self.app, "Deleting group", "Deleting from keycloak")
|
||||||
try:
|
try:
|
||||||
keycloak_group = self.keycloak.get_group_by_id(group_id)
|
keycloak_group = self.keycloak.get_group_by_id(group_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -1918,7 +1968,7 @@ class Admin:
|
||||||
try:
|
try:
|
||||||
self.keycloak.delete_group(group_id)
|
self.keycloak.delete_group(group_id)
|
||||||
except:
|
except:
|
||||||
log.error("KEYCLOAK GROUPS: Could no delete group " + group["path"])
|
log.error("KEYCLOAK GROUPS: Could no delete group " + group_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
cohorts = self.moodle.get_cohorts()
|
cohorts = self.moodle.get_cohorts()
|
||||||
|
@ -1932,7 +1982,7 @@ class Admin:
|
||||||
self.nextcloud.delete_group(sg_gid)
|
self.nextcloud.delete_group(sg_gid)
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def delete_group_by_path(self, path):
|
def delete_group_by_path(self, path : str) -> None:
|
||||||
group = self.keycloak.get_group_by_path(path)
|
group = self.keycloak.get_group_by_path(path)
|
||||||
|
|
||||||
to_be_deleted = []
|
to_be_deleted = []
|
||||||
|
@ -1953,6 +2003,3 @@ class Admin:
|
||||||
self.moodle.delete_cohorts(cohort)
|
self.moodle.delete_cohorts(cohort)
|
||||||
self.nextcloud.delete_group(gid)
|
self.nextcloud.delete_group(gid)
|
||||||
self.resync_data()
|
self.resync_data()
|
||||||
|
|
||||||
def set_nextcloud_user_mail(self, data):
|
|
||||||
self.nextcloud.set_user_mail(data)
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -23,10 +24,11 @@ import logging as log
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from flask import jsonify, request
|
from typing import Any, Dict, Union, List
|
||||||
|
|
||||||
from admin import app
|
from flask import request
|
||||||
|
|
||||||
|
# TODO: Improve these constants' structure
|
||||||
content_type = {"Content-Type": "application/json"}
|
content_type = {"Content-Type": "application/json"}
|
||||||
ex = {
|
ex = {
|
||||||
"bad_request": {
|
"bad_request": {
|
||||||
|
@ -96,8 +98,10 @@ ex = {
|
||||||
|
|
||||||
|
|
||||||
class Error(Exception):
|
class Error(Exception):
|
||||||
def __init__(self, error="bad_request", description="", debug="", data=None):
|
status_code : int
|
||||||
self.error = ex[error]["error"].copy()
|
content_type : Dict[str, str]
|
||||||
|
def __init__(self, error : str ="bad_request", description : str="", debug : Union[str, List[str]]="", data : Any =None):
|
||||||
|
self.error : Dict[str, str] = (ex[error]["error"]).copy() # type: ignore # bad struct
|
||||||
self.error["function"] = (
|
self.error["function"] = (
|
||||||
inspect.stack()[1][1].split(os.sep)[-1]
|
inspect.stack()[1][1].split(os.sep)[-1]
|
||||||
+ ":"
|
+ ":"
|
||||||
|
@ -123,7 +127,7 @@ class Error(Exception):
|
||||||
"----------- REQUEST START -----------",
|
"----------- REQUEST START -----------",
|
||||||
request.method + " " + request.url,
|
request.method + " " + request.url,
|
||||||
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
|
"\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()),
|
||||||
request.body if hasattr(request, "body") else "",
|
getattr(request, "body", ""),
|
||||||
"----------- REQUEST STOP -----------",
|
"----------- REQUEST STOP -----------",
|
||||||
)
|
)
|
||||||
if request
|
if request
|
||||||
|
@ -138,7 +142,7 @@ class Error(Exception):
|
||||||
if data
|
if data
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
self.status_code = ex[error]["status_code"]
|
self.status_code = ex[error]["status_code"] # type: ignore # bad struct
|
||||||
self.content_type = content_type
|
self.content_type = content_type
|
||||||
log.debug(
|
log.debug(
|
||||||
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
|
"%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s"
|
||||||
|
@ -152,11 +156,3 @@ class Error(Exception):
|
||||||
self.error["data"],
|
self.error["data"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(Error)
|
|
||||||
def handle_user_error(ex):
|
|
||||||
response = jsonify(ex.error)
|
|
||||||
response.status_code = ex.status_code
|
|
||||||
response.headers = {"content-type": content_type}
|
|
||||||
return response
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -26,11 +27,13 @@ from minio.commonconfig import REPLACE, CopySource
|
||||||
from minio.deleteobjects import DeleteObject
|
from minio.deleteobjects import DeleteObject
|
||||||
from requests import get, post
|
from requests import get, post
|
||||||
|
|
||||||
from admin import app
|
from typing import Any, Callable, Dict, Iterable, List
|
||||||
|
|
||||||
|
|
||||||
class Avatars:
|
class Avatars:
|
||||||
def __init__(self):
|
avatars_path : str
|
||||||
|
def __init__(self, avatars_path : str):
|
||||||
|
self.avatars_path = avatars_path
|
||||||
self.mclient = Minio(
|
self.mclient = Minio(
|
||||||
"dd-sso-avatars:9000",
|
"dd-sso-avatars:9000",
|
||||||
access_key="AKIAIOSFODNN7EXAMPLE",
|
access_key="AKIAIOSFODNN7EXAMPLE",
|
||||||
|
@ -41,21 +44,22 @@ class Avatars:
|
||||||
self._minio_set_realm()
|
self._minio_set_realm()
|
||||||
# self.update_missing_avatars()
|
# self.update_missing_avatars()
|
||||||
|
|
||||||
def add_user_default_avatar(self, userid, role="unknown"):
|
def add_user_default_avatar(self, userid : str, role : str="unknown") -> None:
|
||||||
|
path = os.path.join(self.avatars_path, role) + ".jpg",
|
||||||
self.mclient.fput_object(
|
self.mclient.fput_object(
|
||||||
self.bucket,
|
self.bucket,
|
||||||
userid,
|
userid,
|
||||||
os.path.join(app.root_path, "../custom/avatars/" + role + ".jpg"),
|
path,
|
||||||
content_type="image/jpeg ",
|
content_type="image/jpeg ",
|
||||||
)
|
)
|
||||||
log.warning(
|
log.warning(
|
||||||
" AVATARS: Updated avatar for user " + userid + " with role " + role
|
" AVATARS: Updated avatar for user " + userid + " with role " + role
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_user_avatar(self, userid):
|
def delete_user_avatar(self, userid : str) -> None:
|
||||||
self.minio_delete_object(userid)
|
self.minio_delete_object(userid)
|
||||||
|
|
||||||
def update_missing_avatars(self, users):
|
def update_missing_avatars(self, users : Iterable[Dict[str, Any]]) -> None:
|
||||||
sys_roles = ["admin", "manager", "teacher", "student"]
|
sys_roles = ["admin", "manager", "teacher", "student"]
|
||||||
for u in self.get_users_without_image(users):
|
for u in self.get_users_without_image(users):
|
||||||
try:
|
try:
|
||||||
|
@ -63,10 +67,11 @@ class Avatars:
|
||||||
except:
|
except:
|
||||||
img = "unknown.jpg"
|
img = "unknown.jpg"
|
||||||
|
|
||||||
|
path = os.path.join(self.avatars_path, img)
|
||||||
self.mclient.fput_object(
|
self.mclient.fput_object(
|
||||||
self.bucket,
|
self.bucket,
|
||||||
u["id"],
|
u["id"],
|
||||||
os.path.join(app.root_path, "../custom/avatars/" + img),
|
path,
|
||||||
content_type="image/jpeg ",
|
content_type="image/jpeg ",
|
||||||
)
|
)
|
||||||
log.warning(
|
log.warning(
|
||||||
|
@ -76,26 +81,24 @@ class Avatars:
|
||||||
+ img.split(".")[0]
|
+ img.split(".")[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _minio_set_realm(self):
|
def _minio_set_realm(self) -> None:
|
||||||
if not self.mclient.bucket_exists(self.bucket):
|
if not self.mclient.bucket_exists(self.bucket):
|
||||||
self.mclient.make_bucket(self.bucket)
|
self.mclient.make_bucket(self.bucket)
|
||||||
|
|
||||||
def minio_get_objects(self):
|
def minio_get_objects(self) -> List[Any]:
|
||||||
return [o.object_name for o in self.mclient.list_objects(self.bucket)]
|
return [o.object_name for o in self.mclient.list_objects(self.bucket)]
|
||||||
|
|
||||||
def minio_delete_all_objects(self):
|
def minio_delete_all_objects(self) -> None:
|
||||||
delete_object_list = map(
|
f : Callable[[Any], Any] = lambda x: DeleteObject(x.object_name)
|
||||||
lambda x: DeleteObject(x.object_name),
|
delete_object_list = map(f, self.mclient.list_objects(self.bucket))
|
||||||
self.mclient.list_objects(self.bucket),
|
|
||||||
)
|
|
||||||
errors = self.mclient.remove_objects(self.bucket, delete_object_list)
|
errors = self.mclient.remove_objects(self.bucket, delete_object_list)
|
||||||
for error in errors:
|
for error in errors:
|
||||||
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
|
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
|
||||||
|
|
||||||
def minio_delete_object(self, oid):
|
def minio_delete_object(self, oid : str) -> None:
|
||||||
errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
|
errors = self.mclient.remove_objects(self.bucket, [DeleteObject(oid)])
|
||||||
for error in errors:
|
for error in errors:
|
||||||
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
|
log.error(" AVATARS: Error occured when deleting avatar object: " + error)
|
||||||
|
|
||||||
def get_users_without_image(self, users):
|
def get_users_without_image(self, users : Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
|
||||||
return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]
|
return [u for u in users if u["id"] and u["id"] not in self.minio_get_objects()]
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from attr import define
|
||||||
|
|
||||||
|
from admin.lib.keys import ThirdPartyIntegrationKeys
|
||||||
|
|
||||||
|
DDUser = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def user_parser(dduser: DDUser) -> DDUser:
|
||||||
|
user = copy.deepcopy(dduser)
|
||||||
|
user["keycloak_id"] = user.pop("id")
|
||||||
|
user["role"] = user["roles"][0] if user.get("roles", []) else None
|
||||||
|
user["groups"] = user.get("groups", user.get("keycloak_groups", []))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@define
|
||||||
|
class ThirdPartyCallbacks:
|
||||||
|
"""
|
||||||
|
If necessary this class may be inherited from and customised.
|
||||||
|
By default it uses the configured endpoints to send data to the third-party.
|
||||||
|
This data is sent using ThirdPartyIntegrationKeys, which takes care of
|
||||||
|
encrypting first, then signing, with the service-specific keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tpkeys: ThirdPartyIntegrationKeys
|
||||||
|
"""
|
||||||
|
The ThirdPartyIntegrationKeys instance where domain and keys are setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
endpoint_add_users: Tuple[
|
||||||
|
str,
|
||||||
|
str,
|
||||||
|
] = ("POST", "/api/users/")
|
||||||
|
"""
|
||||||
|
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
endpoint_update_users: Tuple[
|
||||||
|
str,
|
||||||
|
str,
|
||||||
|
] = ("PUT", "/api/users/")
|
||||||
|
"""
|
||||||
|
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
endpoint_delete_users: Tuple[
|
||||||
|
str,
|
||||||
|
str,
|
||||||
|
] = ("DELETE", "/api/users/")
|
||||||
|
"""
|
||||||
|
An HTTP_METHOD, ENDPOINT tuple, where ENDPOINT is an absolute path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def add_users_url(self) -> str:
|
||||||
|
return f"{self.tpkeys.their_service_domain}{self.endpoint_add_users[1]}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def update_users_url(self) -> str:
|
||||||
|
return f"{self.tpkeys.their_service_domain}{self.endpoint_update_users[1]}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delete_users_url(self) -> str:
|
||||||
|
return f"{self.tpkeys.their_service_domain}{self.endpoint_delete_users[1]}"
|
||||||
|
|
||||||
|
def _request(self, method: str, url: str, data: DDUser) -> bool:
|
||||||
|
# The endpoints are prepared for batch operations, but the way
|
||||||
|
# the admin lib is set up, it is currently not doable.
|
||||||
|
prepared_data = [user_parser(data)]
|
||||||
|
try:
|
||||||
|
enc_data = self.tpkeys.sign_and_encrypt_outgoing_json(prepared_data)
|
||||||
|
headers = self.tpkeys.get_outgoing_request_headers()
|
||||||
|
res = requests.request(method, url, data=enc_data, headers=headers)
|
||||||
|
except:
|
||||||
|
# Something went wrong sending the request
|
||||||
|
return False
|
||||||
|
return res.status_code == 200
|
||||||
|
|
||||||
|
def add_user(self, user_id: str, user: DDUser) -> bool:
|
||||||
|
data = copy.deepcopy(user)
|
||||||
|
data["id"] = user_id
|
||||||
|
return self._request(self.endpoint_add_users[0], self.add_users_url, data)
|
||||||
|
|
||||||
|
def update_user(self, user_id: str, user: DDUser) -> bool:
|
||||||
|
data = copy.deepcopy(user)
|
||||||
|
data["id"] = user_id
|
||||||
|
return self._request(self.endpoint_update_users[0], self.update_users_url, data)
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str) -> bool:
|
||||||
|
data = {"id": user_id}
|
||||||
|
return self._request(self.endpoint_delete_users[0], self.delete_users_url, data)
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -29,16 +30,25 @@ import yaml
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from schema import And, Optional, Schema, SchemaError, Use
|
from schema import And, Optional, Schema, SchemaError, Use
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, Any, Dict
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
class Dashboard:
|
class Dashboard:
|
||||||
|
app : "AdminFlaskApp"
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
):
|
app : "AdminFlaskApp",
|
||||||
self.custom_menu = os.path.join(app.root_path, "../custom/menu/custom.yaml")
|
) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
def _update_custom_menu(self, custom_menu_part):
|
@property
|
||||||
|
def custom_menu(self) -> str:
|
||||||
|
return os.path.join(self.app.custom_dir, "menu/custom.yaml")
|
||||||
|
|
||||||
|
def _update_custom_menu(self, custom_menu_part : Dict[str, Any]) -> bool:
|
||||||
with open(self.custom_menu) as yml:
|
with open(self.custom_menu) as yml:
|
||||||
menu = yaml.load(yml, Loader=yaml.FullLoader)
|
menu = yaml.load(yml, Loader=yaml.FullLoader)
|
||||||
menu = {**menu, **custom_menu_part}
|
menu = {**menu, **custom_menu_part}
|
||||||
|
@ -46,7 +56,7 @@ class Dashboard:
|
||||||
yml.write(yaml.dump(menu, default_flow_style=False))
|
yml.write(yaml.dump(menu, default_flow_style=False))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update_colours(self, colours):
|
def update_colours(self, colours : Dict[str, Any]) -> bool:
|
||||||
schema_template = Schema(
|
schema_template = Schema(
|
||||||
{
|
{
|
||||||
"background": And(Use(str)),
|
"background": And(Use(str)),
|
||||||
|
@ -63,7 +73,7 @@ class Dashboard:
|
||||||
self._update_custom_menu({"colours": colours})
|
self._update_custom_menu({"colours": colours})
|
||||||
return self.apply_updates()
|
return self.apply_updates()
|
||||||
|
|
||||||
def update_menu(self, menu):
|
def update_menu(self, menu : Dict[str, Any]) -> bool:
|
||||||
items = []
|
items = []
|
||||||
for menu_item in menu.keys():
|
for menu_item in menu.keys():
|
||||||
for mustexist_key in ["href", "icon", "name", "shortname"]:
|
for mustexist_key in ["href", "icon", "name", "shortname"]:
|
||||||
|
@ -73,16 +83,16 @@ class Dashboard:
|
||||||
self._update_custom_menu({"apps_external": items})
|
self._update_custom_menu({"apps_external": items})
|
||||||
return self.apply_updates()
|
return self.apply_updates()
|
||||||
|
|
||||||
def update_logo(self, logo):
|
def update_logo(self, logo : FileStorage) -> bool:
|
||||||
img = Image.open(logo.stream)
|
img = Image.open(logo.stream)
|
||||||
img.save(os.path.join(app.root_path, "../custom/img/logo.png"))
|
img.save(os.path.join(self.app.custom_dir, "img/logo.png"))
|
||||||
return self.apply_updates()
|
return self.apply_updates()
|
||||||
|
|
||||||
def update_background(self, background):
|
def update_background(self, background : FileStorage) -> bool:
|
||||||
img = Image.open(background.stream)
|
img = Image.open(background.stream)
|
||||||
img.save(os.path.join(app.root_path, "../custom/img/background.png"))
|
img.save(os.path.join(self.app.custom_dir, "img/background.png"))
|
||||||
return self.apply_updates()
|
return self.apply_updates()
|
||||||
|
|
||||||
def apply_updates(self):
|
def apply_updates(self) -> bool:
|
||||||
resp = requests.get("http://dd-sso-api:7039/restart")
|
resp = requests.get("http://dd-sso-api:7039/restart")
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -38,34 +39,46 @@ from flask_socketio import (
|
||||||
send,
|
send,
|
||||||
)
|
)
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, Any, Dict
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
|
|
||||||
def sio_event_send(event, data):
|
def sio_event_send(app : "AdminFlaskApp", event : str, data : Dict[str, Any]) -> None:
|
||||||
app.socketio.emit(
|
app.socketio.emit(
|
||||||
event,
|
event,
|
||||||
json.dumps(data),
|
json.dumps(data),
|
||||||
namespace="/sio/events",
|
namespace="/sio/events",
|
||||||
room="events",
|
room="events",
|
||||||
)
|
)
|
||||||
|
# TODO: Why on earth do we find these all over the place?
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
|
|
||||||
class Events:
|
class Events:
|
||||||
def __init__(self, title, text="", total=0, table=False, type="info"):
|
app : "AdminFlaskApp"
|
||||||
|
eid : str
|
||||||
|
title : str
|
||||||
|
text : str
|
||||||
|
total : int
|
||||||
|
table : str
|
||||||
|
type : str
|
||||||
|
def __init__(self, app : "AdminFlaskApp", title : str, text : str="", total : int=0, table : str="", type : str="info") -> None:
|
||||||
|
self.app = app
|
||||||
# notice, info, success, and error
|
# notice, info, success, and error
|
||||||
self.eid = str(base64.b64encode(os.urandom(32))[:8])
|
self.eid = str(base64.b64encode(os.urandom(32))[:8])
|
||||||
self.title = title
|
self.title = title
|
||||||
self.text = text
|
self.text = text
|
||||||
self.total = total
|
self.total = total
|
||||||
|
# TODO: this is probably replacing the .table method????
|
||||||
self.table = table
|
self.table = table
|
||||||
self.item = 0
|
self.item = 0
|
||||||
self.type = type
|
self.type = type
|
||||||
self.create()
|
self.create()
|
||||||
|
|
||||||
def create(self):
|
def create(self) -> None:
|
||||||
log.info("START " + self.eid + ": " + self.text)
|
log.info("START " + self.eid + ": " + self.text)
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-create",
|
"notify-create",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
@ -80,9 +93,9 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self) -> None:
|
||||||
log.info("END " + self.eid + ": " + self.text)
|
log.info("END " + self.eid + ": " + self.text)
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-destroy",
|
"notify-destroy",
|
||||||
json.dumps({"id": self.eid}),
|
json.dumps({"id": self.eid}),
|
||||||
namespace="/sio",
|
namespace="/sio",
|
||||||
|
@ -90,9 +103,9 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
def update_text(self, text):
|
def update_text(self, text : str) -> None:
|
||||||
self.text = text
|
self.text = text
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-update",
|
"notify-update",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
@ -105,9 +118,9 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
def append_text(self, text):
|
def append_text(self, text : str) -> None:
|
||||||
self.text = self.text + "<br>" + text
|
self.text = self.text + "<br>" + text
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-update",
|
"notify-update",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
@ -120,10 +133,10 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
def increment(self, data={"name": "", "data": []}):
|
def increment(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
|
||||||
self.item += 1
|
self.item += 1
|
||||||
log.info("INCREMENT " + self.eid + ": " + self.text)
|
log.info("INCREMENT " + self.eid + ": " + self.text)
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-increment",
|
"notify-increment",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
@ -149,10 +162,10 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.0001)
|
sleep(0.0001)
|
||||||
|
|
||||||
def decrement(self, data={"name": "", "data": []}):
|
def decrement(self, data : Dict[str, Any]={"name": "", "data": []}) -> None:
|
||||||
self.item -= 1
|
self.item -= 1
|
||||||
log.info("DECREMENT " + self.eid + ": " + self.text)
|
log.info("DECREMENT " + self.eid + ": " + self.text)
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"notify-decrement",
|
"notify-decrement",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
@ -178,13 +191,13 @@ class Events:
|
||||||
)
|
)
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
def reload(self):
|
def reload(self) -> None:
|
||||||
app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
|
self.app.socketio.emit("reload", json.dumps({}), namespace="/sio", room="admin")
|
||||||
sleep(0.0001)
|
sleep(0.0001)
|
||||||
|
|
||||||
def table(self, event, table, data={}):
|
def table(self, event : str, table : str, data : Dict[str, Any]={}) -> None:
|
||||||
# refresh, add, delete, update
|
# refresh, add, delete, update
|
||||||
app.socketio.emit(
|
self.app.socketio.emit(
|
||||||
"table_" + event,
|
"table_" + event,
|
||||||
json.dumps({"table": table, "data": data}),
|
json.dumps({"table": table, "data": data}),
|
||||||
namespace="/sio",
|
namespace="/sio",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -22,8 +23,11 @@ import string
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
|
|
||||||
|
from typing import Any, Dict, Generator, Iterable, Optional, List
|
||||||
|
|
||||||
def get_recursive_groups(l_groups, l):
|
DDGroup = Dict[str, Any]
|
||||||
|
|
||||||
|
def get_recursive_groups(l_groups : Iterable[DDGroup], l : List[DDGroup]) -> List[DDGroup]:
|
||||||
for d_group in l_groups:
|
for d_group in l_groups:
|
||||||
data = {}
|
data = {}
|
||||||
for key, value in d_group.items():
|
for key, value in d_group.items():
|
||||||
|
@ -35,11 +39,11 @@ def get_recursive_groups(l_groups, l):
|
||||||
return l
|
return l
|
||||||
|
|
||||||
|
|
||||||
def get_group_with_childs(keycloak_group):
|
def get_group_with_childs(keycloak_group : DDGroup) -> List[str]:
|
||||||
return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
|
return [g["path"] for g in get_recursive_groups([keycloak_group], [])]
|
||||||
|
|
||||||
|
|
||||||
def system_username(username):
|
def system_username(username : str) -> bool:
|
||||||
return (
|
return (
|
||||||
True
|
True
|
||||||
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
|
if username in ["guest", "ddadmin", "admin"] or username.startswith("system_")
|
||||||
|
@ -47,41 +51,43 @@ def system_username(username):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def system_group(groupname):
|
def system_group(groupname : str) -> bool:
|
||||||
return True if groupname in ["admin", "manager", "teacher", "student"] else False
|
return True if groupname in ["admin", "manager", "teacher", "student"] else False
|
||||||
|
|
||||||
|
|
||||||
def get_group_from_group_id(group_id, groups):
|
def get_group_from_group_id(group_id : str, groups : Iterable[DDGroup]) -> Optional[DDGroup]:
|
||||||
return next((d for d in groups if d.get("id") == group_id), None)
|
return next((d for d in groups if d.get("id") == group_id), None)
|
||||||
|
|
||||||
|
|
||||||
def get_kid_from_kpath(kpath, groups):
|
def get_kid_from_kpath(kpath : str, groups : Iterable[DDGroup]) -> Optional[str]:
|
||||||
ids = [g["id"] for g in groups if g["path"] == kpath]
|
ids : List[str] = [g["id"] for g in groups if g["path"] == kpath]
|
||||||
if not len(ids) or len(ids) > 1:
|
if len(ids) != 1:
|
||||||
return False
|
return None
|
||||||
return ids[0]
|
return ids[0]
|
||||||
|
|
||||||
|
|
||||||
def get_gid_from_kgroup_id(kgroup_id, groups):
|
def get_gid_from_kgroup_id(kgroup_id : str, groups : Iterable[DDGroup]) -> str:
|
||||||
return [
|
# TODO: Why is this interface different from get_kid_from_kpath?
|
||||||
|
o : List[str] = [
|
||||||
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
|
g["path"].replace("/", ".")[1:] if len(g["path"].split("/")) else g["path"][1:]
|
||||||
for g in groups
|
for g in groups
|
||||||
if g["id"] == kgroup_id
|
if g["id"] == kgroup_id
|
||||||
][0]
|
]
|
||||||
|
return o[0]
|
||||||
|
|
||||||
|
|
||||||
def get_gids_from_kgroup_ids(kgroup_ids, groups):
|
def get_gids_from_kgroup_ids(kgroup_ids : Iterable[str], groups : Iterable[DDGroup]) -> List[str]:
|
||||||
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
|
return [get_gid_from_kgroup_id(kgroup_id, groups) for kgroup_id in kgroup_ids]
|
||||||
|
|
||||||
|
|
||||||
def kpath2gid(path):
|
def kpath2gid(path : str) -> str:
|
||||||
# print(path.replace('/','.')[1:])
|
# print(path.replace('/','.')[1:])
|
||||||
if path.startswith("/"):
|
if path.startswith("/"):
|
||||||
return path.replace("/", ".")[1:]
|
return path.replace("/", ".")[1:]
|
||||||
return path.replace("/", ".")
|
return path.replace("/", ".")
|
||||||
|
|
||||||
|
|
||||||
def kpath2gids(path):
|
def kpath2gids(path : str) -> List[str]:
|
||||||
path = kpath2gid(path)
|
path = kpath2gid(path)
|
||||||
l = []
|
l = []
|
||||||
for i in range(len(path.split("."))):
|
for i in range(len(path.split("."))):
|
||||||
|
@ -89,44 +95,45 @@ def kpath2gids(path):
|
||||||
return l
|
return l
|
||||||
|
|
||||||
|
|
||||||
def kpath2kpaths(path):
|
def kpath2kpaths(path : str) -> List[str]:
|
||||||
l = []
|
l = []
|
||||||
for i in range(len(path.split("/"))):
|
for i in range(len(path.split("/"))):
|
||||||
l.append("/".join(path.split("/")[: i + 1]))
|
l.append("/".join(path.split("/")[: i + 1]))
|
||||||
return l[1:]
|
return l[1:]
|
||||||
|
|
||||||
|
|
||||||
def gid2kpath(gid):
|
def gid2kpath(gid : str) -> str:
|
||||||
return "/" + gid.replace(".", "/")
|
return "/" + gid.replace(".", "/")
|
||||||
|
|
||||||
|
|
||||||
def count_repeated(itemslist):
|
def count_repeated(itemslist : Iterable[Any]) -> None:
|
||||||
print(Counter(itemslist))
|
print(Counter(itemslist))
|
||||||
|
|
||||||
|
|
||||||
def groups_kname2gid(groups):
|
def groups_kname2gid(groups : Iterable[str]) -> List[str]:
|
||||||
return [name.replace(".", "/") for name in groups]
|
return [name.replace(".", "/") for name in groups]
|
||||||
|
|
||||||
|
|
||||||
def groups_path2id(groups):
|
def groups_path2id(groups : Iterable[str]) -> List[str]:
|
||||||
return [g.replace("/", ".")[1:] for g in groups]
|
return [g.replace("/", ".")[1:] for g in groups]
|
||||||
|
|
||||||
|
|
||||||
def groups_id2path(groups):
|
def groups_id2path(groups : Iterable[str]) -> List[str]:
|
||||||
return ["/" + g.replace(".", "/") for g in groups]
|
return ["/" + g.replace(".", "/") for g in groups]
|
||||||
|
|
||||||
|
|
||||||
def filter_roles_list(role_list):
|
def filter_roles_list(role_list : Iterable[str]) -> List[str]:
|
||||||
client_roles = ["admin", "manager", "teacher", "student"]
|
client_roles = ["admin", "manager", "teacher", "student"]
|
||||||
return [r for r in role_list if r in client_roles]
|
return [r for r in role_list if r in client_roles]
|
||||||
|
|
||||||
|
|
||||||
def filter_roles_listofdicts(role_listofdicts):
|
def filter_roles_listofdicts(role_listofdicts : Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
client_roles = ["admin", "manager", "teacher", "student"]
|
client_roles = ["admin", "manager", "teacher", "student"]
|
||||||
return [r for r in role_listofdicts if r["name"] in client_roles]
|
return [r for r in role_listofdicts if r["name"] in client_roles]
|
||||||
|
|
||||||
|
|
||||||
def rand_password(lenght):
|
def rand_password(lenght : int) -> str:
|
||||||
|
# TODO: why is this not using py3's secrets?
|
||||||
characters = string.ascii_letters + string.digits + string.punctuation
|
characters = string.ascii_letters + string.digits + string.punctuation
|
||||||
passwd = "".join(random.choice(characters) for i in range(lenght))
|
passwd = "".join(random.choice(characters) for i in range(lenght))
|
||||||
while not any(ele.isupper() for ele in passwd):
|
while not any(ele.isupper() for ele in passwd):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -34,23 +35,33 @@ from .api_exceptions import Error
|
||||||
from .helpers import get_recursive_groups, kpath2kpaths
|
from .helpers import get_recursive_groups, kpath2kpaths
|
||||||
from .postgres import Postgres
|
from .postgres import Postgres
|
||||||
|
|
||||||
# from admin import app
|
from typing import cast, Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
DDUser = Dict[str, Any]
|
||||||
|
|
||||||
|
# TODO: Improve typing of these class and simplify it
|
||||||
|
|
||||||
class KeycloakClient:
|
class KeycloakClient:
|
||||||
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
|
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
|
||||||
https://github.com/marcospereirampj/python-keycloak
|
https://github.com/marcospereirampj/python-keycloak
|
||||||
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
|
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
|
||||||
"""
|
"""
|
||||||
|
url : str
|
||||||
|
username : str
|
||||||
|
password : str
|
||||||
|
realm : str
|
||||||
|
verify : bool
|
||||||
|
keycloak_pg : Postgres
|
||||||
|
keycloak_admin : KeycloakAdmin
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url="http://dd-sso-keycloak:8080/auth/",
|
url : str="http://dd-sso-keycloak:8080/auth/",
|
||||||
username=os.environ["KEYCLOAK_USER"],
|
username : str=os.environ["KEYCLOAK_USER"],
|
||||||
password=os.environ["KEYCLOAK_PASSWORD"],
|
password : str=os.environ["KEYCLOAK_PASSWORD"],
|
||||||
realm="master",
|
realm : str="master",
|
||||||
verify=True,
|
verify : bool=True,
|
||||||
):
|
) -> None:
|
||||||
self.url = url
|
self.url = url
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
|
@ -64,7 +75,7 @@ class KeycloakClient:
|
||||||
os.environ["KEYCLOAK_DB_PASSWORD"],
|
os.environ["KEYCLOAK_DB_PASSWORD"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def connect(self):
|
def connect(self) -> None:
|
||||||
self.keycloak_admin = KeycloakAdmin(
|
self.keycloak_admin = KeycloakAdmin(
|
||||||
server_url=self.url,
|
server_url=self.url,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
|
@ -78,15 +89,19 @@ class KeycloakClient:
|
||||||
|
|
||||||
""" USERS """
|
""" USERS """
|
||||||
|
|
||||||
def get_user_id(self, username):
|
def get_user_id(self, username : str) -> str:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_user_id(username)
|
uid : str = self.keycloak_admin.get_user_id(username)
|
||||||
|
return uid
|
||||||
|
|
||||||
def get_users(self):
|
def get_users(self) -> Iterable[Dict[str, Any]]:
|
||||||
|
# https://www.keycloak.org/docs-api/18.0/rest-api/index.html#_userrepresentation
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_users({})
|
o : Iterable[Dict[str, Any]] = self.keycloak_admin.get_users({})
|
||||||
|
return o
|
||||||
|
|
||||||
def get_users_with_groups_and_roles(self):
|
# TODO: what is this actually doing?
|
||||||
|
def get_users_with_groups_and_roles(self) -> List[DDUser]:
|
||||||
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota
|
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota
|
||||||
,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
|
||||||
,json_agg(r.name) as role
|
,json_agg(r.name) as role
|
||||||
|
@ -125,7 +140,7 @@ class KeycloakClient:
|
||||||
|
|
||||||
return list_dict_users
|
return list_dict_users
|
||||||
|
|
||||||
def getparent(self, group_id, data):
|
def getparent(self, group_id : str, data : Iterable[Any]) -> str:
|
||||||
# Recursively get full path from any group_id in the tree
|
# Recursively get full path from any group_id in the tree
|
||||||
path = ""
|
path = ""
|
||||||
for item in data:
|
for item in data:
|
||||||
|
@ -134,14 +149,14 @@ class KeycloakClient:
|
||||||
path = f"{path}/{item[1]}"
|
path = f"{path}/{item[1]}"
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def get_group_path(self, group_id):
|
def get_group_path(self, group_id : str) -> str:
|
||||||
# Get full path using getparent recursive func
|
# Get full path using getparent recursive func
|
||||||
# RETURNS: String with full path
|
# RETURNS: String with full path
|
||||||
q = """SELECT * FROM keycloak_group"""
|
q = """SELECT * FROM keycloak_group"""
|
||||||
groups = self.keycloak_pg.select(q)
|
groups = self.keycloak_pg.select(q)
|
||||||
return self.getparent(group_id, groups)
|
return self.getparent(group_id, groups)
|
||||||
|
|
||||||
def get_user_groups_paths(self, user_id):
|
def get_user_groups_paths(self, user_id : str) -> List[str]:
|
||||||
# Get full paths for user grups
|
# Get full paths for user grups
|
||||||
# RETURNS list of paths
|
# RETURNS list of paths
|
||||||
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
|
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
|
||||||
|
@ -165,20 +180,20 @@ class KeycloakClient:
|
||||||
|
|
||||||
def add_user(
|
def add_user(
|
||||||
self,
|
self,
|
||||||
username,
|
username : str,
|
||||||
first,
|
first : str,
|
||||||
last,
|
last : str,
|
||||||
email,
|
email : str,
|
||||||
password,
|
password : str,
|
||||||
group=False,
|
group : Any=False,
|
||||||
password_temporary=True,
|
password_temporary : bool=True,
|
||||||
enabled=True,
|
enabled : bool=True,
|
||||||
):
|
) -> Any:
|
||||||
# RETURNS string with keycloak user id (the main id in this app)
|
# RETURNS string with keycloak user id (the main id in this app)
|
||||||
self.connect()
|
self.connect()
|
||||||
username = username.lower()
|
username = username.lower()
|
||||||
try:
|
try:
|
||||||
uid = self.keycloak_admin.create_user(
|
uid : Any = self.keycloak_admin.create_user(
|
||||||
{
|
{
|
||||||
"email": email,
|
"email": email,
|
||||||
"username": username,
|
"username": username,
|
||||||
|
@ -213,7 +228,7 @@ class KeycloakClient:
|
||||||
self.keycloak_admin.group_user_add(uid, gid)
|
self.keycloak_admin.group_user_add(uid, gid)
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
def update_user_pwd(self, user_id, password, password_temporary=True):
|
def update_user_pwd(self, user_id : str, password : str, password_temporary : bool=True) -> Any:
|
||||||
# Updates
|
# Updates
|
||||||
payload = {
|
payload = {
|
||||||
"credentials": [
|
"credentials": [
|
||||||
|
@ -223,7 +238,7 @@ class KeycloakClient:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.update_user(user_id, payload)
|
return self.keycloak_admin.update_user(user_id, payload)
|
||||||
|
|
||||||
def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]):
|
def user_update(self, user_id : str, enabled : bool, email : str, first : str, last : str, groups : Iterable[str]=[], roles : Iterable[str]=[]) -> Any:
|
||||||
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
|
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
|
||||||
# Updates
|
# Updates
|
||||||
payload = {
|
payload = {
|
||||||
|
@ -237,17 +252,17 @@ class KeycloakClient:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.update_user(user_id, payload)
|
return self.keycloak_admin.update_user(user_id, payload)
|
||||||
|
|
||||||
def user_enable(self, user_id):
|
def user_enable(self, user_id : str) -> Any:
|
||||||
payload = {"enabled": True}
|
payload = {"enabled": True}
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.update_user(user_id, payload)
|
return self.keycloak_admin.update_user(user_id, payload)
|
||||||
|
|
||||||
def user_disable(self, user_id):
|
def user_disable(self, user_id : str) -> Any:
|
||||||
payload = {"enabled": False}
|
payload = {"enabled": False}
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.update_user(user_id, payload)
|
return self.keycloak_admin.update_user(user_id, payload)
|
||||||
|
|
||||||
def group_user_remove(self, user_id, group_id):
|
def group_user_remove(self, user_id : str, group_id : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.group_user_remove(user_id, group_id)
|
return self.keycloak_admin.group_user_remove(user_id, group_id)
|
||||||
|
|
||||||
|
@ -255,7 +270,7 @@ class KeycloakClient:
|
||||||
# self.connect()
|
# self.connect()
|
||||||
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
|
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
|
||||||
|
|
||||||
def remove_user_realm_roles(self, user_id, roles):
|
def remove_user_realm_roles(self, user_id : str, roles : Iterable[str]) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
roles = [
|
roles = [
|
||||||
r
|
r
|
||||||
|
@ -264,66 +279,66 @@ class KeycloakClient:
|
||||||
]
|
]
|
||||||
return self.keycloak_admin.delete_user_realm_role(user_id, roles)
|
return self.keycloak_admin.delete_user_realm_role(user_id, roles)
|
||||||
|
|
||||||
def delete_user(self, userid):
|
def delete_user(self, userid : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.delete_user(user_id=userid)
|
return self.keycloak_admin.delete_user(user_id=userid)
|
||||||
|
|
||||||
def get_user_groups(self, userid):
|
def get_user_groups(self, userid : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_user_groups(user_id=userid)
|
return self.keycloak_admin.get_user_groups(user_id=userid)
|
||||||
|
|
||||||
def get_user_realm_roles(self, userid):
|
def get_user_realm_roles(self, userid : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid)
|
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid)
|
||||||
|
|
||||||
def add_user_client_role(self, client_id, user_id, role_id, role_name):
|
def add_user_client_role(self, client_id : str, user_id : str, role_id : str, role_name : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.assign_client_role(
|
return self.keycloak_admin.assign_client_role(
|
||||||
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
|
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
|
||||||
)
|
)
|
||||||
|
|
||||||
## GROUPS
|
## GROUPS
|
||||||
def get_all_groups(self):
|
def get_all_groups(self) -> Iterable[Any]:
|
||||||
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
|
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_groups()
|
return cast(Iterable[Any], self.keycloak_admin.get_groups())
|
||||||
|
|
||||||
def get_groups(self, with_subgroups=True):
|
def get_groups(self, with_subgroups : bool=True) -> Iterable[Any]:
|
||||||
## RETURNS ALL GROUPS in root list
|
## RETURNS ALL GROUPS in root list
|
||||||
self.connect()
|
self.connect()
|
||||||
groups = self.keycloak_admin.get_groups()
|
groups = self.keycloak_admin.get_groups()
|
||||||
return get_recursive_groups(groups, [])
|
return get_recursive_groups(groups, [])
|
||||||
|
|
||||||
def get_group_by_id(self, group_id):
|
def get_group_by_id(self, group_id : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_group(group_id=group_id)
|
return self.keycloak_admin.get_group(group_id=group_id)
|
||||||
|
|
||||||
def get_group_by_path(self, path, recursive=True):
|
def get_group_by_path(self, path : str, recursive : bool=True) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_group_by_path(
|
return self.keycloak_admin.get_group_by_path(
|
||||||
path=path, search_in_subgroups=recursive
|
path=path, search_in_subgroups=recursive
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_group(self, name, parent=None, skip_exists=False):
|
def add_group(self, name : str, parent : str="", skip_exists : bool=False) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
if parent != None:
|
if parent:
|
||||||
parent = self.get_group_by_path(parent)["id"]
|
parent = self.get_group_by_path(parent)["id"]
|
||||||
return self.keycloak_admin.create_group({"name": name}, parent=parent)
|
return self.keycloak_admin.create_group({"name": name}, parent=parent)
|
||||||
|
|
||||||
def delete_group(self, group_id):
|
def delete_group(self, group_id : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.delete_group(group_id=group_id)
|
return self.keycloak_admin.delete_group(group_id=group_id)
|
||||||
|
|
||||||
def group_user_add(self, user_id, group_id):
|
def group_user_add(self, user_id : str, group_id : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.group_user_add(user_id, group_id)
|
return self.keycloak_admin.group_user_add(user_id, group_id)
|
||||||
|
|
||||||
def add_group_tree(self, path):
|
def add_group_tree(self, path : str) -> None:
|
||||||
paths = kpath2kpaths(path)
|
paths = kpath2kpaths(path)
|
||||||
parent = "/"
|
parent = "/"
|
||||||
for path in paths:
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
parent_path = None if parent == "/" else parent
|
parent_path = "" if parent == "/" else parent
|
||||||
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
|
# print("parent: "+str(parent_path)+" path: "+path.split("/")[-1])
|
||||||
self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
|
self.add_group(path.split("/")[-1], parent_path, skip_exists=True)
|
||||||
parent = path
|
parent = path
|
||||||
|
@ -333,8 +348,8 @@ class KeycloakClient:
|
||||||
parent = path
|
parent = path
|
||||||
|
|
||||||
def add_user_with_groups_and_role(
|
def add_user_with_groups_and_role(
|
||||||
self, username, first, last, email, password, role, groups
|
self, username : str, first : str, last : str, email : str, password : str, role : str, groups : Iterable[str]
|
||||||
):
|
) -> None:
|
||||||
## Add user
|
## Add user
|
||||||
uid = self.add_user(username, first, last, email, password)
|
uid = self.add_user(username, first, last, email, password)
|
||||||
## Add user to role
|
## Add user to role
|
||||||
|
@ -348,7 +363,7 @@ class KeycloakClient:
|
||||||
for g in groups:
|
for g in groups:
|
||||||
log.warning("Creating keycloak group: " + g)
|
log.warning("Creating keycloak group: " + g)
|
||||||
parts = g.split("/")
|
parts = g.split("/")
|
||||||
parent_path = None
|
parent_path = ""
|
||||||
for i in range(1, len(parts)):
|
for i in range(1, len(parts)):
|
||||||
# parent_id=None if parent_path==None else self.get_group(parent_path)['id']
|
# parent_id=None if parent_path==None else self.get_group(parent_path)['id']
|
||||||
try:
|
try:
|
||||||
|
@ -360,10 +375,7 @@ class KeycloakClient:
|
||||||
+ " already exists. Skipping creation"
|
+ " already exists. Skipping creation"
|
||||||
)
|
)
|
||||||
pass
|
pass
|
||||||
if parent_path is None:
|
thepath = parent_path + "/" + parts[i]
|
||||||
thepath = "/" + parts[i]
|
|
||||||
else:
|
|
||||||
thepath = parent_path + "/" + parts[i]
|
|
||||||
if thepath == "/":
|
if thepath == "/":
|
||||||
log.warning(
|
log.warning(
|
||||||
"Not adding the user "
|
"Not adding the user "
|
||||||
|
@ -385,53 +397,51 @@ class KeycloakClient:
|
||||||
)
|
)
|
||||||
self.keycloak_admin.group_user_add(uid, gid)
|
self.keycloak_admin.group_user_add(uid, gid)
|
||||||
|
|
||||||
if parent_path == None:
|
parent_path += "/" + parts[i]
|
||||||
parent_path = ""
|
|
||||||
parent_path = parent_path + "/" + parts[i]
|
|
||||||
|
|
||||||
# self.group_user_add(uid,gid)
|
# self.group_user_add(uid,gid)
|
||||||
|
|
||||||
## ROLES
|
## ROLES
|
||||||
def get_roles(self):
|
def get_roles(self) -> Iterable[Any]:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_realm_roles()
|
return cast(Iterable[Any], self.keycloak_admin.get_realm_roles())
|
||||||
|
|
||||||
def get_role(self, name):
|
def get_role(self, name : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_realm_role(name)
|
return self.keycloak_admin.get_realm_role(name)
|
||||||
|
|
||||||
def add_role(self, name, description=""):
|
def add_role(self, name : str, description : str="") -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.create_realm_role(
|
return self.keycloak_admin.create_realm_role(
|
||||||
{"name": name, "description": description}
|
{"name": name, "description": description}
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_role(self, name):
|
def delete_role(self, name : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.delete_realm_role(name)
|
return self.keycloak_admin.delete_realm_role(name)
|
||||||
|
|
||||||
## CLIENTS
|
## CLIENTS
|
||||||
|
|
||||||
def get_client_roles(self, client_id):
|
def get_client_roles(self, client_id : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_client_roles(client_id=client_id)
|
return self.keycloak_admin.get_client_roles(client_id=client_id)
|
||||||
|
|
||||||
def add_client_role(self, client_id, name, description=""):
|
def add_client_role(self, client_id : str, name : str, description : str="") -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.create_client_role(
|
return self.keycloak_admin.create_client_role(
|
||||||
client_id, {"name": name, "description": description, "clientRole": True}
|
client_id, {"name": name, "description": description, "clientRole": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
## SYSTEM
|
## SYSTEM
|
||||||
def get_server_info(self):
|
def get_server_info(self) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_server_info()
|
return self.keycloak_admin.get_server_info()
|
||||||
|
|
||||||
def get_server_clients(self):
|
def get_server_clients(self) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.get_clients()
|
return self.keycloak_admin.get_clients()
|
||||||
|
|
||||||
def get_server_rsa_key(self):
|
def get_server_rsa_key(self) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
rsa_key = [
|
rsa_key = [
|
||||||
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
|
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
|
||||||
|
@ -439,22 +449,21 @@ class KeycloakClient:
|
||||||
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
|
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
|
||||||
|
|
||||||
## REALM
|
## REALM
|
||||||
def assign_realm_roles(self, user_id, role):
|
def assign_realm_roles(self, user_id : str, role : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
try:
|
try:
|
||||||
role = [
|
kcroles = [
|
||||||
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
|
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
|
||||||
]
|
]
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=role)
|
return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=kcroles)
|
||||||
# return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
|
|
||||||
|
|
||||||
## CLIENTS
|
## CLIENTS
|
||||||
def delete_client(self, clientid):
|
def delete_client(self, clientid : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.delete_client(clientid)
|
return self.keycloak_admin.delete_client(clientid)
|
||||||
|
|
||||||
def add_client(self, client):
|
def add_client(self, client : str) -> Any:
|
||||||
self.connect()
|
self.connect()
|
||||||
return self.keycloak_admin.create_client(client)
|
return self.keycloak_admin.create_client(client)
|
||||||
|
|
|
@ -0,0 +1,411 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
This file only depends on:
|
||||||
|
- attrs
|
||||||
|
- cryptography
|
||||||
|
- requests
|
||||||
|
|
||||||
|
Integrations should be able to copy this file and use it verbatim on their
|
||||||
|
codebase without using anything else from DD.
|
||||||
|
|
||||||
|
To check for changes or to update this file, head to:
|
||||||
|
https://gitlab.com/DD-workspace/DD/-/blob/main/dd-sso/admin/src/admin/lib/keys.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from attr import field, frozen
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives.serialization.base import (
|
||||||
|
PRIVATE_KEY_TYPES,
|
||||||
|
PUBLIC_KEY_TYPES,
|
||||||
|
)
|
||||||
|
from jose import jwe, jws, jwt
|
||||||
|
from jose.backends.rsa_backend import RSAKey
|
||||||
|
from jose.constants import ALGORITHMS
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python 3.8
|
||||||
|
from functools import cached_property as cache
|
||||||
|
except ImportError:
|
||||||
|
from functools import cache # type: ignore # Python 3.9+
|
||||||
|
|
||||||
|
|
||||||
|
Data = Union[str, bytes]
|
||||||
|
Json = Union[Dict, str, List[Dict]]
|
||||||
|
|
||||||
|
|
||||||
|
@frozen(slots=False)
|
||||||
|
class ThirdPartyIntegrationKeys(object):
|
||||||
|
"""
|
||||||
|
This represents the keys for a a third-party integration that interacts
|
||||||
|
with us.
|
||||||
|
|
||||||
|
|
||||||
|
These services must publish their public key on an agreed endpoint.
|
||||||
|
See: {their_remote_pubkey_url}
|
||||||
|
|
||||||
|
Their key is cached in the file system: '{key_store}/their.pubkey'.
|
||||||
|
Key rotation can happen if requested by the third-party service, by
|
||||||
|
manually removing the public key file from the data store.
|
||||||
|
|
||||||
|
|
||||||
|
We also generate a private key for each third-party service.
|
||||||
|
This key is read from or generated to if needed in the file system:
|
||||||
|
'{key_store}/our.privkey'
|
||||||
|
|
||||||
|
Rotating our private key can only happen by removing the private key file
|
||||||
|
and restarting the service.
|
||||||
|
|
||||||
|
In order to use the private key, you get full access to the cryptography
|
||||||
|
primitive with {our_privkey}.
|
||||||
|
|
||||||
|
In order to publish your public key, you get it serialised with
|
||||||
|
{our_pubkey_pem}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Standard attributes
|
||||||
|
#
|
||||||
|
our_name: str
|
||||||
|
"""
|
||||||
|
The identifier of our service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key_store: Path = field(converter=Path)
|
||||||
|
"""
|
||||||
|
Local path to a directory containing service keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Attributes related to the third party
|
||||||
|
#
|
||||||
|
their_name: str
|
||||||
|
"""
|
||||||
|
The identifier of the third-party.
|
||||||
|
"""
|
||||||
|
|
||||||
|
their_service_domain: str
|
||||||
|
"""
|
||||||
|
The domain on which the third party integration is running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
their_pubkey_endpoint: str = "/pubkey/"
|
||||||
|
"""
|
||||||
|
The absolute path (starting with /) on {their_service_domain} that will
|
||||||
|
serve the pubkey.
|
||||||
|
"""
|
||||||
|
#
|
||||||
|
# Cryptography attributes
|
||||||
|
#
|
||||||
|
|
||||||
|
key_size_bits: int = 4096
|
||||||
|
"""
|
||||||
|
When generating private keys, how many bits will be used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key_chmod: int = 0o600
|
||||||
|
"""
|
||||||
|
Permissions to apply to private keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
encrypt_algorithm: str = ALGORITHMS.RSA_OAEP_256
|
||||||
|
"""
|
||||||
|
The encryption algorithm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sign_algorithm: str = ALGORITHMS.RS512
|
||||||
|
"""
|
||||||
|
The signature algorithm.
|
||||||
|
"""
|
||||||
|
|
||||||
|
validity: timedelta = timedelta(minutes=1)
|
||||||
|
"""
|
||||||
|
The validity of a signed token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validity_half(self) -> timedelta:
|
||||||
|
return self.validity / 2
|
||||||
|
|
||||||
|
#
|
||||||
|
# Properties related to the third party keys
|
||||||
|
#
|
||||||
|
@property
|
||||||
|
def their_pubkey_path(self) -> Path:
|
||||||
|
"""
|
||||||
|
Helper returning the full path to the cached pubkey.
|
||||||
|
"""
|
||||||
|
return self.key_store.joinpath("their.pubkey")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def their_remote_pubkey_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Helper returning the full remote URL to the service's pubkey endpoint.
|
||||||
|
"""
|
||||||
|
return f"https://{self.their_service_domain}{self.their_pubkey_endpoint}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def their_pubkey_bytes(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Return the service's pubkey by fetching it only if necessary.
|
||||||
|
That means the key is fetched exactly once and saved to
|
||||||
|
their_pubkey_path.
|
||||||
|
In order to rotate keys, their_pubkey_path must be manually deleted.
|
||||||
|
|
||||||
|
If the key has never been fetched and downloading it fails, an empty
|
||||||
|
bytestring will be returned.
|
||||||
|
|
||||||
|
Note we do not cache this property since there might be temporary
|
||||||
|
failures.
|
||||||
|
"""
|
||||||
|
if not self.their_pubkey_path.exists():
|
||||||
|
# Download if not available
|
||||||
|
try:
|
||||||
|
res = requests.get(self.their_remote_pubkey_url)
|
||||||
|
except:
|
||||||
|
# The service is currently unavailable
|
||||||
|
return b""
|
||||||
|
self.their_pubkey_path.write_bytes(res.content)
|
||||||
|
return self.their_pubkey_path.read_bytes()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def their_pubkey_jwk(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return the service's PublicKey in JWK form fetching if necessary or
|
||||||
|
empty dict if it was not in the store and we were unable to fetch it.
|
||||||
|
"""
|
||||||
|
pk_b = self.their_pubkey_bytes
|
||||||
|
if not pk_b:
|
||||||
|
return dict()
|
||||||
|
rsak = RSAKey(json.loads(pk_b), self.sign_algorithm)
|
||||||
|
return rsak.to_dict()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Properties related to our own keys
|
||||||
|
#
|
||||||
|
@property
|
||||||
|
def our_privkey_path(self) -> Path:
|
||||||
|
"""
|
||||||
|
Helper returning the full path to our private key.
|
||||||
|
"""
|
||||||
|
return self.key_store.joinpath("our.privkey")
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def our_privkey_bytes(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Helper that returns our private key, generating it if necessary.
|
||||||
|
|
||||||
|
This property is cached to avoid expensive operations.
|
||||||
|
That does mean that on key rotation the service must be restarted.
|
||||||
|
"""
|
||||||
|
return self._load_privkey(self.our_privkey_path)
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def our_privkey(self) -> RSAKey:
|
||||||
|
"""
|
||||||
|
Helper that returns our private key in cryptography form, generating
|
||||||
|
it if necessary.
|
||||||
|
"""
|
||||||
|
pk_b = self.our_privkey_bytes
|
||||||
|
return RSAKey(json.loads(pk_b), self.sign_algorithm)
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def our_privkey_jwk(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return our PrivateKey in JWK for this third-party service, generating
|
||||||
|
it if necessary.
|
||||||
|
"""
|
||||||
|
return self.our_privkey.to_dict()
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def our_pubkey(self) -> RSAKey:
|
||||||
|
"""
|
||||||
|
Helper that returns our public key, generating the privkey if necessary.
|
||||||
|
"""
|
||||||
|
return self.our_privkey.public_key()
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def our_pubkey_jwk(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Helper that returns our public key in JWK form.
|
||||||
|
"""
|
||||||
|
return self.our_pubkey.to_dict()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Message-passing methods
|
||||||
|
#
|
||||||
|
def encrypt_outgoing_data(self, data: bytes) -> str:
|
||||||
|
return jwe.encrypt(
|
||||||
|
data,
|
||||||
|
self.their_pubkey_jwk,
|
||||||
|
algorithm=self.encrypt_algorithm,
|
||||||
|
kid=self.our_name,
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
def encrypt_outgoing_json(self, data: Json) -> str:
|
||||||
|
return self.encrypt_outgoing_data(json.dumps(data).encode("utf-8"))
|
||||||
|
|
||||||
|
def decrypt_incoming_data(self, enc_data: Data) -> bytes:
|
||||||
|
return jwe.decrypt(enc_data, self.our_privkey_jwk) # type: ignore # Wrong hint
|
||||||
|
|
||||||
|
def decrypt_incoming_json(self, enc_data: Data) -> Json:
|
||||||
|
d: Json = json.loads(self.decrypt_incoming_data(enc_data))
|
||||||
|
return d
|
||||||
|
|
||||||
|
def sign_outgoing_json(self, data: Json) -> str:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
claims = {
|
||||||
|
"data": data,
|
||||||
|
"aud": self.their_name,
|
||||||
|
"iss": self.our_name,
|
||||||
|
"iat": now,
|
||||||
|
"nbf": now - self.validity_half,
|
||||||
|
"exp": now + self.validity_half,
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
claims,
|
||||||
|
self.our_privkey_jwk,
|
||||||
|
algorithm=self.sign_algorithm,
|
||||||
|
headers={
|
||||||
|
"kid": self.our_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def sign_outgoing_data(self, data: Json) -> str:
|
||||||
|
return self.sign_outgoing_json(data)
|
||||||
|
|
||||||
|
def verify_incoming_data(self, data: Data) -> Any:
|
||||||
|
signed_data: Dict = jwt.decode(
|
||||||
|
data,
|
||||||
|
self.their_pubkey_jwk,
|
||||||
|
algorithms=[self.sign_algorithm],
|
||||||
|
audience=self.our_name,
|
||||||
|
issuer=self.their_name,
|
||||||
|
options={
|
||||||
|
"require_aud": True,
|
||||||
|
"require_iat": True,
|
||||||
|
"require_iss": True,
|
||||||
|
"require_nbf": True,
|
||||||
|
"require_exp": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return signed_data["data"]
|
||||||
|
|
||||||
|
def verify_incoming_json(self, data: bytes) -> Json:
|
||||||
|
d: Json = json.loads(self.verify_incoming_data(data))
|
||||||
|
return d
|
||||||
|
|
||||||
|
def sign_and_encrypt_outgoing_data(self, data: bytes) -> str:
|
||||||
|
return self.sign_outgoing_data(self.encrypt_outgoing_data(data))
|
||||||
|
|
||||||
|
def sign_and_encrypt_outgoing_json(self, data: Json) -> str:
|
||||||
|
return self.sign_outgoing_data(self.encrypt_outgoing_json(data))
|
||||||
|
|
||||||
|
def verify_and_decrypt_incoming_data(self, data: Data) -> bytes:
|
||||||
|
enc_data: str = self.verify_incoming_data(data)
|
||||||
|
return self.decrypt_incoming_data(enc_data.encode("utf-8"))
|
||||||
|
|
||||||
|
def verify_and_decrypt_incoming_json(self, data: Data) -> Json:
|
||||||
|
enc_data: str = self.verify_incoming_data(data)
|
||||||
|
return self.decrypt_incoming_json(enc_data.encode("utf-8"))
|
||||||
|
|
||||||
|
def get_outgoing_request_headers(self) -> Dict[str, str]:
|
||||||
|
# Use current time as ever-changing payload
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
return {"Authorization": self.sign_outgoing_data(now)}
|
||||||
|
|
||||||
|
#
|
||||||
|
# Helper methods
|
||||||
|
#
|
||||||
|
def _load_privkey(self, path: Path, force_generation: bool = False) -> bytes:
|
||||||
|
# Check recommendations here
|
||||||
|
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key
|
||||||
|
#
|
||||||
|
needs_generation = force_generation or not path.exists()
|
||||||
|
|
||||||
|
# Perform further sanity checks
|
||||||
|
# by re-using needs_generation we save checking the file system
|
||||||
|
if not needs_generation:
|
||||||
|
path_st = path.stat()
|
||||||
|
# Check and fix permissions if necessary
|
||||||
|
if stat.S_IMODE(path_st.st_mode) != self.key_chmod:
|
||||||
|
path.touch(mode=self.key_chmod, exist_ok=True)
|
||||||
|
# Force generation if file is empty
|
||||||
|
needs_generation = path_st == 0
|
||||||
|
|
||||||
|
if needs_generation:
|
||||||
|
# Generate the key as needed
|
||||||
|
gpk = rsa.generate_private_key(
|
||||||
|
public_exponent=65537, key_size=self.key_size_bits
|
||||||
|
)
|
||||||
|
enc_gpk = gpk.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
enc_jwk = json.dumps(RSAKey(enc_gpk, self.sign_algorithm).to_dict())
|
||||||
|
# Ensure permissions
|
||||||
|
path.touch(mode=self.key_chmod, exist_ok=True)
|
||||||
|
# Write private key
|
||||||
|
path.write_text(enc_jwk)
|
||||||
|
|
||||||
|
# Return key
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# TODO: convert into real tests
|
||||||
|
a = ThirdPartyIntegrationKeys(
|
||||||
|
our_name="a", their_name="b", their_service_domain="b.com", key_store="tmpa"
|
||||||
|
)
|
||||||
|
b = ThirdPartyIntegrationKeys(
|
||||||
|
our_name="b", their_name="a", their_service_domain="a.com", key_store="tmpb"
|
||||||
|
)
|
||||||
|
Path("tmpa/their.pubkey").write_text(json.dumps(b.our_pubkey_jwk))
|
||||||
|
Path("tmpb/their.pubkey").write_text(json.dumps(a.our_pubkey_jwk))
|
||||||
|
|
||||||
|
m1 = b"test message"
|
||||||
|
print("out", m1)
|
||||||
|
o1 = a.sign_and_encrypt_outgoing_data(m1)
|
||||||
|
print("enc", o1)
|
||||||
|
r1 = b.verify_and_decrypt_incoming_data(o1)
|
||||||
|
print("got", r1)
|
||||||
|
assert m1 == r1, f"Wrong result. Got: {r1!r}. Expected: {m1!r}"
|
||||||
|
|
||||||
|
m2 = {"test": 1, "hello": "world"}
|
||||||
|
m2 = {}
|
||||||
|
print("out", m2)
|
||||||
|
o2 = a.sign_and_encrypt_outgoing_json(m2)
|
||||||
|
print(jwt.get_unverified_headers(o2))
|
||||||
|
print("enc", o2)
|
||||||
|
r2 = b.verify_and_decrypt_incoming_json(o2)
|
||||||
|
print("got", r2)
|
||||||
|
assert m2 == r2, f"Wrong result. Got: {r2!r}. Expected: {m2!r}"
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -21,7 +22,6 @@ import logging as log
|
||||||
import os
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from admin import app
|
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
|
@ -29,18 +29,22 @@ from minio.commonconfig import REPLACE, CopySource
|
||||||
from minio.deleteobjects import DeleteObject
|
from minio.deleteobjects import DeleteObject
|
||||||
from requests import get, post
|
from requests import get, post
|
||||||
|
|
||||||
legal_path= os.path.join(app.root_path, "static/templates/pages/legal/")
|
from typing import TYPE_CHECKING
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
def get_legal(lang):
|
|
||||||
with open(legal_path+lang, "r") as languagefile:
|
# TODO: Fix all this
|
||||||
|
def get_legal(app : "AdminFlaskApp", lang : str) -> str:
|
||||||
|
with open(app.legal_path+lang, "r") as languagefile:
|
||||||
return languagefile.read()
|
return languagefile.read()
|
||||||
|
|
||||||
def gen_legal_if_not_exists(lang):
|
def gen_legal_if_not_exists(app : "AdminFlaskApp", lang : str) -> None:
|
||||||
if not os.path.isfile(legal_path+lang):
|
if not os.path.isfile(app.legal_path+lang):
|
||||||
log.debug("Creating new language file")
|
log.debug("Creating new language file")
|
||||||
with open(legal_path+lang, "w") as languagefile:
|
with open(app.legal_path+lang, "w") as languagefile:
|
||||||
languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
|
languagefile.write("<b>Legal</b><br>This is the default legal page for language " + lang)
|
||||||
|
|
||||||
def new_legal(lang,html):
|
def new_legal(app : "AdminFlaskApp", lang : str, html : str) -> None:
|
||||||
with open(legal_path+lang, "w") as languagefile:
|
with open(app.legal_path+lang, "w") as languagefile:
|
||||||
languagefile.write(html)
|
languagefile.write(html)
|
|
@ -1,94 +0,0 @@
|
||||||
#
|
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
|
||||||
#
|
|
||||||
# This file is part of DD
|
|
||||||
#
|
|
||||||
# DD is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
# option) any later version.
|
|
||||||
#
|
|
||||||
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
|
|
||||||
import logging as log
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from cerberus import Validator, rules_set_registry, schema_registry
|
|
||||||
|
|
||||||
from admin import app
|
|
||||||
|
|
||||||
|
|
||||||
class AdminValidator(Validator):
|
|
||||||
None
|
|
||||||
# def _normalize_default_setter_genid(self, document):
|
|
||||||
# return _parse_string(document["name"])
|
|
||||||
|
|
||||||
# def _normalize_default_setter_genidlower(self, document):
|
|
||||||
# return _parse_string(document["name"]).lower()
|
|
||||||
|
|
||||||
# def _normalize_default_setter_gengroupid(self, document):
|
|
||||||
# return _parse_string(
|
|
||||||
# document["parent_category"] + "-" + document["uid"]
|
|
||||||
# ).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def load_validators(purge_unknown=True):
|
|
||||||
validators = {}
|
|
||||||
schema_path = os.path.join(app.root_path, "schemas")
|
|
||||||
for schema_filename in os.listdir(schema_path):
|
|
||||||
try:
|
|
||||||
with open(os.path.join(schema_path, schema_filename)) as file:
|
|
||||||
schema_yml = file.read()
|
|
||||||
schema = yaml.load(schema_yml, Loader=yaml.FullLoader)
|
|
||||||
validators[schema_filename.split(".")[0]] = AdminValidator(
|
|
||||||
schema, purge_unknown=purge_unknown
|
|
||||||
)
|
|
||||||
except IsADirectoryError:
|
|
||||||
None
|
|
||||||
return validators
|
|
||||||
|
|
||||||
|
|
||||||
app.validators = load_validators()
|
|
||||||
|
|
||||||
|
|
||||||
class loadConfig:
|
|
||||||
def __init__(self, app=None):
|
|
||||||
try:
|
|
||||||
app.config.setdefault("DOMAIN", os.environ["DOMAIN"])
|
|
||||||
app.config.setdefault(
|
|
||||||
"KEYCLOAK_POSTGRES_USER", os.environ["KEYCLOAK_DB_USER"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"KEYCLOAK_POSTGRES_PASSWORD", os.environ["KEYCLOAK_DB_PASSWORD"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"MOODLE_POSTGRES_USER", os.environ["MOODLE_POSTGRES_USER"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"MOODLE_POSTGRES_PASSWORD", os.environ["MOODLE_POSTGRES_PASSWORD"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"NEXTCLOUD_POSTGRES_USER", os.environ["NEXTCLOUD_POSTGRES_USER"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"NEXTCLOUD_POSTGRES_PASSWORD", os.environ["NEXTCLOUD_POSTGRES_PASSWORD"]
|
|
||||||
)
|
|
||||||
app.config.setdefault(
|
|
||||||
"VERIFY", True if os.environ["VERIFY"] == "true" else False
|
|
||||||
)
|
|
||||||
app.config.setdefault("API_SECRET", os.environ.get("API_SECRET"))
|
|
||||||
except Exception as e:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
raise
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -23,11 +24,15 @@ from pprint import pprint
|
||||||
|
|
||||||
from requests import get, post
|
from requests import get, post
|
||||||
|
|
||||||
from admin import app
|
|
||||||
|
|
||||||
from .exceptions import UserExists, UserNotFound
|
from .exceptions import UserExists, UserNotFound
|
||||||
from .postgres import Postgres
|
from .postgres import Postgres
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, cast, Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
# Module variables to connect to moodle api
|
# Module variables to connect to moodle api
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,18 +41,20 @@ class Moodle:
|
||||||
https://docs.moodle.org/dev/Web_service_API_functions
|
https://docs.moodle.org/dev/Web_service_API_functions
|
||||||
https://docs.moodle.org/311/en/Using_web_services
|
https://docs.moodle.org/311/en/Using_web_services
|
||||||
"""
|
"""
|
||||||
|
key: str
|
||||||
|
url : str
|
||||||
|
endpoint : str
|
||||||
|
verify : bool
|
||||||
|
moodle_pg : Postgres
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
key=app.config["MOODLE_WS_TOKEN"],
|
app : "AdminFlaskApp",
|
||||||
url="https://moodle." + app.config["DOMAIN"],
|
endpoint : str="/webservice/rest/server.php",
|
||||||
endpoint="/webservice/rest/server.php",
|
) -> None:
|
||||||
verify=app.config["VERIFY"],
|
self.key = app.config["MOODLE_WS_TOKEN"]
|
||||||
):
|
self.url = f"https://moodle.{ app.config['DOMAIN'] }"
|
||||||
self.key = key
|
|
||||||
self.url = url
|
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.verify = verify
|
self.verify = cast(bool, app.config["VERIFY"])
|
||||||
|
|
||||||
self.moodle_pg = Postgres(
|
self.moodle_pg = Postgres(
|
||||||
"dd-apps-postgresql",
|
"dd-apps-postgresql",
|
||||||
|
@ -56,7 +63,7 @@ class Moodle:
|
||||||
app.config["MOODLE_POSTGRES_PASSWORD"],
|
app.config["MOODLE_POSTGRES_PASSWORD"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def rest_api_parameters(self, in_args, prefix="", out_dict=None):
|
def rest_api_parameters(self, in_args : Any, prefix : str="", out_dict : Optional[Dict]=None) -> Dict[Any, Any]:
|
||||||
"""Transform dictionary/array structure to a flat dictionary, with key names
|
"""Transform dictionary/array structure to a flat dictionary, with key names
|
||||||
defining the structure.
|
defining the structure.
|
||||||
Example usage:
|
Example usage:
|
||||||
|
@ -64,24 +71,23 @@ class Moodle:
|
||||||
{'courses[0][id]':1,
|
{'courses[0][id]':1,
|
||||||
'courses[0][name]':'course1'}
|
'courses[0][name]':'course1'}
|
||||||
"""
|
"""
|
||||||
if out_dict == None:
|
o : Dict[Any, Any] = {} if out_dict is None else out_dict
|
||||||
out_dict = {}
|
|
||||||
if not type(in_args) in (list, dict):
|
if not type(in_args) in (list, dict):
|
||||||
out_dict[prefix] = in_args
|
o[prefix] = in_args
|
||||||
return out_dict
|
return o
|
||||||
if prefix == "":
|
if prefix == "":
|
||||||
prefix = prefix + "{0}"
|
prefix = prefix + "{0}"
|
||||||
else:
|
else:
|
||||||
prefix = prefix + "[{0}]"
|
prefix = prefix + "[{0}]"
|
||||||
if type(in_args) == list:
|
if type(in_args) == list:
|
||||||
for idx, item in enumerate(in_args):
|
for idx, item in enumerate(in_args):
|
||||||
self.rest_api_parameters(item, prefix.format(idx), out_dict)
|
self.rest_api_parameters(item, prefix.format(idx), o)
|
||||||
elif type(in_args) == dict:
|
elif type(in_args) == dict:
|
||||||
for key, item in in_args.items():
|
for key, item in in_args.items():
|
||||||
self.rest_api_parameters(item, prefix.format(key), out_dict)
|
self.rest_api_parameters(item, prefix.format(key), o)
|
||||||
return out_dict
|
return o
|
||||||
|
|
||||||
def call(self, fname, **kwargs):
|
def call(self, fname : str, **kwargs : Any) -> Any:
|
||||||
"""Calls moodle API function with function name fname and keyword arguments.
|
"""Calls moodle API function with function name fname and keyword arguments.
|
||||||
Example:
|
Example:
|
||||||
>>> call_mdl_function('core_course_update_courses',
|
>>> call_mdl_function('core_course_update_courses',
|
||||||
|
@ -97,7 +103,7 @@ class Moodle:
|
||||||
raise SystemError(response)
|
raise SystemError(response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def create_user(self, email, username, password, first_name="-", last_name="-"):
|
def create_user(self, email : str, username : str, password : str, first_name : str="-", last_name : str="-") -> Any:
|
||||||
if len(self.get_user_by("username", username)["users"]):
|
if len(self.get_user_by("username", username)["users"]):
|
||||||
raise UserExists
|
raise UserExists
|
||||||
try:
|
try:
|
||||||
|
@ -115,7 +121,7 @@ class Moodle:
|
||||||
except SystemError as se:
|
except SystemError as se:
|
||||||
raise SystemError(se.args[0]["message"])
|
raise SystemError(se.args[0]["message"])
|
||||||
|
|
||||||
def update_user(self, username, email, first_name, last_name, enabled=True):
|
def update_user(self, username : str, email : str, first_name : str, last_name : str, enabled : bool=True) -> Any:
|
||||||
user = self.get_user_by("username", username)["users"][0]
|
user = self.get_user_by("username", username)["users"][0]
|
||||||
if not len(user):
|
if not len(user):
|
||||||
raise UserNotFound
|
raise UserNotFound
|
||||||
|
@ -135,15 +141,15 @@ class Moodle:
|
||||||
except SystemError as se:
|
except SystemError as se:
|
||||||
raise SystemError(se.args[0]["message"])
|
raise SystemError(se.args[0]["message"])
|
||||||
|
|
||||||
def delete_user(self, user_id):
|
def delete_user(self, user_id : str) -> Any:
|
||||||
user = self.call("core_user_delete_users", userids=[user_id])
|
user = self.call("core_user_delete_users", userids=[user_id])
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def delete_users(self, userids):
|
def delete_users(self, userids : List[str]) -> Any:
|
||||||
user = self.call("core_user_delete_users", userids=userids)
|
user = self.call("core_user_delete_users", userids=userids)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_user_by(self, key, value):
|
def get_user_by(self, key : str, value : str) -> Any:
|
||||||
criteria = [{"key": key, "value": value}]
|
criteria = [{"key": key, "value": value}]
|
||||||
try:
|
try:
|
||||||
user = self.call("core_user_get_users", criteria=criteria)
|
user = self.call("core_user_get_users", criteria=criteria)
|
||||||
|
@ -152,7 +158,7 @@ class Moodle:
|
||||||
return user
|
return user
|
||||||
# {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []}
|
# {'users': [{'id': 8, 'username': 'asdfw', 'firstname': 'afowie', 'lastname': 'aokjdnfwe', 'fullname': 'afowie aokjdnfwe', 'email': 'awfewe@ads.com', 'department': '', 'firstaccess': 0, 'lastaccess': 0, 'auth': 'manual', 'suspended': False, 'confirmed': True, 'lang': 'ca', 'theme': '', 'timezone': '99', 'mailformat': 1, 'profileimageurlsmall': 'https://moodle.mydomain.duckdns.org/theme/image.php/cbe/core/1630941606/u/f2', 'profileimageurl': 'https://DOMAIN/theme/image.php/cbe/core/1630941606/u/f1'}], 'warnings': []}
|
||||||
|
|
||||||
def get_users_with_groups_and_roles(self):
|
def get_users_with_groups_and_roles(self) -> List[Dict[Any, Any]]:
|
||||||
q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles
|
q = """select u.id as id, username, firstname as first, lastname as last, email, json_agg(h.name) as groups, json_agg(r.shortname) as roles
|
||||||
from mdl_user as u
|
from mdl_user as u
|
||||||
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
|
LEFT JOIN mdl_cohort_members AS hm on hm.userid = u.id
|
||||||
|
@ -179,31 +185,31 @@ class Moodle:
|
||||||
# user['roles']=[]
|
# user['roles']=[]
|
||||||
# return users
|
# return users
|
||||||
|
|
||||||
def enroll_user_to_course(self, user_id, course_id, role_id=5):
|
def enroll_user_to_course(self, user_id : str, course_id : str, role_id : int=5) -> Any:
|
||||||
# 5 is student
|
# 5 is student
|
||||||
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
|
data = [{"roleid": role_id, "userid": user_id, "courseid": course_id}]
|
||||||
enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
|
enrolment = self.call("enrol_manual_enrol_users", enrolments=data)
|
||||||
return enrolment
|
return enrolment
|
||||||
|
|
||||||
def get_quiz_attempt(self, quiz_id, user_id):
|
def get_quiz_attempt(self, quiz_id : str, user_id : str) -> Any:
|
||||||
attempts = self.call(
|
attempts = self.call(
|
||||||
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
|
"mod_quiz_get_user_attempts", quizid=quiz_id, userid=user_id
|
||||||
)
|
)
|
||||||
return attempts
|
return attempts
|
||||||
|
|
||||||
def get_cohorts(self):
|
def get_cohorts(self) -> List[Dict[str, Any]]:
|
||||||
cohorts = self.call("core_cohort_get_cohorts")
|
cohorts = self.call("core_cohort_get_cohorts")
|
||||||
return cohorts
|
return cast(List[Dict[str, Any]], cohorts)
|
||||||
|
|
||||||
def add_system_cohort(self, name, description="", visible=True):
|
def add_system_cohort(self, name : str, description : str ="", visible : bool=True) -> Any:
|
||||||
visible = 1 if visible else 0
|
bit_visible = 1 if visible else 0
|
||||||
data = [
|
data = [
|
||||||
{
|
{
|
||||||
"categorytype": {"type": "system", "value": ""},
|
"categorytype": {"type": "system", "value": ""},
|
||||||
"name": name,
|
"name": name,
|
||||||
"idnumber": name,
|
"idnumber": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
"visible": visible,
|
"visible": bit_visible,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
cohort = self.call("core_cohort_create_cohorts", cohorts=data)
|
cohort = self.call("core_cohort_create_cohorts", cohorts=data)
|
||||||
|
@ -214,7 +220,7 @@ class Moodle:
|
||||||
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
|
# user = self.call('core_cohort_add_cohort_members', criteria=criteria)
|
||||||
# return user
|
# return user
|
||||||
|
|
||||||
def add_user_to_cohort(self, userid, cohortid):
|
def add_user_to_cohort(self, userid : str, cohortid : str) -> Any:
|
||||||
members = [
|
members = [
|
||||||
{
|
{
|
||||||
"cohorttype": {"type": "id", "value": cohortid},
|
"cohorttype": {"type": "id", "value": cohortid},
|
||||||
|
@ -224,21 +230,21 @@ class Moodle:
|
||||||
user = self.call("core_cohort_add_cohort_members", members=members)
|
user = self.call("core_cohort_add_cohort_members", members=members)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def delete_user_in_cohort(self, userid, cohortid):
|
def delete_user_in_cohort(self, userid : str, cohortid : str) -> Any:
|
||||||
members = [{"cohortid": cohortid, "userid": userid}]
|
members = [{"cohortid": cohortid, "userid": userid}]
|
||||||
user = self.call("core_cohort_delete_cohort_members", members=members)
|
user = self.call("core_cohort_delete_cohort_members", members=members)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def get_cohort_members(self, cohort_ids):
|
def get_cohort_members(self, cohort_ids : str) -> Any:
|
||||||
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
|
members = self.call("core_cohort_get_cohort_members", cohortids=cohort_ids)
|
||||||
# [0]['userids']
|
# [0]['userids']
|
||||||
return members
|
return members
|
||||||
|
|
||||||
def delete_cohorts(self, cohortids):
|
def delete_cohorts(self, cohortids : Iterable[str]) -> Any:
|
||||||
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
|
deleted = self.call("core_cohort_delete_cohorts", cohortids=cohortids)
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
def get_user_cohorts(self, user_id):
|
def get_user_cohorts(self, user_id : str) -> Any:
|
||||||
user_cohorts = []
|
user_cohorts = []
|
||||||
cohorts = self.get_cohorts()
|
cohorts = self.get_cohorts()
|
||||||
for cohort in cohorts:
|
for cohort in cohorts:
|
||||||
|
@ -246,7 +252,7 @@ class Moodle:
|
||||||
user_cohorts.append(cohort)
|
user_cohorts.append(cohort)
|
||||||
return user_cohorts
|
return user_cohorts
|
||||||
|
|
||||||
def add_user_to_siteadmin(self, user_id):
|
def add_user_to_siteadmin(self, user_id : str) -> Any:
|
||||||
q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
|
q = """SELECT value FROM mdl_config WHERE name='siteadmins'"""
|
||||||
value = self.moodle_pg.select(q)[0][0]
|
value = self.moodle_pg.select(q)[0][0]
|
||||||
if str(user_id) not in value:
|
if str(user_id) not in value:
|
||||||
|
@ -258,6 +264,7 @@ class Moodle:
|
||||||
log.warning(
|
log.warning(
|
||||||
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
|
"MOODLE:ADDING THE USER TO ADMINS: This needs a purge cache in moodle!"
|
||||||
)
|
)
|
||||||
|
|
||||||
def unassign_user_rol(self, user_id, role_id):
|
def unassign_user_rol(self, user_id, role_id):
|
||||||
unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}]
|
unassignments = [{"roleid": role_id, "userid": user_id, "contextlevel": 'system', "instanceid": 0}]
|
||||||
return self.call("core_role_unassign_roles", unassignments=unassignments)
|
return self.call("core_role_unassign_roles", unassignments=unassignments)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -18,32 +19,29 @@
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import json
|
|
||||||
import logging as log
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
import yaml
|
|
||||||
|
|
||||||
# from admin import app
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
class Mysql:
|
class Mysql:
|
||||||
def __init__(self, host, database, user, password):
|
# TODO: Fix this whole class
|
||||||
|
cur : mysql.connector.MySQLCursor
|
||||||
|
conn : mysql.connector.MySQLConnection
|
||||||
|
def __init__(self, host : str, database : str, user : str, password : str) -> None:
|
||||||
self.conn = mysql.connector.connect(
|
self.conn = mysql.connector.connect(
|
||||||
host=host, database=database, user=user, password=password
|
host=host, database=database, user=user, password=password
|
||||||
)
|
)
|
||||||
|
|
||||||
def select(self, sql):
|
def select(self, sql : str) -> List[Tuple]:
|
||||||
self.cur = self.conn.cursor()
|
self.cur = self.conn.cursor()
|
||||||
self.cur.execute(sql)
|
self.cur.execute(sql)
|
||||||
data = self.cur.fetchall()
|
data : List[Tuple] = self.cur.fetchall()
|
||||||
self.cur.close()
|
self.cur.close()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def update(self, sql):
|
def update(self, sql : str) -> None:
|
||||||
|
# TODO: Fix this whole method
|
||||||
self.cur = self.conn.cursor()
|
self.cur = self.conn.cursor()
|
||||||
self.cur.execute(sql)
|
self.cur.execute(sql)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -30,21 +31,31 @@ import urllib
|
||||||
import requests
|
import requests
|
||||||
from psycopg2 import sql
|
from psycopg2 import sql
|
||||||
|
|
||||||
# from ..lib.log import *
|
|
||||||
from admin import app
|
|
||||||
|
|
||||||
from .nextcloud_exc import *
|
from .nextcloud_exc import *
|
||||||
from .postgres import Postgres
|
from .postgres import Postgres
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
|
DDUser = Dict[Any, Any]
|
||||||
|
|
||||||
class Nextcloud:
|
class Nextcloud:
|
||||||
|
verify_cert : bool
|
||||||
|
apiurl : str
|
||||||
|
shareurl : str
|
||||||
|
davurl : str
|
||||||
|
auth : Tuple[str, str]
|
||||||
|
user : str
|
||||||
|
nextcloud_pg : Postgres
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url="https://nextcloud." + app.config["DOMAIN"],
|
app : "AdminFlaskApp",
|
||||||
username=os.environ["NEXTCLOUD_ADMIN_USER"],
|
username : str=os.environ["NEXTCLOUD_ADMIN_USER"],
|
||||||
password=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
|
password : str=os.environ["NEXTCLOUD_ADMIN_PASSWORD"],
|
||||||
verify=True,
|
verify : bool=True,
|
||||||
):
|
) -> None:
|
||||||
|
url = "https://nextcloud." + app.config["DOMAIN"]
|
||||||
|
|
||||||
self.verify_cert = verify
|
self.verify_cert = verify
|
||||||
self.apiurl = url + "/ocs/v1.php/cloud/"
|
self.apiurl = url + "/ocs/v1.php/cloud/"
|
||||||
|
@ -61,9 +72,9 @@ class Nextcloud:
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self, method, url, data={}, headers={"OCS-APIRequest": "true"}, auth=False
|
self, method : str, url : str, data : Any={}, headers : Dict[str, str]={"OCS-APIRequest": "true"}, auth : Optional[Tuple[str, str]]=None
|
||||||
):
|
) -> str:
|
||||||
if auth == False:
|
if auth is None:
|
||||||
auth = self.auth
|
auth = self.auth
|
||||||
try:
|
try:
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
|
@ -96,7 +107,7 @@ class Nextcloud:
|
||||||
raise ProviderConnError
|
raise ProviderConnError
|
||||||
raise ProviderError
|
raise ProviderError
|
||||||
|
|
||||||
def check_connection(self):
|
def check_connection(self) -> bool:
|
||||||
url = self.apiurl + "users/" + self.user + "?format=json"
|
url = self.apiurl + "users/" + self.user + "?format=json"
|
||||||
try:
|
try:
|
||||||
result = self._request("GET", url)
|
result = self._request("GET", url)
|
||||||
|
@ -118,7 +129,7 @@ class Nextcloud:
|
||||||
raise ProviderConnError
|
raise ProviderConnError
|
||||||
raise ProviderError
|
raise ProviderError
|
||||||
|
|
||||||
def get_user(self, userid):
|
def get_user(self, userid : str) -> Any:
|
||||||
url = self.apiurl + "users/" + userid + "?format=json"
|
url = self.apiurl + "users/" + userid + "?format=json"
|
||||||
try:
|
try:
|
||||||
result = json.loads(self._request("GET", url))
|
result = json.loads(self._request("GET", url))
|
||||||
|
@ -148,7 +159,7 @@ class Nextcloud:
|
||||||
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
|
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users]
|
||||||
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
|
# users_with_lists = [list(l[:-2])+([[]] if l[-2] == [None] else [list(set(l[-2]))]) + ([[]] if l[-1] == [None] else [list(set(l[-1]))]) for l in users_with_lists]
|
||||||
# list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
|
# list_dict_users = [dict(zip(fields, r)) for r in users_with_lists]
|
||||||
def get_users_list(self):
|
def get_users_list(self) -> List[DDUser]:
|
||||||
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
|
# q = """select u.uid as username, adn.value as displayname, ade.value as email, json_agg(gg.displayname) as admin_groups,json_agg(g.displayname) as groups
|
||||||
# from oc_users as u
|
# from oc_users as u
|
||||||
# left join oc_group_user as gu on gu.uid = u.uid
|
# left join oc_group_user as gu on gu.uid = u.uid
|
||||||
|
@ -200,9 +211,10 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
# raise
|
# raise
|
||||||
|
|
||||||
|
# TODO: Improve typing of these functions...
|
||||||
def add_user(
|
def add_user(
|
||||||
self, userid, userpassword, quota=False, group=False, email="", displayname=""
|
self, userid : str, userpassword : str, quota : Any=False, group : Any=False, email : str="", displayname : str=""
|
||||||
):
|
) -> bool:
|
||||||
data = {
|
data = {
|
||||||
"userid": userid,
|
"userid": userid,
|
||||||
"password": userpassword,
|
"password": userpassword,
|
||||||
|
@ -247,7 +259,7 @@ class Nextcloud:
|
||||||
# 106 - no group specified (required for subadmins)
|
# 106 - no group specified (required for subadmins)
|
||||||
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
|
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
|
||||||
|
|
||||||
def update_user(self, userid, key_values):
|
def update_user(self, userid : str, key_values : Dict[str, Any]) -> bool:
|
||||||
# key_values={'quota':quota,'email':email,'displayname':displayname}
|
# key_values={'quota':quota,'email':email,'displayname':displayname}
|
||||||
|
|
||||||
url = self.apiurl + "users/" + userid + "?format=json"
|
url = self.apiurl + "users/" + userid + "?format=json"
|
||||||
|
@ -262,6 +274,8 @@ class Nextcloud:
|
||||||
result = json.loads(
|
result = json.loads(
|
||||||
self._request("PUT", url, data=data, headers=headers)
|
self._request("PUT", url, data=data, headers=headers)
|
||||||
)
|
)
|
||||||
|
# TODO: It seems like this only sets the first item in key_values
|
||||||
|
# This function probably doesn't do what it is supposed to
|
||||||
if result["ocs"]["meta"]["statuscode"] == 100:
|
if result["ocs"]["meta"]["statuscode"] == 100:
|
||||||
return True
|
return True
|
||||||
if result["ocs"]["meta"]["statuscode"] == 102:
|
if result["ocs"]["meta"]["statuscode"] == 102:
|
||||||
|
@ -273,8 +287,9 @@ class Nextcloud:
|
||||||
except:
|
except:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
return False
|
||||||
|
|
||||||
def add_user_to_group(self, userid, group_id):
|
def add_user_to_group(self, userid : str, group_id : str) -> bool:
|
||||||
data = {"groupid": group_id}
|
data = {"groupid": group_id}
|
||||||
|
|
||||||
url = self.apiurl + "users/" + userid + "/groups?format=json"
|
url = self.apiurl + "users/" + userid + "/groups?format=json"
|
||||||
|
@ -296,7 +311,7 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def remove_user_from_group(self, userid, group_id):
|
def remove_user_from_group(self, userid : str, group_id : str) -> bool:
|
||||||
data = {"groupid": group_id}
|
data = {"groupid": group_id}
|
||||||
|
|
||||||
url = self.apiurl + "users/" + userid + "/groups?format=json"
|
url = self.apiurl + "users/" + userid + "/groups?format=json"
|
||||||
|
@ -312,18 +327,21 @@ class Nextcloud:
|
||||||
return True
|
return True
|
||||||
if result["ocs"]["meta"]["statuscode"] == 102:
|
if result["ocs"]["meta"]["statuscode"] == 102:
|
||||||
raise ProviderItemExists
|
raise ProviderItemExists
|
||||||
if result["ocs"]["meta"]["statuscode"] == 104:
|
# TODO: It is unclear what status code 104 is, it certainly
|
||||||
self.add_group(group)
|
# shouldn't the group if it doesn't exist
|
||||||
# raise ProviderGroupNotExists
|
#if result["ocs"]["meta"]["statuscode"] == 104:
|
||||||
|
# self.add_group(group)
|
||||||
|
# # raise ProviderGroupNotExists
|
||||||
log.error("Get Nextcloud provider user add error: " + str(result))
|
log.error("Get Nextcloud provider user add error: " + str(result))
|
||||||
raise ProviderOpError
|
raise ProviderOpError
|
||||||
except:
|
except:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# TODO: Improve typing of these functions...
|
||||||
def add_user_with_groups(
|
def add_user_with_groups(
|
||||||
self, userid, userpassword, quota=False, groups=[], email="", displayname=""
|
self, userid : str, userpassword : str, quota : Any=False, groups : Any=[], email : str="", displayname : str=""
|
||||||
):
|
) -> bool:
|
||||||
data = {
|
data = {
|
||||||
"userid": userid,
|
"userid": userid,
|
||||||
"password": userpassword,
|
"password": userpassword,
|
||||||
|
@ -352,7 +370,7 @@ class Nextcloud:
|
||||||
raise ProviderItemExists
|
raise ProviderItemExists
|
||||||
if result["ocs"]["meta"]["statuscode"] == 104:
|
if result["ocs"]["meta"]["statuscode"] == 104:
|
||||||
# self.add_group(group)
|
# self.add_group(group)
|
||||||
None
|
pass
|
||||||
# raise ProviderGroupNotExists
|
# raise ProviderGroupNotExists
|
||||||
log.error("Get Nextcloud provider user add error: " + str(result))
|
log.error("Get Nextcloud provider user add error: " + str(result))
|
||||||
raise ProviderOpError
|
raise ProviderOpError
|
||||||
|
@ -368,7 +386,7 @@ class Nextcloud:
|
||||||
# 106 - no group specified (required for subadmins)
|
# 106 - no group specified (required for subadmins)
|
||||||
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
|
# 107 - all errors that contain a hint - for example “Password is among the 1,000,000 most common ones. Please make it unique.” (this code was added in 12.0.6 & 13.0.1)
|
||||||
|
|
||||||
def delete_user(self, userid):
|
def delete_user(self, userid : str) -> bool:
|
||||||
url = self.apiurl + "users/" + userid + "?format=json"
|
url = self.apiurl + "users/" + userid + "?format=json"
|
||||||
try:
|
try:
|
||||||
result = json.loads(self._request("DELETE", url))
|
result = json.loads(self._request("DELETE", url))
|
||||||
|
@ -384,13 +402,13 @@ class Nextcloud:
|
||||||
# 100 - successful
|
# 100 - successful
|
||||||
# 101 - failure
|
# 101 - failure
|
||||||
|
|
||||||
def enable_user(self, userid):
|
def enable_user(self, userid : str) -> None:
|
||||||
None
|
pass
|
||||||
|
|
||||||
def disable_user(self, userid):
|
def disable_user(self, userid : str) -> None:
|
||||||
None
|
pass
|
||||||
|
|
||||||
def exists_user_folder(self, userid, userpassword, folder="IsardVDI"):
|
def exists_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
|
||||||
auth = (userid, userpassword)
|
auth = (userid, userpassword)
|
||||||
url = self.davurl + userid + "/" + folder + "?format=json"
|
url = self.davurl + userid + "/" + folder + "?format=json"
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -407,7 +425,7 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_user_folder(self, userid, userpassword, folder="IsardVDI"):
|
def add_user_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> bool:
|
||||||
auth = (userid, userpassword)
|
auth = (userid, userpassword)
|
||||||
url = self.davurl + userid + "/" + folder + "?format=json"
|
url = self.davurl + userid + "/" + folder + "?format=json"
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -429,7 +447,7 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def exists_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
|
def exists_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
|
||||||
auth = (userid, userpassword)
|
auth = (userid, userpassword)
|
||||||
url = self.shareurl + "shares?format=json"
|
url = self.shareurl + "shares?format=json"
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -449,7 +467,7 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_user_share_folder(self, userid, userpassword, folder="IsardVDI"):
|
def add_user_share_folder(self, userid : str, userpassword : str, folder : str="IsardVDI") -> Dict[str, str]:
|
||||||
auth = (userid, userpassword)
|
auth = (userid, userpassword)
|
||||||
data = {"path": "/" + folder, "shareType": 3}
|
data = {"path": "/" + folder, "shareType": 3}
|
||||||
url = self.shareurl + "shares?format=json"
|
url = self.shareurl + "shares?format=json"
|
||||||
|
@ -477,10 +495,10 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def get_group(self, userid):
|
def get_group(self, userid : str) -> None:
|
||||||
None
|
pass
|
||||||
|
|
||||||
def get_groups_list(self):
|
def get_groups_list(self) -> List[Any]:
|
||||||
url = self.apiurl + "groups?format=json"
|
url = self.apiurl + "groups?format=json"
|
||||||
try:
|
try:
|
||||||
result = json.loads(self._request("GET", url))
|
result = json.loads(self._request("GET", url))
|
||||||
|
@ -491,7 +509,7 @@ class Nextcloud:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_group(self, groupid):
|
def add_group(self, groupid : str) -> bool:
|
||||||
data = {"groupid": groupid}
|
data = {"groupid": groupid}
|
||||||
url = self.apiurl + "groups?format=json"
|
url = self.apiurl + "groups?format=json"
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -515,7 +533,7 @@ class Nextcloud:
|
||||||
# 102 - group already exists
|
# 102 - group already exists
|
||||||
# 103 - failed to add the group
|
# 103 - failed to add the group
|
||||||
|
|
||||||
def delete_group(self, groupid):
|
def delete_group(self, groupid : str) -> bool:
|
||||||
group = urllib.parse.quote(groupid, safe="")
|
group = urllib.parse.quote(groupid, safe="")
|
||||||
url = self.apiurl + "groups/" + group + "?format=json"
|
url = self.apiurl + "groups/" + group + "?format=json"
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -538,7 +556,7 @@ class Nextcloud:
|
||||||
# 102 - group already exists
|
# 102 - group already exists
|
||||||
# 103 - failed to add the group
|
# 103 - failed to add the group
|
||||||
|
|
||||||
def set_user_mail(self, data):
|
def set_user_mail(self, data : DDUser) -> None:
|
||||||
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
|
query = """SELECT * FROM "oc_mail_accounts" WHERE "email" = '%s'"""
|
||||||
sql_query = sql.SQL(query.format(data["email"]))
|
sql_query = sql.SQL(query.format(data["email"]))
|
||||||
if not len(self.nextcloud_pg.select(sql_query)):
|
if not len(self.nextcloud_pg.select(sql_query)):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -46,6 +47,10 @@ class ProviderItemNotExists(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderUserNotExists(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ProviderGroupNotExists(Exception):
|
class ProviderGroupNotExists(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -18,54 +19,41 @@
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import json
|
|
||||||
import logging as log
|
|
||||||
import time
|
|
||||||
import traceback
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import yaml
|
import psycopg2.sql
|
||||||
|
from psycopg2.extensions import connection, cursor
|
||||||
|
|
||||||
# from admin import app
|
from typing import Any, List, Tuple, Union
|
||||||
|
|
||||||
|
query = Union[str, psycopg2.sql.SQL]
|
||||||
|
|
||||||
class Postgres:
|
class Postgres:
|
||||||
def __init__(self, host, database, user, password):
|
# TODO: Fix this whole class
|
||||||
|
cur : cursor
|
||||||
|
conn : connection
|
||||||
|
def __init__(self, host : str, database : str, user : str, password : str) -> None:
|
||||||
self.conn = psycopg2.connect(
|
self.conn = psycopg2.connect(
|
||||||
host=host, database=database, user=user, password=password
|
host=host, database=database, user=user, password=password
|
||||||
)
|
)
|
||||||
|
|
||||||
# def __del__(self):
|
def select(self, sql: query) -> List[Tuple[Any, ...]]:
|
||||||
# self.cur.close()
|
|
||||||
# self.conn.close()
|
|
||||||
|
|
||||||
def select(self, sql):
|
|
||||||
self.cur = self.conn.cursor()
|
self.cur = self.conn.cursor()
|
||||||
self.cur.execute(sql)
|
self.cur.execute(sql)
|
||||||
data = self.cur.fetchall()
|
data = self.cur.fetchall()
|
||||||
self.cur.close()
|
self.cur.close() # type: ignore # psycopg2 type hint missing
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def update(self, sql):
|
def update(self, sql : query) -> None:
|
||||||
self.cur = self.conn.cursor()
|
self.cur = self.conn.cursor()
|
||||||
self.cur.execute(sql)
|
self.cur.execute(sql)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
self.cur.close()
|
self.cur.close() # type: ignore # psycopg2 type hint missing
|
||||||
# return self.cur.fetchall()
|
# return self.cur.fetchall()
|
||||||
|
|
||||||
def select_with_headers(self, sql):
|
def select_with_headers(self, sql : query) -> Tuple[List[Any], List[Tuple[Any, ...]]]:
|
||||||
self.cur = self.conn.cursor()
|
self.cur = self.conn.cursor()
|
||||||
self.cur.execute(sql)
|
self.cur.execute(sql)
|
||||||
data = self.cur.fetchall()
|
data = self.cur.fetchall()
|
||||||
fields = [a.name for a in self.cur.description]
|
fields = [a.name for a in self.cur.description]
|
||||||
self.cur.close()
|
self.cur.close() # type: ignore # psycopg2 type hint missing
|
||||||
return (fields, data)
|
return (fields, data)
|
||||||
|
|
||||||
# def update_moodle_saml_plugin(self):
|
|
||||||
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
|
|
||||||
# pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
|
|
||||||
# cursor.execute(pg_update, (title, bookid))
|
|
||||||
# connection.commit()
|
|
||||||
# count = cursor.rowcount
|
|
||||||
# print(count, "Successfully Updated!")
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -23,8 +24,6 @@ import logging as log
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
|
||||||
# from .keycloak import Keycloak
|
|
||||||
# from .moodle import Moodle
|
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
@ -33,18 +32,21 @@ from datetime import datetime, timedelta
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from .postgres import Postgres
|
from .postgres import Postgres
|
||||||
|
|
||||||
|
|
||||||
class Postup:
|
class Postup:
|
||||||
def __init__(self):
|
def __init__(self, app: "AdminFlaskApp") -> None:
|
||||||
ready = False
|
ready = False
|
||||||
while not ready:
|
while not ready:
|
||||||
try:
|
try:
|
||||||
self.pg = Postgres(
|
self.pg = Postgres(
|
||||||
"isard-apps-postgresql",
|
"dd-apps-postgresql",
|
||||||
"moodle",
|
"moodle",
|
||||||
app.config["MOODLE_POSTGRES_USER"],
|
app.config["MOODLE_POSTGRES_USER"],
|
||||||
app.config["MOODLE_POSTGRES_PASSWORD"],
|
app.config["MOODLE_POSTGRES_PASSWORD"],
|
||||||
|
@ -93,9 +95,9 @@ class Postup:
|
||||||
|
|
||||||
self.select_and_configure_theme()
|
self.select_and_configure_theme()
|
||||||
self.configure_tipnc()
|
self.configure_tipnc()
|
||||||
self.add_moodle_ws_token()
|
self.add_moodle_ws_token(app)
|
||||||
|
|
||||||
def select_and_configure_theme(self, theme="cbe"):
|
def select_and_configure_theme(self, theme : str="cbe") -> None:
|
||||||
try:
|
try:
|
||||||
self.pg.update(
|
self.pg.update(
|
||||||
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
|
"""UPDATE "mdl_config" SET value = '%s' WHERE "name" = 'theme';"""
|
||||||
|
@ -104,7 +106,6 @@ class Postup:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
exit(1)
|
exit(1)
|
||||||
None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.pg.update(
|
self.pg.update(
|
||||||
|
@ -127,9 +128,8 @@ class Postup:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
exit(1)
|
exit(1)
|
||||||
None
|
|
||||||
|
|
||||||
def configure_tipnc(self):
|
def configure_tipnc(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.pg.update(
|
self.pg.update(
|
||||||
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
|
"""UPDATE "mdl_config_plugins" SET value = '%s' WHERE "plugin" = 'assignsubmission_tipnc' AND "name" = 'host';"""
|
||||||
|
@ -155,9 +155,8 @@ class Postup:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
exit(1)
|
exit(1)
|
||||||
None
|
|
||||||
|
|
||||||
def add_moodle_ws_token(self):
|
def add_moodle_ws_token(self, app: "AdminFlaskApp") -> None:
|
||||||
try:
|
try:
|
||||||
token = self.pg.select(
|
token = self.pg.select(
|
||||||
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
|
"""SELECT * FROM "mdl_external_tokens" WHERE "externalserviceid" = 3"""
|
||||||
|
@ -166,7 +165,7 @@ class Postup:
|
||||||
return
|
return
|
||||||
except:
|
except:
|
||||||
# log.error(traceback.format_exc())
|
# log.error(traceback.format_exc())
|
||||||
None
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.pg.update(
|
self.pg.update(
|
||||||
|
@ -226,4 +225,3 @@ class Postup:
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
exit(1)
|
exit(1)
|
||||||
None
|
|
||||||
|
|
|
@ -2,5 +2,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"gentelella": "^1.4.0",
|
"gentelella": "^1.4.0",
|
||||||
"socket.io": "^4.1.3"
|
"socket.io": "^4.1.3"
|
||||||
}
|
},
|
||||||
|
"license": "AGPL-3.0-or-later"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -19,6 +20,7 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
import json
|
import json
|
||||||
import logging as log
|
import logging as log
|
||||||
|
from operator import itemgetter
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
@ -27,302 +29,308 @@ import traceback
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from ..lib.api_exceptions import Error
|
from ..lib.api_exceptions import Error
|
||||||
from .decorators import has_token
|
from .decorators import has_token, OptionalJsonResponse
|
||||||
|
|
||||||
|
|
||||||
## LISTS
|
def setup_api_views(app : "AdminFlaskApp") -> None:
|
||||||
@app.route("/ddapi/users", methods=["GET"])
|
## LISTS
|
||||||
@has_token
|
@app.json_route("/ddapi/users", methods=["GET"])
|
||||||
def ddapi_users():
|
@has_token
|
||||||
if request.method == "GET":
|
def ddapi_users() -> OptionalJsonResponse:
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
if request.method == "GET":
|
||||||
users = []
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
for user in sorted_users:
|
users = []
|
||||||
users.append(user_parser(user))
|
for user in sorted_users:
|
||||||
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
users.append(user_parser(user))
|
||||||
|
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/users/filter", methods=["POST"])
|
||||||
|
@has_token
|
||||||
|
def ddapi_users_search() -> OptionalJsonResponse:
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if not data.get("text"):
|
||||||
|
raise Error("bad_request", "Incorrect data requested.")
|
||||||
|
users = app.admin.get_mix_users()
|
||||||
|
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
||||||
|
sorted_result = sorted(result, key=itemgetter("id"))
|
||||||
|
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route("/ddapi/users/filter", methods=["POST"])
|
@app.json_route("/ddapi/groups", methods=["GET"])
|
||||||
@has_token
|
@has_token
|
||||||
def ddapi_users_search():
|
def ddapi_groups() -> OptionalJsonResponse:
|
||||||
if request.method == "POST":
|
if request.method == "GET":
|
||||||
data = request.get_json(force=True)
|
sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
|
||||||
if not data.get("text"):
|
groups = []
|
||||||
raise Error("bad_request", "Incorrect data requested.")
|
for group in sorted_groups:
|
||||||
users = app.admin.get_mix_users()
|
groups.append(group_parser(group))
|
||||||
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
return json.dumps(groups), 200, {"Content-Type": "application/json"}
|
||||||
sorted_result = sorted(result, key=lambda k: k["id"])
|
return None
|
||||||
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/group/users", methods=["POST"])
|
||||||
@app.route("/ddapi/groups", methods=["GET"])
|
@has_token
|
||||||
@has_token
|
def ddapi_group_users() -> OptionalJsonResponse:
|
||||||
def ddapi_groups():
|
if request.method == "POST":
|
||||||
if request.method == "GET":
|
data = request.get_json(force=True)
|
||||||
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"])
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
groups = []
|
if data.get("id"):
|
||||||
for group in sorted_groups:
|
|
||||||
groups.append(group_parser(group))
|
|
||||||
return json.dumps(groups), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ddapi/group/users", methods=["POST"])
|
|
||||||
@has_token
|
|
||||||
def ddapi_group_users():
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
|
||||||
if data.get("id"):
|
|
||||||
group_users = [
|
|
||||||
user_parser(user)
|
|
||||||
for user in sorted_users
|
|
||||||
if data.get("id") in user["keycloak_groups"]
|
|
||||||
]
|
|
||||||
elif data.get("path"):
|
|
||||||
try:
|
|
||||||
name = [
|
|
||||||
g["name"]
|
|
||||||
for g in app.admin.get_mix_groups()
|
|
||||||
if g["path"] == data.get("path")
|
|
||||||
][0]
|
|
||||||
group_users = [
|
group_users = [
|
||||||
user_parser(user)
|
user_parser(user)
|
||||||
for user in sorted_users
|
for user in sorted_users
|
||||||
if name in user["keycloak_groups"]
|
if data.get("id") in user["keycloak_groups"]
|
||||||
]
|
]
|
||||||
except:
|
elif data.get("path"):
|
||||||
raise Error("not_found", "Group path not found in system")
|
try:
|
||||||
elif data.get("keycloak_id"):
|
name = [
|
||||||
try:
|
g["name"]
|
||||||
name = [
|
for g in app.admin.get_mix_groups()
|
||||||
g["name"]
|
if g["path"] == data.get("path")
|
||||||
for g in app.admin.get_mix_groups()
|
][0]
|
||||||
if g["id"] == data.get("keycloak_id")
|
group_users = [
|
||||||
][0]
|
user_parser(user)
|
||||||
group_users = [
|
for user in sorted_users
|
||||||
user_parser(user)
|
if name in user["keycloak_groups"]
|
||||||
for user in sorted_users
|
]
|
||||||
if name in user["keycloak_groups"]
|
except:
|
||||||
]
|
raise Error("not_found", "Group path not found in system")
|
||||||
except:
|
elif data.get("keycloak_id"):
|
||||||
raise Error("not_found", "Group keycloak_id not found in system")
|
try:
|
||||||
else:
|
name = [
|
||||||
raise Error("bad_request", "Incorrect data requested.")
|
g["name"]
|
||||||
return json.dumps(group_users), 200, {"Content-Type": "application/json"}
|
for g in app.admin.get_mix_groups()
|
||||||
|
if g["id"] == data.get("keycloak_id")
|
||||||
|
][0]
|
||||||
|
group_users = [
|
||||||
|
user_parser(user)
|
||||||
|
for user in sorted_users
|
||||||
|
if name in user["keycloak_groups"]
|
||||||
|
]
|
||||||
|
except:
|
||||||
|
raise Error("not_found", "Group keycloak_id not found in system")
|
||||||
|
else:
|
||||||
|
raise Error("bad_request", "Incorrect data requested.")
|
||||||
|
return json.dumps(group_users), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/roles", methods=["GET"])
|
||||||
|
@has_token
|
||||||
|
def ddapi_roles() -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
roles = []
|
||||||
|
for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
|
||||||
|
log.error(role)
|
||||||
|
roles.append(
|
||||||
|
{
|
||||||
|
"keycloak_id": role["id"],
|
||||||
|
"id": role["name"],
|
||||||
|
"name": role["name"],
|
||||||
|
"description": role.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json.dumps(roles), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route("/ddapi/roles", methods=["GET"])
|
@app.json_route("/ddapi/role/users", methods=["POST"])
|
||||||
@has_token
|
@has_token
|
||||||
def ddapi_roles():
|
def ddapi_role_users() -> OptionalJsonResponse:
|
||||||
if request.method == "GET":
|
if request.method == "POST":
|
||||||
roles = []
|
data = request.get_json(force=True)
|
||||||
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]):
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
log.error(role)
|
if data.get("id", data.get("name")):
|
||||||
roles.append(
|
|
||||||
{
|
|
||||||
"keycloak_id": role["id"],
|
|
||||||
"id": role["name"],
|
|
||||||
"name": role["name"],
|
|
||||||
"description": role.get("description", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return json.dumps(roles), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ddapi/role/users", methods=["POST"])
|
|
||||||
@has_token
|
|
||||||
def ddapi_role_users():
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
|
||||||
if data.get("id", data.get("name")):
|
|
||||||
role_users = [
|
|
||||||
user_parser(user)
|
|
||||||
for user in sorted_users
|
|
||||||
if data.get("id", data.get("name")) in user["roles"]
|
|
||||||
]
|
|
||||||
elif data.get("keycloak_id"):
|
|
||||||
try:
|
|
||||||
id = [
|
|
||||||
r["id"]
|
|
||||||
for r in app.admin.get_roles()
|
|
||||||
if r["id"] == data.get("keycloak_id")
|
|
||||||
][0]
|
|
||||||
role_users = [
|
role_users = [
|
||||||
user_parser(user) for user in sorted_users if id in user["roles"]
|
user_parser(user)
|
||||||
|
for user in sorted_users
|
||||||
|
if data.get("id", data.get("name")) in user["roles"]
|
||||||
]
|
]
|
||||||
except:
|
elif data.get("keycloak_id"):
|
||||||
raise Error("not_found", "Role keycloak_id not found in system")
|
try:
|
||||||
else:
|
id = [
|
||||||
raise Error("bad_request", "Incorrect data requested.")
|
r["id"]
|
||||||
return json.dumps(role_users), 200, {"Content-Type": "application/json"}
|
for r in app.admin.get_roles()
|
||||||
|
if r["id"] == data.get("keycloak_id")
|
||||||
|
][0]
|
||||||
|
role_users = [
|
||||||
|
user_parser(user) for user in sorted_users if id in user["roles"]
|
||||||
|
]
|
||||||
|
except:
|
||||||
|
raise Error("not_found", "Role keycloak_id not found in system")
|
||||||
|
else:
|
||||||
|
raise Error("bad_request", "Incorrect data requested.")
|
||||||
|
return json.dumps(role_users), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
## INDIVIDUAL ACTIONS
|
||||||
## INDIVIDUAL ACTIONS
|
@app.json_route("/ddapi/user", methods=["POST"])
|
||||||
@app.route("/ddapi/user", methods=["POST"])
|
@app.json_route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
|
||||||
@app.route("/ddapi/user/<user_ddid>", methods=["PUT", "GET", "DELETE"])
|
@has_token
|
||||||
@has_token
|
def ddapi_user(user_ddid : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
def ddapi_user(user_ddid=None):
|
uid : str = user_ddid if user_ddid else ''
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
user = app.admin.get_user_username(user_ddid)
|
user = app.admin.get_user_username(uid)
|
||||||
if not user:
|
if not user:
|
||||||
raise Error("not_found", "User id not found")
|
raise Error("not_found", "User id not found")
|
||||||
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
|
return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"}
|
||||||
if request.method == "DELETE":
|
if request.method == "DELETE":
|
||||||
user = app.admin.get_user_username(user_ddid)
|
user = app.admin.get_user_username(uid)
|
||||||
if not user:
|
if not user:
|
||||||
raise Error("not_found", "User id not found")
|
raise Error("not_found", "User id not found")
|
||||||
app.admin.delete_user(user["id"])
|
app.admin.delete_user(user["id"])
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
if not app.validators["user"].validate(data):
|
if not app.validators["user"].validate(data):
|
||||||
raise Error(
|
|
||||||
"bad_request",
|
|
||||||
"Data validation for user failed: ",
|
|
||||||
+str(app.validators["user"].errors),
|
|
||||||
traceback.format_exc(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if app.admin.get_user_username(data["username"]):
|
|
||||||
raise Error("conflict", "User id already exists")
|
|
||||||
data = app.validators["user"].normalized(data)
|
|
||||||
keycloak_id = app.admin.add_user(data)
|
|
||||||
if not keycloak_id:
|
|
||||||
raise Error(
|
|
||||||
"precondition_required",
|
|
||||||
"Not all user groups already in system. Please create user groups before adding user.",
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
json.dumps({"keycloak_id": keycloak_id}),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.method == "PUT":
|
|
||||||
user = app.admin.get_user_username(user_ddid)
|
|
||||||
if not user:
|
|
||||||
raise Error("not_found", "User id not found")
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
if not app.validators["user_update"].validate(data):
|
|
||||||
raise Error(
|
|
||||||
"bad_request",
|
|
||||||
"Data validation for user failed: "
|
|
||||||
+ str(app.validators["user_update"].errors),
|
|
||||||
traceback.format_exc(),
|
|
||||||
)
|
|
||||||
data = {**user, **data}
|
|
||||||
data = app.validators["user_update"].normalized(data)
|
|
||||||
data = {**data, **{"username": user_ddid}}
|
|
||||||
data["roles"] = [data.pop("role")]
|
|
||||||
data["firstname"] = data.pop("first")
|
|
||||||
data["lastname"] = data.pop("last")
|
|
||||||
app.admin.user_update(data)
|
|
||||||
if data.get("password"):
|
|
||||||
app.admin.user_update_password(
|
|
||||||
user["id"], data["password"], data["password_temporary"]
|
|
||||||
)
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ddapi/username/<old_user_ddid>/<new_user_did>", methods=["PUT"])
|
|
||||||
@has_token
|
|
||||||
def ddapi_username(old_user_ddid, new_user_did):
|
|
||||||
user = app.admin.get_user_username(user_ddid)
|
|
||||||
if not user:
|
|
||||||
raise Error("not_found", "User id not found")
|
|
||||||
# user = app.admin.update_user_username(old_user_ddid,new_user_did)
|
|
||||||
return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ddapi/group", methods=["POST"])
|
|
||||||
@app.route("/ddapi/group/<id>", methods=["GET", "POST", "DELETE"])
|
|
||||||
# @app.route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
|
|
||||||
@has_token
|
|
||||||
def ddapi_group(id=None):
|
|
||||||
if request.method == "GET":
|
|
||||||
group = app.admin.get_group_by_name(id)
|
|
||||||
if not group:
|
|
||||||
Error("not found", "Group id not found")
|
|
||||||
return (
|
|
||||||
json.dumps(group_parser(group)),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
if not app.validators["group"].validate(data):
|
|
||||||
raise Error(
|
|
||||||
"bad_request",
|
|
||||||
"Data validation for group failed: "
|
|
||||||
+ str(app.validators["group"].errors),
|
|
||||||
traceback.format_exc(),
|
|
||||||
)
|
|
||||||
data = app.validators["group"].normalized(data)
|
|
||||||
data["parent"] = data["parent"] if data["parent"] != "" else None
|
|
||||||
|
|
||||||
if app.admin.get_group_by_name(id):
|
|
||||||
raise Error("conflict", "Group id already exists")
|
|
||||||
|
|
||||||
path = app.admin.add_group(data)
|
|
||||||
# log.error(path)
|
|
||||||
# keycloak_id = app.admin.get_group_by_name(id)["id"]
|
|
||||||
# log.error()
|
|
||||||
return (
|
|
||||||
json.dumps({"keycloak_id": None}),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "DELETE":
|
|
||||||
group = app.admin.get_group_by_name(id)
|
|
||||||
if not group:
|
|
||||||
raise Error("not_found", "Group id not found")
|
|
||||||
app.admin.delete_group_by_id(group["id"])
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ddapi/user_mail", methods=["POST"])
|
|
||||||
@app.route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
|
|
||||||
@has_token
|
|
||||||
def ddapi_user_mail(id=None):
|
|
||||||
if request.method == "GET":
|
|
||||||
return (
|
|
||||||
json.dumps("Not implemented yet"),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
|
|
||||||
# if not app.validators["mails"].validate(data):
|
|
||||||
# raise Error(
|
|
||||||
# "bad_request",
|
|
||||||
# "Data validation for mail failed: "
|
|
||||||
# + str(app.validators["mail"].errors),
|
|
||||||
# traceback.format_exc(),
|
|
||||||
# )
|
|
||||||
for user in data:
|
|
||||||
if not app.validators["mail"].validate(user):
|
|
||||||
raise Error(
|
raise Error(
|
||||||
"bad_request",
|
"bad_request",
|
||||||
"Data validation for mail failed: "
|
"Data validation for user failed: "
|
||||||
+ str(app.validators["mail"].errors),
|
+ str(app.validators["user"].errors),
|
||||||
traceback.format_exc(),
|
traceback.format_exc(),
|
||||||
)
|
)
|
||||||
for user in data:
|
|
||||||
log.info("Added user email")
|
|
||||||
app.admin.set_nextcloud_user_mail(user)
|
|
||||||
return (
|
|
||||||
json.dumps("Users emails updated"),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if app.admin.get_user_username(data["username"]):
|
||||||
|
raise Error("conflict", "User id already exists")
|
||||||
|
data = app.validators["user"].normalized(data)
|
||||||
|
keycloak_id = app.admin.add_user(data)
|
||||||
|
if not keycloak_id:
|
||||||
|
raise Error(
|
||||||
|
"precondition_required",
|
||||||
|
"Not all user groups already in system. Please create user groups before adding user.",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
json.dumps({"keycloak_id": keycloak_id}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
def user_parser(user):
|
if request.method == "PUT":
|
||||||
|
user = app.admin.get_user_username(uid)
|
||||||
|
if not user:
|
||||||
|
raise Error("not_found", "User id not found")
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if not app.validators["user_update"].validate(data):
|
||||||
|
raise Error(
|
||||||
|
"bad_request",
|
||||||
|
"Data validation for user failed: "
|
||||||
|
+ str(app.validators["user_update"].errors),
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
||||||
|
data = {**user, **data}
|
||||||
|
data = app.validators["user_update"].normalized(data)
|
||||||
|
data = {**data, **{"username": uid}}
|
||||||
|
data["roles"] = [data.pop("role")]
|
||||||
|
data["firstname"] = data.pop("first")
|
||||||
|
data["lastname"] = data.pop("last")
|
||||||
|
app.admin.user_update(data)
|
||||||
|
if data.get("password"):
|
||||||
|
app.admin.user_update_password(
|
||||||
|
user["id"], data["password"], data["password_temporary"]
|
||||||
|
)
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/username/<old_user_ddid>/<new_user_did>", methods=["PUT"])
|
||||||
|
@has_token
|
||||||
|
def ddapi_username(old_user_ddid : str, new_user_did : str) -> OptionalJsonResponse:
|
||||||
|
user = app.admin.get_user_username(old_user_ddid)
|
||||||
|
if not user:
|
||||||
|
raise Error("not_found", "User id not found")
|
||||||
|
# user = app.admin.update_user_username(old_user_ddid,new_user_did)
|
||||||
|
return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/group", methods=["POST"])
|
||||||
|
@app.json_route("/ddapi/group/<group_id>", methods=["GET", "POST", "DELETE"])
|
||||||
|
# @app.json_route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
|
||||||
|
@has_token
|
||||||
|
def ddapi_group(group_id : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
uid : str = group_id if group_id else ''
|
||||||
|
if request.method == "GET":
|
||||||
|
group = app.admin.get_group_by_name(uid)
|
||||||
|
if not group:
|
||||||
|
Error("not found", "Group id not found")
|
||||||
|
return (
|
||||||
|
json.dumps(group_parser(group)),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if not app.validators["group"].validate(data):
|
||||||
|
raise Error(
|
||||||
|
"bad_request",
|
||||||
|
"Data validation for group failed: "
|
||||||
|
+ str(app.validators["group"].errors),
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
||||||
|
data = app.validators["group"].normalized(data)
|
||||||
|
data["parent"] = data["parent"] if data["parent"] != "" else None
|
||||||
|
|
||||||
|
if app.admin.get_group_by_name(uid):
|
||||||
|
raise Error("conflict", "Group id already exists")
|
||||||
|
|
||||||
|
path = app.admin.add_group(data)
|
||||||
|
# log.error(path)
|
||||||
|
# keycloak_id = app.admin.get_group_by_name(id)["id"]
|
||||||
|
# log.error()
|
||||||
|
return (
|
||||||
|
json.dumps({"keycloak_id": None}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "DELETE":
|
||||||
|
group = app.admin.get_group_by_name(uid)
|
||||||
|
if not group:
|
||||||
|
raise Error("not_found", "Group id not found")
|
||||||
|
app.admin.delete_group_by_id(group["id"])
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/user_mail", methods=["POST"])
|
||||||
|
@app.json_route("/ddapi/user_mail/<id>", methods=["GET", "DELETE"])
|
||||||
|
@has_token
|
||||||
|
def ddapi_user_mail(id : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
# TODO: Remove this endpoint when we ensure there are no consumers
|
||||||
|
if request.method == "GET":
|
||||||
|
return (
|
||||||
|
json.dumps("Not implemented yet"),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
|
||||||
|
# if not app.validators["mails"].validate(data):
|
||||||
|
# raise Error(
|
||||||
|
# "bad_request",
|
||||||
|
# "Data validation for mail failed: "
|
||||||
|
# + str(app.validators["mail"].errors),
|
||||||
|
# traceback.format_exc(),
|
||||||
|
# )
|
||||||
|
for user in data:
|
||||||
|
if not app.validators["mail"].validate(user):
|
||||||
|
raise Error(
|
||||||
|
"bad_request",
|
||||||
|
"Data validation for mail failed: "
|
||||||
|
+ str(app.validators["mail"].errors),
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
||||||
|
for user in data:
|
||||||
|
log.info("Added user email")
|
||||||
|
app.admin.nextcloud_mail_set([user], dict())
|
||||||
|
return (
|
||||||
|
json.dumps("Users emails updated"),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# TODO: After this line, this is all mostly duplicated from other places...
|
||||||
|
def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"keycloak_id": user["id"],
|
"keycloak_id": user["id"],
|
||||||
"id": user["username"],
|
"id": user["username"],
|
||||||
|
@ -338,7 +346,7 @@ def user_parser(user):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def group_parser(group):
|
def group_parser(group : Dict[str, str]) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"keycloak_id": group["id"],
|
"keycloak_id": group["id"],
|
||||||
"id": group["name"],
|
"id": group["name"],
|
||||||
|
@ -348,7 +356,7 @@ def group_parser(group):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def filter_users(users, text):
|
def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
user
|
user
|
||||||
for user in users
|
for user in users
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -20,6 +21,7 @@
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import logging as log
|
import logging as log
|
||||||
|
from operator import itemgetter
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
@ -33,12 +35,15 @@ from uuid import uuid4
|
||||||
from flask import Response, jsonify, redirect, render_template, request, url_for
|
from flask import Response, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, cast, Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from ..lib.helpers import system_group
|
from ..lib.helpers import system_group
|
||||||
from .decorators import login_or_token
|
from .decorators import login_or_token, OptionalJsonResponse
|
||||||
|
|
||||||
threads = {"external": None}
|
# TODO: this is quirky and non-trivial to manage
|
||||||
|
threads : Dict[str, threading.Thread] = {}
|
||||||
# q = Queue.Queue()
|
# q = Queue.Queue()
|
||||||
|
|
||||||
from keycloak.exceptions import KeycloakGetError
|
from keycloak.exceptions import KeycloakGetError
|
||||||
|
@ -46,536 +51,444 @@ from keycloak.exceptions import KeycloakGetError
|
||||||
from ..lib.dashboard import Dashboard
|
from ..lib.dashboard import Dashboard
|
||||||
from ..lib.exceptions import UserExists, UserNotFound
|
from ..lib.exceptions import UserExists, UserNotFound
|
||||||
|
|
||||||
dashboard = Dashboard()
|
|
||||||
|
|
||||||
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
|
from ..lib.legal import get_legal, gen_legal_if_not_exists, new_legal
|
||||||
|
|
||||||
@app.route("/sysadmin/api/resync")
|
def run_in_thread(
|
||||||
@app.route("/api/resync")
|
op : Callable[..., Any],
|
||||||
@login_required
|
args : Tuple = tuple(),
|
||||||
def resync():
|
err_msg : str = "Something went wrong",
|
||||||
return (
|
err_code : int = 500,
|
||||||
json.dumps(app.admin.resync_data()),
|
busy_err_msg : str ="Precondition failed: already operating users"
|
||||||
200,
|
) -> OptionalJsonResponse:
|
||||||
{"Content-Type": "application/json"},
|
if threads.get("external", None) is not None:
|
||||||
)
|
if threads["external"].is_alive():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/users", methods=["GET", "PUT"])
|
|
||||||
@app.route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
|
|
||||||
@login_or_token
|
|
||||||
def users(provider=False):
|
|
||||||
if request.method == "DELETE":
|
|
||||||
if current_user.role != "admin":
|
|
||||||
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
|
||||||
if provider == "keycloak":
|
|
||||||
return (
|
return (
|
||||||
json.dumps(app.admin.delete_keycloak_users()),
|
json.dumps(
|
||||||
200,
|
{"msg": busy_err_msg}
|
||||||
|
),
|
||||||
|
412,
|
||||||
{"Content-Type": "application/json"},
|
{"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
if provider == "nextcloud":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.delete_nextcloud_users()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if provider == "moodle":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.delete_moodle_users()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "POST":
|
|
||||||
if current_user.role != "admin":
|
|
||||||
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
|
||||||
if provider == "moodle":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.sync_to_moodle()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if provider == "nextcloud":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.sync_to_nextcloud()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "PUT" and not provider:
|
|
||||||
if current_user.role != "admin":
|
|
||||||
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return (
|
|
||||||
json.dumps(
|
|
||||||
{"msg": "Precondition failed: already working with users"}
|
|
||||||
),
|
|
||||||
412,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
try:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.update_users_from_keycloak, args=()
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Add user error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# return json.dumps(app.admin.update_users_from_keycloak()), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
users = app.admin.get_mix_users()
|
|
||||||
if current_user.role != "admin":
|
|
||||||
for user in users:
|
|
||||||
user["keycloak_groups"] = [
|
|
||||||
g for g in user["keycloak_groups"] if not system_group(g)
|
|
||||||
]
|
|
||||||
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/users_bulk/<action>", methods=["PUT"])
|
|
||||||
@login_required
|
|
||||||
def users_bulk(action):
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
if request.method == "PUT":
|
|
||||||
if action == "enable":
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return (
|
|
||||||
json.dumps(
|
|
||||||
{"msg": "Precondition failed: already operating users"}
|
|
||||||
),
|
|
||||||
412,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
try:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.enable_users, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Enable users error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if action == "disable":
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return (
|
|
||||||
json.dumps(
|
|
||||||
{"msg": "Precondition failed: already operating users"}
|
|
||||||
),
|
|
||||||
412,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
try:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.disable_users, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Disabling users error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if action == "delete":
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return (
|
|
||||||
json.dumps(
|
|
||||||
{"msg": "Precondition failed: already operating users"}
|
|
||||||
),
|
|
||||||
412,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
try:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.delete_users, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Deleting users error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
return json.dumps({}), 405, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
# Update pwd
|
|
||||||
@app.route("/api/user_password", methods=["GET"])
|
|
||||||
@app.route("/api/user_password/<userid>", methods=["PUT"])
|
|
||||||
@login_required
|
|
||||||
def user_password(userid=False):
|
|
||||||
if request.method == "GET":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.get_dice_pwd()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "PUT":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
password = data["password"]
|
|
||||||
temporary = data.get("temporary", True)
|
|
||||||
try:
|
|
||||||
res = app.admin.user_update_password(userid, password, temporary)
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except KeycloakGetError as e:
|
|
||||||
log.error(e.error_message.decode("utf-8"))
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Update password error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return json.dumps({}), 405, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
# User
|
|
||||||
@app.route("/api/user", methods=["POST"])
|
|
||||||
@app.route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
|
|
||||||
@login_required
|
|
||||||
def user(userid=None):
|
|
||||||
if request.method == "DELETE":
|
|
||||||
app.admin.delete_user(userid)
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
if app.admin.get_user_username(data["username"]):
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Add user error: already exists."}),
|
|
||||||
409,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
data["enabled"] = data.get("enabled", False) in [True, "on"]
|
|
||||||
data["quota"] = data["quota"] if data["quota"] != "false" else False
|
|
||||||
data["groups"] = data["groups"] if data.get("groups", False) else []
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Precondition failed: already adding users"}),
|
|
||||||
412,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
try:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.add_user, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "Add user error."}),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.method == "PUT":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
data["enabled"] = True if data.get("enabled", False) else False
|
|
||||||
data["groups"] = data["groups"] if data.get("groups", False) else []
|
|
||||||
data["roles"] = [data.pop("role-keycloak")]
|
|
||||||
try:
|
|
||||||
app.admin.user_update(data)
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
except UserNotFound:
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "User not found."}),
|
|
||||||
404,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "DELETE":
|
|
||||||
pass
|
|
||||||
if request.method == "GET":
|
|
||||||
user = app.admin.get_user(userid)
|
|
||||||
if not user:
|
|
||||||
return (
|
|
||||||
json.dumps({"msg": "User not found."}),
|
|
||||||
404,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
return json.dumps(user), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/roles")
|
|
||||||
@login_required
|
|
||||||
def roles():
|
|
||||||
sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"])
|
|
||||||
if current_user.role != "admin":
|
|
||||||
sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"]
|
|
||||||
return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/group", methods=["POST", "DELETE"])
|
|
||||||
@app.route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
|
|
||||||
@login_required
|
|
||||||
def group(group_id=False):
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
log.error(data)
|
|
||||||
data["parent"] = data["parent"] if data["parent"] != "" else None
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.add_group(data)),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
if request.method == "DELETE":
|
|
||||||
try:
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
except:
|
|
||||||
data = False
|
|
||||||
|
|
||||||
if data:
|
|
||||||
res = app.admin.delete_group_by_path(data["path"])
|
|
||||||
else:
|
else:
|
||||||
if not group_id:
|
del threads["external"]
|
||||||
return (
|
try:
|
||||||
json.dumps({"error": "bad_request","msg":"Bad request"}),
|
|
||||||
400,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
res = app.admin.delete_group_by_id(group_id)
|
|
||||||
return json.dumps(res), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/groups")
|
|
||||||
@app.route("/api/groups/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
|
|
||||||
@login_required
|
|
||||||
def groups(provider=False):
|
|
||||||
if request.method == "GET":
|
|
||||||
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"]))
|
|
||||||
if current_user.role != "admin":
|
|
||||||
## internal groups should be avoided as are assigned with the role
|
|
||||||
sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])]
|
|
||||||
else:
|
|
||||||
sorted_groups = [sg for sg in sorted_groups]
|
|
||||||
return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"}
|
|
||||||
if request.method == "DELETE":
|
|
||||||
if provider == "keycloak":
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.delete_keycloak_groups()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
### SYSADM USERS ONLY
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
|
|
||||||
@login_required
|
|
||||||
def external():
|
|
||||||
if "external" in threads.keys():
|
|
||||||
if threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
|
||||||
else:
|
|
||||||
threads["external"] = None
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
if data["format"] == "json-ga":
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.upload_json_ga, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
if data["format"] == "csv-ug":
|
|
||||||
valid = check_upload_errors(data)
|
|
||||||
if valid["pass"]:
|
|
||||||
threads["external"] = threading.Thread(
|
|
||||||
target=app.admin.upload_csv_ug, args=(data,)
|
|
||||||
)
|
|
||||||
threads["external"].start()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
else:
|
|
||||||
return json.dumps(valid), 422, {"Content-Type": "application/json"}
|
|
||||||
if request.method == "PUT":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
threads["external"] = threading.Thread(
|
threads["external"] = threading.Thread(
|
||||||
target=app.admin.sync_external, args=(data,)
|
target=op, args=args
|
||||||
)
|
)
|
||||||
|
# TODO: this probably returns immediately and client gets no real feedback
|
||||||
threads["external"].start()
|
threads["external"].start()
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
if request.method == "DELETE":
|
except:
|
||||||
print("RESET")
|
log.error(traceback.format_exc())
|
||||||
app.admin.reset_external()
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
return json.dumps({}), 500, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/external/users")
|
|
||||||
@login_required
|
|
||||||
def external_users_list():
|
|
||||||
while threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
time.sleep(0.5)
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.get_external_users()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/external/groups")
|
|
||||||
@login_required
|
|
||||||
def external_groups_list():
|
|
||||||
while threads["external"] is not None and threads["external"].is_alive():
|
|
||||||
time.sleep(0.5)
|
|
||||||
return (
|
|
||||||
json.dumps(app.admin.get_external_groups()),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/external/roles", methods=["PUT"])
|
|
||||||
@login_required
|
|
||||||
def external_roles():
|
|
||||||
if request.method == "PUT":
|
|
||||||
return (
|
return (
|
||||||
json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
|
json.dumps({"msg": err_msg}),
|
||||||
|
err_code,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def setup_app_views(app : "AdminFlaskApp") -> None:
|
||||||
|
dashboard = Dashboard(app)
|
||||||
|
@app.json_route("/sysadmin/api/resync")
|
||||||
|
@app.json_route("/api/resync")
|
||||||
|
@login_required
|
||||||
|
def resync() -> OptionalJsonResponse:
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.resync_data()),
|
||||||
200,
|
200,
|
||||||
{"Content-Type": "application/json"},
|
{"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_upload_errors(data):
|
@app.json_route("/api/users", methods=["GET", "PUT"])
|
||||||
email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
@app.json_route("/api/users/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
|
||||||
for u in data["data"]:
|
@login_or_token
|
||||||
try:
|
def users(provider : bool=False) -> OptionalJsonResponse:
|
||||||
user_groups = [g.strip() for g in u["groups"].split(",")]
|
if request.method == "DELETE":
|
||||||
except:
|
if current_user.role != "admin":
|
||||||
resp = {
|
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
||||||
"pass": False,
|
if provider == "keycloak":
|
||||||
"msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
|
return (
|
||||||
}
|
json.dumps(app.admin.delete_keycloak_users()),
|
||||||
log.error(resp)
|
200,
|
||||||
return resp
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if provider == "nextcloud":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.delete_nextcloud_users()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if provider == "moodle":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.delete_moodle_users(app)),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
||||||
|
if provider == "moodle":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.sync_to_moodle()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if provider == "nextcloud":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.sync_to_nextcloud()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "PUT" and not provider:
|
||||||
|
if current_user.role != "admin":
|
||||||
|
return json.dumps({}), 301, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
if not re.fullmatch(email_regex, u["email"]):
|
return run_in_thread(app.admin.update_users_from_keycloak, err_msg="Add user error.")
|
||||||
resp = {
|
|
||||||
"pass": False,
|
|
||||||
"msg": "User " + u["username"] + " has invalid email: " + u["email"],
|
|
||||||
}
|
|
||||||
log.error(resp)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
if u["role"] not in ["admin", "manager", "teacher", "student"]:
|
users = app.admin.get_mix_users()
|
||||||
if u["role"] == "":
|
if current_user.role != "admin":
|
||||||
|
for user in users:
|
||||||
|
user["keycloak_groups"] = [
|
||||||
|
g for g in user["keycloak_groups"] if not system_group(g)
|
||||||
|
]
|
||||||
|
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/users_bulk/<action>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def users_bulk(action : str) -> OptionalJsonResponse:
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if request.method == "PUT":
|
||||||
|
if action == "enable":
|
||||||
|
return run_in_thread(app.admin.enable_users, args=(data,), err_msg="Enable users error.")
|
||||||
|
if action == "disable":
|
||||||
|
return run_in_thread(app.admin.disable_users, args=(data,), err_msg="Disabling users error.")
|
||||||
|
if action == "delete":
|
||||||
|
return run_in_thread(app.admin.delete_users, args=(data,), err_msg="Deleting users error.")
|
||||||
|
|
||||||
|
return json.dumps({}), 405, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
# Update pwd
|
||||||
|
@app.json_route("/api/user_password", methods=["GET"])
|
||||||
|
@app.json_route("/api/user_password/<userid>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def user_password(userid : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.get_dice_pwd()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "PUT":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
password = data["password"]
|
||||||
|
temporary = data.get("temporary", True)
|
||||||
|
uid = cast(str, userid)
|
||||||
|
try:
|
||||||
|
res = app.admin.user_update_password(uid, password, temporary)
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
except KeycloakGetError as e:
|
||||||
|
log.error(e.error_message.decode("utf-8"))
|
||||||
|
return (
|
||||||
|
json.dumps({"msg": "Update password error."}),
|
||||||
|
500,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps({}), 405, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
# User
|
||||||
|
@app.json_route("/api/user", methods=["POST"])
|
||||||
|
@app.json_route("/api/user/<userid>", methods=["PUT", "GET", "DELETE"])
|
||||||
|
@login_required
|
||||||
|
def user(userid : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
# This is where changes happen from the UI
|
||||||
|
uid : str = userid if userid else ''
|
||||||
|
if request.method == "DELETE":
|
||||||
|
app.admin.delete_user(uid)
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if app.admin.get_user_username(data["username"]):
|
||||||
|
return (
|
||||||
|
json.dumps({"msg": "Add user error: already exists."}),
|
||||||
|
409,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
data["enabled"] = data.get("enabled", False) in [True, "on"]
|
||||||
|
data["quota"] = data["quota"] if data["quota"] != "false" else False
|
||||||
|
data["groups"] = data["groups"] if data.get("groups", False) else []
|
||||||
|
return run_in_thread(app.admin.add_user, args=(data,), err_msg="Add user error")
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
data["enabled"] = True if data.get("enabled", False) else False
|
||||||
|
data["groups"] = data["groups"] if data.get("groups", False) else []
|
||||||
|
data["roles"] = [data.pop("role-keycloak")]
|
||||||
|
try:
|
||||||
|
app.admin.user_update(data)
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
except UserNotFound:
|
||||||
|
return (
|
||||||
|
json.dumps({"msg": "User not found."}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "DELETE":
|
||||||
|
pass
|
||||||
|
if request.method == "GET":
|
||||||
|
user = app.admin.get_user(uid)
|
||||||
|
if not user:
|
||||||
|
return (
|
||||||
|
json.dumps({"msg": "User not found."}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return json.dumps(user), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/api/roles")
|
||||||
|
@login_required
|
||||||
|
def roles() -> OptionalJsonResponse:
|
||||||
|
sorted_roles = sorted(app.admin.get_roles(), key=itemgetter("name"))
|
||||||
|
if current_user.role != "admin":
|
||||||
|
sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"]
|
||||||
|
return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/group", methods=["POST", "DELETE"])
|
||||||
|
@app.json_route("/api/group/<group_id>", methods=["PUT", "GET", "DELETE"])
|
||||||
|
@login_required
|
||||||
|
def group(group_id : Optional[str]=None) -> OptionalJsonResponse:
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
log.error(data)
|
||||||
|
data["parent"] = data["parent"] if data["parent"] != "" else None
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.add_group(data)),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if request.method == "DELETE":
|
||||||
|
try:
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
except:
|
||||||
|
data = False
|
||||||
|
|
||||||
|
if data:
|
||||||
|
res = app.admin.delete_group_by_path(data["path"])
|
||||||
|
else:
|
||||||
|
if not group_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "bad_request","msg":"Bad request"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
res = app.admin.delete_group_by_id(group_id)
|
||||||
|
return json.dumps(res), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/api/groups")
|
||||||
|
@app.json_route("/api/groups/<provider>", methods=["POST", "PUT", "GET", "DELETE"])
|
||||||
|
@login_required
|
||||||
|
def groups(provider : Optional[str] = None) -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"]))
|
||||||
|
if current_user.role != "admin":
|
||||||
|
## internal groups should be avoided as are assigned with the role
|
||||||
|
sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])]
|
||||||
|
else:
|
||||||
|
sorted_groups = [sg for sg in sorted_groups]
|
||||||
|
return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"}
|
||||||
|
if request.method == "DELETE":
|
||||||
|
if provider == "keycloak":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.delete_keycloak_groups()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
### SYSADM USERS ONLY
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/external", methods=["POST", "PUT", "GET", "DELETE"])
|
||||||
|
@login_required
|
||||||
|
def external() -> OptionalJsonResponse:
|
||||||
|
if request.method == "POST":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if data["format"] == "json-ga":
|
||||||
|
return run_in_thread(app.admin.upload_json_ga, args=(data,))
|
||||||
|
if data["format"] == "csv-ug":
|
||||||
|
valid = check_upload_errors(data)
|
||||||
|
if valid["pass"]:
|
||||||
|
return run_in_thread(app.admin.upload_csv_ug, args=(data,))
|
||||||
|
else:
|
||||||
|
return json.dumps(valid), 422, {"Content-Type": "application/json"}
|
||||||
|
if request.method == "PUT":
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
return run_in_thread(app.admin.sync_external, args=(data,))
|
||||||
|
if request.method == "DELETE":
|
||||||
|
print("RESET")
|
||||||
|
app.admin.reset_external()
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/external/users")
|
||||||
|
@login_required
|
||||||
|
def external_users_list() -> OptionalJsonResponse:
|
||||||
|
while threads["external"] is not None and threads["external"].is_alive():
|
||||||
|
time.sleep(0.5)
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.get_external_users()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/external/groups")
|
||||||
|
@login_required
|
||||||
|
def external_groups_list() -> OptionalJsonResponse:
|
||||||
|
while threads["external"] is not None and threads["external"].is_alive():
|
||||||
|
time.sleep(0.5)
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.get_external_groups()),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/external/roles", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def external_roles() -> OptionalJsonResponse:
|
||||||
|
if request.method == "PUT":
|
||||||
|
return (
|
||||||
|
json.dumps(app.admin.external_roleassign(request.get_json(force=True))),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_upload_errors(data : Dict[Any, Any]) -> Dict[Any, Any]:
|
||||||
|
email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
||||||
|
for u in data["data"]:
|
||||||
|
try:
|
||||||
|
user_groups = [g.strip() for g in u["groups"].split(",")]
|
||||||
|
except:
|
||||||
resp = {
|
resp = {
|
||||||
"pass": False,
|
"pass": False,
|
||||||
"msg": "User " + u["username"] + " has no role assigned!",
|
"msg": "User " + u["username"] + " has invalid groups: " + u["groups"],
|
||||||
}
|
}
|
||||||
log.error(resp)
|
log.error(resp)
|
||||||
return resp
|
return resp
|
||||||
resp = {
|
|
||||||
"pass": False,
|
if not re.fullmatch(email_regex, u["email"]):
|
||||||
"msg": "User " + u["username"] + " has invalid role: " + u["role"],
|
resp = {
|
||||||
}
|
"pass": False,
|
||||||
log.error(resp)
|
"msg": "User " + u["username"] + " has invalid email: " + u["email"],
|
||||||
return resp
|
}
|
||||||
return {"pass": True, "msg": ""}
|
log.error(resp)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
if u["role"] not in ["admin", "manager", "teacher", "student"]:
|
||||||
|
if u["role"] == "":
|
||||||
|
resp = {
|
||||||
|
"pass": False,
|
||||||
|
"msg": "User " + u["username"] + " has no role assigned!",
|
||||||
|
}
|
||||||
|
log.error(resp)
|
||||||
|
return resp
|
||||||
|
resp = {
|
||||||
|
"pass": False,
|
||||||
|
"msg": "User " + u["username"] + " has invalid role: " + u["role"],
|
||||||
|
}
|
||||||
|
log.error(resp)
|
||||||
|
return resp
|
||||||
|
return {"pass": True, "msg": ""}
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/dashboard/<item>", methods=["PUT"])
|
@app.json_route("/api/dashboard/<item>", methods=["PUT"])
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard_put(item):
|
def dashboard_put(item : str) -> OptionalJsonResponse:
|
||||||
if item == "colours":
|
if item == "colours":
|
||||||
try:
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
dashboard.update_colours(data)
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
|
|
||||||
if item == "menu":
|
|
||||||
try:
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
dashboard.update_menu(data)
|
|
||||||
except:
|
|
||||||
log.error(traceback.format_exc())
|
|
||||||
return json.dumps(data), 200, {"Content-Type": "application/json"}
|
|
||||||
if item == "logo":
|
|
||||||
dashboard.update_logo(request.files["croppedImage"])
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
if item == "background":
|
|
||||||
dashboard.update_background(request.files["croppedImage"])
|
|
||||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
|
||||||
return (
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"error": "update_error",
|
|
||||||
"msg": "Error updating item " + item + "\n" + traceback.format_exc(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
500,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/legal/<item>", methods=["GET"])
|
|
||||||
# @login_required
|
|
||||||
def legal_get(item):
|
|
||||||
if request.method == "GET":
|
|
||||||
if item == "legal":
|
|
||||||
lang = request.args.get("lang")
|
|
||||||
if not lang or lang not in ["ca","es","en","fr"]:
|
|
||||||
lang="ca"
|
|
||||||
gen_legal_if_not_exists(lang)
|
|
||||||
return (
|
|
||||||
json.dumps({"html": get_legal(lang)}),
|
|
||||||
200,
|
|
||||||
{"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
# if item == "privacy":
|
|
||||||
# return json.dumps({ "html": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/legal/<item>", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
def legal_put(item):
|
|
||||||
if request.method == "POST":
|
|
||||||
if item == "legal":
|
|
||||||
data = None
|
|
||||||
try:
|
try:
|
||||||
data = data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
html = data["html"]
|
dashboard.update_colours(data)
|
||||||
lang = data["lang"]
|
except:
|
||||||
if not lang or lang not in ["ca","es","en","fr"]:
|
log.error(traceback.format_exc())
|
||||||
lang="ca"
|
return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"}
|
||||||
new_legal(lang,html)
|
if item == "menu":
|
||||||
|
try:
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
dashboard.update_menu(data)
|
||||||
except:
|
except:
|
||||||
log.error(traceback.format_exc())
|
log.error(traceback.format_exc())
|
||||||
return json.dumps(data), 200, {"Content-Type": "application/json"}
|
return json.dumps(data), 200, {"Content-Type": "application/json"}
|
||||||
# if item == "privacy":
|
if item == "logo":
|
||||||
# data = None
|
dashboard.update_logo(request.files["croppedImage"])
|
||||||
# try:
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
# data = request.json
|
if item == "background":
|
||||||
# html = data["html"]
|
dashboard.update_background(request.files["croppedImage"])
|
||||||
# lang = data["lang"]
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
# except:
|
return (
|
||||||
# log.error(traceback.format_exc())
|
json.dumps(
|
||||||
# return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
{
|
||||||
|
"error": "update_error",
|
||||||
|
"msg": "Error updating item " + item + "\n" + traceback.format_exc(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
500,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/legal/<item>", methods=["GET"])
|
||||||
|
# @login_required
|
||||||
|
def legal_get(item : str) -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
if item == "legal":
|
||||||
|
lang = request.args.get("lang")
|
||||||
|
if not lang or lang not in ["ca","es","en","fr"]:
|
||||||
|
lang="ca"
|
||||||
|
gen_legal_if_not_exists(app, lang)
|
||||||
|
return (
|
||||||
|
json.dumps({"html": get_legal(app, lang)}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
# if item == "privacy":
|
||||||
|
# return json.dumps({ "html": "<b>Privacy policy</b><br>This works!"}), 200, {'Content-Type': 'application/json'}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.json_route("/api/legal/<item>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def legal_put(item : str) -> OptionalJsonResponse:
|
||||||
|
if request.method == "POST":
|
||||||
|
if item == "legal":
|
||||||
|
data = None
|
||||||
|
try:
|
||||||
|
data = data = request.get_json(force=True)
|
||||||
|
html = data["html"]
|
||||||
|
lang = data["lang"]
|
||||||
|
if not lang or lang not in ["ca","es","en","fr"]:
|
||||||
|
lang="ca"
|
||||||
|
new_legal(app, lang, html)
|
||||||
|
except:
|
||||||
|
log.error(traceback.format_exc())
|
||||||
|
return json.dumps(data), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
# if item == "privacy":
|
||||||
|
# data = None
|
||||||
|
# try:
|
||||||
|
# data = request.json
|
||||||
|
# html = data["html"]
|
||||||
|
# lang = data["lang"]
|
||||||
|
# except:
|
||||||
|
# log.error(traceback.format_exc())
|
||||||
|
# return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -21,41 +22,44 @@ import os
|
||||||
|
|
||||||
from flask import flash, redirect, render_template, request, url_for
|
from flask import flash, redirect, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required, login_user, logout_user
|
from flask_login import current_user, login_required, login_user, logout_user
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from ..auth.authentication import *
|
from ..auth.authentication import *
|
||||||
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
def setup_login_views(app : "AdminFlaskApp") -> None:
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def login():
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
if request.method == "POST":
|
def login() -> Response:
|
||||||
if request.form["user"] == "" or request.form["password"] == "":
|
if request.method == "POST":
|
||||||
flash("Can't leave it blank", "danger")
|
if request.form["user"] == "" or request.form["password"] == "":
|
||||||
elif request.form["user"].startswith(" "):
|
flash("Can't leave it blank", "danger")
|
||||||
flash("Username not found or incorrect password.", "warning")
|
elif request.form["user"].startswith(" "):
|
||||||
else:
|
|
||||||
ram_user = ram_users.get(request.form["user"])
|
|
||||||
if ram_user and request.form["password"] == ram_user["password"]:
|
|
||||||
user = User(
|
|
||||||
{
|
|
||||||
"id": ram_user["id"],
|
|
||||||
"password": ram_user["password"],
|
|
||||||
"role": ram_user["role"],
|
|
||||||
"active": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
login_user(user)
|
|
||||||
flash("Logged in successfully.", "success")
|
|
||||||
return redirect(url_for("web_users"))
|
|
||||||
else:
|
|
||||||
flash("Username not found or incorrect password.", "warning")
|
flash("Username not found or incorrect password.", "warning")
|
||||||
return render_template("login.html")
|
else:
|
||||||
|
ram_user = ram_users.get(request.form["user"])
|
||||||
|
if ram_user and request.form["password"] == ram_user["password"]:
|
||||||
|
user = User(
|
||||||
|
id = ram_user["id"],
|
||||||
|
password = ram_user["password"],
|
||||||
|
role = ram_user["role"],
|
||||||
|
active = True,
|
||||||
|
)
|
||||||
|
login_user(user)
|
||||||
|
flash("Logged in successfully.", "success")
|
||||||
|
return redirect(url_for("web_users"))
|
||||||
|
else:
|
||||||
|
flash("Username not found or incorrect password.", "warning")
|
||||||
|
o : Response = app.make_response(render_template("login.html"))
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logout", methods=["GET"])
|
@app.route("/logout", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout() -> Response:
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2022 MaadiX
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
from operator import itemgetter
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, cast
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from admin.auth.jws_tokens import has_jws_token
|
||||||
|
from admin.lib.callbacks import user_parser
|
||||||
|
from admin.views.decorators import JsonResponse
|
||||||
|
|
||||||
|
from ..lib.api_exceptions import Error
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
|
JsonHeaders = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def setup_mail_views(app: "AdminFlaskApp") -> None:
|
||||||
|
mail_3p = app.api_3p["correu"]
|
||||||
|
|
||||||
|
@app.json_route("/ddapi/pubkey", methods=["GET"])
|
||||||
|
def pub_key() -> JsonResponse:
|
||||||
|
key = json.dumps(mail_3p.our_pubkey_jwk)
|
||||||
|
return key, 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@app.route("/ddapi/mailusers", methods=["GET", "POST", "PUT", "DELETE"])
|
||||||
|
@has_jws_token(app)
|
||||||
|
def ddapi_mail_users() -> JsonResponse:
|
||||||
|
users: List[Dict[str, Any]] = []
|
||||||
|
if request.method == "GET":
|
||||||
|
try:
|
||||||
|
sorted_users = sorted(
|
||||||
|
app.admin.get_mix_users(), key=itemgetter("username")
|
||||||
|
)
|
||||||
|
for user in sorted_users:
|
||||||
|
users.append(user_parser(user))
|
||||||
|
# Encrypt data with mail client public key
|
||||||
|
enc = mail_3p.sign_and_encrypt_outgoing_json({"users": users})
|
||||||
|
headers = mail_3p.get_outgoing_request_headers()
|
||||||
|
headers.update(JsonHeaders)
|
||||||
|
return enc, 200, headers
|
||||||
|
except:
|
||||||
|
raise Error(
|
||||||
|
"internal_server", "Failure sending users", traceback.format_exc()
|
||||||
|
)
|
||||||
|
if request.method not in ["POST", "PUT", "DELETE"]:
|
||||||
|
# Unsupported method
|
||||||
|
return json.dumps({}), 400, JsonHeaders
|
||||||
|
|
||||||
|
try:
|
||||||
|
dec_data = cast(
|
||||||
|
Dict, mail_3p.verify_and_decrypt_incoming_json(request.get_data())
|
||||||
|
)
|
||||||
|
users = dec_data.pop("users")
|
||||||
|
for user in users:
|
||||||
|
if not app.validators["mail"].validate(user):
|
||||||
|
raise Error(
|
||||||
|
"bad_request",
|
||||||
|
"Data validation for mail failed: "
|
||||||
|
+ str(app.validators["mail"].errors),
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
||||||
|
res: Dict
|
||||||
|
if request.method in ["POST", "PUT"]:
|
||||||
|
res = app.admin.nextcloud_mail_set(users, dec_data)
|
||||||
|
elif request.method == "DELETE":
|
||||||
|
res = app.admin.nextcloud_mail_delete(users, dec_data)
|
||||||
|
return (
|
||||||
|
json.dumps(res),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise Error(
|
||||||
|
"internal_server",
|
||||||
|
"Failure changing user emails",
|
||||||
|
traceback.format_exc(),
|
||||||
|
)
|
|
@ -1,39 +0,0 @@
|
||||||
#
|
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
|
||||||
#
|
|
||||||
# This file is part of DD
|
|
||||||
#
|
|
||||||
# DD is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
# option) any later version.
|
|
||||||
#
|
|
||||||
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
||||||
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
# from flask_socketio import SocketIO, emit, join_room, leave_room, \
|
|
||||||
# close_room, rooms, disconnect, send
|
|
||||||
# from admin import app
|
|
||||||
# import json
|
|
||||||
|
|
||||||
# # socketio = SocketIO(app)
|
|
||||||
# # from ...start import socketio
|
|
||||||
|
|
||||||
# @socketio.on('connect', namespace='//sio')
|
|
||||||
# def socketio_connect():
|
|
||||||
# join_room('admin')
|
|
||||||
# socketio.emit('update',
|
|
||||||
# json.dumps('Joined'),
|
|
||||||
# namespace='//sio',
|
|
||||||
# room='admin')
|
|
||||||
|
|
||||||
# @socketio.on('disconnect', namespace='//sio')
|
|
||||||
# def socketio_domains_disconnect():
|
|
||||||
# None
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -34,134 +35,110 @@ from flask import (
|
||||||
jsonify,
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
request,
|
request,
|
||||||
|
Response,
|
||||||
send_file,
|
send_file,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from flask import render_template as render_template_flask
|
from flask import render_template as render_template_flask
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from ..lib.avatars import Avatars
|
|
||||||
from .decorators import is_admin
|
from .decorators import is_admin
|
||||||
|
|
||||||
avatars = Avatars()
|
|
||||||
|
|
||||||
from ..lib.legal import gen_legal_if_not_exists
|
from ..lib.legal import gen_legal_if_not_exists
|
||||||
|
|
||||||
""" OIDC TESTS """
|
|
||||||
# from ..auth.authentication import oidc
|
|
||||||
|
|
||||||
# @app.route('/custom_callback')
|
def render_template(*args : str, **kwargs : str) -> str:
|
||||||
# @oidc.custom_callback
|
|
||||||
# def callback(data):
|
|
||||||
# return 'Hello. You submitted %s' % data
|
|
||||||
|
|
||||||
# @app.route('/private')
|
|
||||||
# @oidc.require_login
|
|
||||||
# def hello_me():
|
|
||||||
# info = oidc.user_getinfo(['email', 'openid_id'])
|
|
||||||
# return ('Hello, %s (%s)! <a href="/">Return</a>' %
|
|
||||||
# (info.get('email'), info.get('openid_id')))
|
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/api')
|
|
||||||
# @oidc.accept_token(True, ['openid'])
|
|
||||||
# def hello_api():
|
|
||||||
# return json.dumps({'hello': 'Welcome %s' % g.oidc_token_info['sub']})
|
|
||||||
|
|
||||||
|
|
||||||
# @app.route('/logout')
|
|
||||||
# def logoutoidc():
|
|
||||||
# oidc.logout()
|
|
||||||
# return 'Hi, you have been logged out! <a href="/">Return</a>'
|
|
||||||
""" OIDC TESTS """
|
|
||||||
|
|
||||||
def render_template(*args, **kwargs):
|
|
||||||
kwargs["DOMAIN"] = os.environ["DOMAIN"]
|
kwargs["DOMAIN"] = os.environ["DOMAIN"]
|
||||||
return render_template_flask(*args, **kwargs)
|
return render_template_flask(*args, **kwargs)
|
||||||
|
|
||||||
@app.route("/users")
|
def setup_web_views(app : "AdminFlaskApp") -> None:
|
||||||
@login_required
|
@app.route("/users")
|
||||||
def web_users():
|
@login_required
|
||||||
return render_template("pages/users.html", title="Users", nav="Users")
|
def web_users() -> str:
|
||||||
|
return render_template("pages/users.html", title="Users", nav="Users")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/roles")
|
@app.route("/roles")
|
||||||
@login_required
|
@login_required
|
||||||
def web_roles():
|
def web_roles() -> str:
|
||||||
return render_template("pages/roles.html", title="Roles", nav="Roles")
|
return render_template("pages/roles.html", title="Roles", nav="Roles")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/groups")
|
@app.route("/groups")
|
||||||
@login_required
|
@login_required
|
||||||
def web_groups(provider=False):
|
def web_groups(provider : bool=False) -> str:
|
||||||
return render_template("pages/groups.html", title="Groups", nav="Groups")
|
return render_template("pages/groups.html", title="Groups", nav="Groups")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/avatar/<userid>", methods=["GET"])
|
@app.route("/avatar/<userid>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def avatar(userid):
|
def avatar(userid : str) -> Response:
|
||||||
if userid != "false":
|
if userid != "false":
|
||||||
return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
|
return send_file("../avatars/master-avatars/" + userid, mimetype="image/jpeg")
|
||||||
return send_file("static/img/missing.jpg", mimetype="image/jpeg")
|
return send_file("static/img/missing.jpg", mimetype="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dashboard")
|
@app.route("/dashboard")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard(provider=False):
|
def dashboard(provider : bool=False) -> str:
|
||||||
data = json.loads(requests.get("http://dd-sso-api/json").text)
|
data = json.loads(requests.get("http://dd-sso-api/json").text)
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/dashboard.html", title="Customization", nav="Customization", data=data
|
"pages/dashboard.html", title="Customization", nav="Customization", data=data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/legal")
|
@app.route("/legal")
|
||||||
@login_required
|
@login_required
|
||||||
def legal():
|
def legal() -> str:
|
||||||
# data = json.loads(requests.get("http://dd-sso-api/json").text)
|
# data = json.loads(requests.get("http://dd-sso-api/json").text)
|
||||||
return render_template("pages/legal.html", title="Legal", nav="Legal", data={})
|
return render_template("pages/legal.html", title="Legal", nav="Legal", data="")
|
||||||
|
|
||||||
@app.route("/legal_text")
|
@app.route("/legal_text")
|
||||||
def legal_text():
|
def legal_text() -> str:
|
||||||
lang = request.args.get("lang")
|
lang = request.args.get("lang")
|
||||||
if not lang or lang not in ["ca","es","en","fr"]:
|
if not lang or lang not in ["ca","es","en","fr"]:
|
||||||
lang="ca"
|
lang="ca"
|
||||||
gen_legal_if_not_exists(lang)
|
gen_legal_if_not_exists(app, lang)
|
||||||
return render_template("pages/legal/"+lang)
|
return render_template("pages/legal/"+lang)
|
||||||
|
|
||||||
### SYS ADMIN
|
### SYS ADMIN
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sysadmin/users")
|
@app.route("/sysadmin/users")
|
||||||
@login_required
|
@login_required
|
||||||
@is_admin
|
@is_admin
|
||||||
def web_sysadmin_users():
|
def web_sysadmin_users() -> Response:
|
||||||
return render_template(
|
o : Response = app.make_response(render_template(
|
||||||
"pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
|
"pages/sysadmin/users.html", title="SysAdmin Users", nav="SysAdminUsers"
|
||||||
)
|
))
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sysadmin/groups")
|
@app.route("/sysadmin/groups")
|
||||||
@login_required
|
@login_required
|
||||||
@is_admin
|
@is_admin
|
||||||
def web_sysadmin_groups():
|
def web_sysadmin_groups() -> Response:
|
||||||
return render_template(
|
o : Response = app.make_response(render_template(
|
||||||
"pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
|
"pages/sysadmin/groups.html", title="SysAdmin Groups", nav="SysAdminGroups"
|
||||||
)
|
))
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sysadmin/external")
|
@app.route("/sysadmin/external")
|
||||||
@login_required
|
@login_required
|
||||||
## SysAdmin role
|
## SysAdmin role
|
||||||
def web_sysadmin_external():
|
def web_sysadmin_external() -> str:
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/sysadmin/external.html", title="External", nav="External"
|
"pages/sysadmin/external.html", title="External", nav="External"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sockettest")
|
@app.route("/sockettest")
|
||||||
def web_sockettest():
|
def web_sockettest() -> str:
|
||||||
return render_template(
|
return render_template(
|
||||||
"pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
|
"pages/sockettest.html", title="Sockettest Users", nav="SysAdminUsers"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -20,6 +21,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging as log
|
import logging as log
|
||||||
|
from operator import itemgetter
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
@ -28,113 +30,117 @@ import traceback
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from admin import app
|
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from admin.flaskapp import AdminFlaskApp
|
||||||
|
|
||||||
from .decorators import is_internal
|
from admin.views.decorators import OptionalJsonResponse, is_internal
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/internal/users", methods=["GET"])
|
|
||||||
@is_internal
|
|
||||||
def internal_users():
|
|
||||||
log.error(socket.gethostbyname("dd-apps-wordpress"))
|
|
||||||
if request.method == "GET":
|
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
|
||||||
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
|
||||||
users = []
|
|
||||||
for user in sorted_users:
|
|
||||||
if not user.get("enabled"):
|
|
||||||
continue
|
|
||||||
users.append(user_parser(user))
|
|
||||||
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
def setup_wp_views(app : "AdminFlaskApp") -> None:
|
||||||
|
@app.json_route("/api/internal/users", methods=["GET"])
|
||||||
|
@is_internal
|
||||||
|
def internal_users() -> OptionalJsonResponse:
|
||||||
|
log.error(socket.gethostbyname("dd-apps-wordpress"))
|
||||||
|
if request.method == "GET":
|
||||||
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
|
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
||||||
|
users = []
|
||||||
|
for user in sorted_users:
|
||||||
|
if not user.get("enabled"):
|
||||||
|
continue
|
||||||
|
users.append(user_parser(user))
|
||||||
|
return json.dumps(users), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route("/api/internal/users/filter", methods=["POST"])
|
@app.json_route("/api/internal/users/filter", methods=["POST"])
|
||||||
@is_internal
|
@is_internal
|
||||||
def internal_users_search():
|
def internal_users_search() -> OptionalJsonResponse:
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
users = app.admin.get_mix_users()
|
users = app.admin.get_mix_users()
|
||||||
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
|
||||||
sorted_result = sorted(result, key=lambda k: k["id"])
|
|
||||||
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/internal/groups", methods=["GET"])
|
|
||||||
@is_internal
|
|
||||||
def internal_groups():
|
|
||||||
if request.method == "GET":
|
|
||||||
sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"])
|
|
||||||
groups = []
|
|
||||||
for group in sorted_groups:
|
|
||||||
if not group["path"].startswith("/"):
|
|
||||||
continue
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"id": group["path"],
|
|
||||||
"name": group["name"],
|
|
||||||
"description": group.get("description", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return json.dumps(groups), 200, {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/internal/group/users", methods=["POST"])
|
|
||||||
@is_internal
|
|
||||||
def internal_group_users():
|
|
||||||
if request.method == "POST":
|
|
||||||
data = request.get_json(force=True)
|
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
|
||||||
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
|
||||||
users = []
|
|
||||||
for user in sorted_users:
|
|
||||||
if data["path"] not in user["keycloak_groups"] or not user.get("enabled"):
|
|
||||||
continue
|
|
||||||
users.append(user)
|
|
||||||
if data.get("text", False) and data["text"] != "":
|
|
||||||
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
||||||
else:
|
sorted_result = sorted(result, key=itemgetter("id"))
|
||||||
result = [user_parser(user) for user in users]
|
return json.dumps(sorted_result), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps(result), 200, {"Content-Type": "application/json"}
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/api/internal/groups", methods=["GET"])
|
||||||
|
@is_internal
|
||||||
|
def internal_groups() -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
sorted_groups = sorted(app.admin.get_mix_groups(), key=itemgetter("name"))
|
||||||
|
groups = []
|
||||||
|
for group in sorted_groups:
|
||||||
|
if not group["path"].startswith("/"):
|
||||||
|
continue
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"id": group["path"],
|
||||||
|
"name": group["name"],
|
||||||
|
"description": group.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json.dumps(groups), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route("/api/internal/roles", methods=["GET"])
|
@app.json_route("/api/internal/group/users", methods=["POST"])
|
||||||
@is_internal
|
@is_internal
|
||||||
def internal_roles():
|
def internal_group_users() -> OptionalJsonResponse:
|
||||||
if request.method == "GET":
|
if request.method == "POST":
|
||||||
roles = []
|
data = request.get_json(force=True)
|
||||||
for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]):
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
if role["name"] == "admin":
|
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
||||||
continue
|
users = []
|
||||||
roles.append(
|
for user in sorted_users:
|
||||||
{
|
if data["path"] not in user["keycloak_groups"] or not user.get("enabled"):
|
||||||
"id": role["id"],
|
continue
|
||||||
"name": role["name"],
|
users.append(user)
|
||||||
"description": role.get("description", ""),
|
if data.get("text", False) and data["text"] != "":
|
||||||
}
|
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
||||||
)
|
else:
|
||||||
return json.dumps(roles), 200, {"Content-Type": "application/json"}
|
result = [user_parser(user) for user in users]
|
||||||
|
return json.dumps(result), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.json_route("/api/internal/roles", methods=["GET"])
|
||||||
|
@is_internal
|
||||||
|
def internal_roles() -> OptionalJsonResponse:
|
||||||
|
if request.method == "GET":
|
||||||
|
roles = []
|
||||||
|
for role in sorted(app.admin.get_roles(), key=itemgetter("name")):
|
||||||
|
if role["name"] == "admin":
|
||||||
|
continue
|
||||||
|
roles.append(
|
||||||
|
{
|
||||||
|
"id": role["id"],
|
||||||
|
"name": role["name"],
|
||||||
|
"description": role.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json.dumps(roles), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route("/api/internal/role/users", methods=["POST"])
|
@app.json_route("/api/internal/role/users", methods=["POST"])
|
||||||
@is_internal
|
@is_internal
|
||||||
def internal_role_users():
|
def internal_role_users() -> OptionalJsonResponse:
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
data = request.get_json(force=True)
|
data = request.get_json(force=True)
|
||||||
sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"])
|
sorted_users = sorted(app.admin.get_mix_users(), key=itemgetter("username"))
|
||||||
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
# group_users = [user for user in sorted_users if data['path'] in user['keycloak_groups']]
|
||||||
users = []
|
users = []
|
||||||
for user in sorted_users:
|
for user in sorted_users:
|
||||||
if data["role"] not in user["roles"] or not user.get("enabled"):
|
if data["role"] not in user["roles"] or not user.get("enabled"):
|
||||||
continue
|
continue
|
||||||
users.append(user)
|
users.append(user)
|
||||||
if data.get("text", False) and data["text"] != "":
|
if data.get("text", False) and data["text"] != "":
|
||||||
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
result = [user_parser(user) for user in filter_users(users, data["text"])]
|
||||||
else:
|
else:
|
||||||
result = [user_parser(user) for user in users]
|
result = [user_parser(user) for user in users]
|
||||||
return json.dumps(result), 200, {"Content-Type": "application/json"}
|
return json.dumps(result), 200, {"Content-Type": "application/json"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def user_parser(user : Dict[str, Any]) -> Dict[str, Any]:
|
||||||
def user_parser(user):
|
|
||||||
return {
|
return {
|
||||||
"id": user["username"],
|
"id": user["username"],
|
||||||
"first": user["first"],
|
"first": user["first"],
|
||||||
|
@ -145,7 +151,7 @@ def user_parser(user):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def filter_users(users, text):
|
def filter_users(users : Iterable[Dict[str, Any]], text : str) -> List[Dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
user
|
user
|
||||||
for user in users
|
for user in users
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -25,25 +26,28 @@ import socket
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import redirect, request, url_for
|
from flask import redirect, request, url_for
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
from flask_login import current_user, logout_user
|
from flask_login import current_user, logout_user
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
from ..auth.tokens import get_header_jwt_payload
|
from ..auth.tokens import get_header_jwt_payload
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
JsonResponse = Tuple[str, int, Dict[str, str]]
|
||||||
|
OptionalJsonResponse = Optional[JsonResponse]
|
||||||
|
|
||||||
def is_admin(fn):
|
def is_admin(fn : Callable[..., Response]) -> Callable[..., Response]:
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorated_view(*args, **kwargs):
|
def decorated_view(*args : Any, **kwargs : Any) -> Response:
|
||||||
if current_user.role == "admin":
|
if current_user.role == "admin":
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
return decorated_view
|
return decorated_view
|
||||||
|
|
||||||
|
def is_internal(fn : Callable[..., OptionalJsonResponse]) -> Callable[..., OptionalJsonResponse]:
|
||||||
def is_internal(fn):
|
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorated_view(*args, **kwargs):
|
def decorated_view(*args : Any, **kwargs : Any) -> OptionalJsonResponse:
|
||||||
remote_addr = (
|
remote_addr = (
|
||||||
request.headers["X-Forwarded-For"].split(",")[0]
|
request.headers["X-Forwarded-For"].split(",")[0]
|
||||||
if "X-Forwarded-For" in request.headers
|
if "X-Forwarded-For" in request.headers
|
||||||
|
@ -67,18 +71,18 @@ def is_internal(fn):
|
||||||
return decorated_view
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
def has_token(fn):
|
def has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args : Any, **kwargs : Any) -> Any:
|
||||||
payload = get_header_jwt_payload()
|
payload = get_header_jwt_payload()
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
def is_internal_or_has_token(fn):
|
def is_internal_or_has_token(fn : Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorated_view(*args, **kwargs):
|
def decorated_view(*args : Any, **kwargs : Any) -> Any:
|
||||||
remote_addr = (
|
remote_addr = (
|
||||||
request.headers["X-Forwarded-For"].split(",")[0]
|
request.headers["X-Forwarded-For"].split(",")[0]
|
||||||
if "X-Forwarded-For" in request.headers
|
if "X-Forwarded-For" in request.headers
|
||||||
|
@ -94,9 +98,9 @@ def is_internal_or_has_token(fn):
|
||||||
return decorated_view
|
return decorated_view
|
||||||
|
|
||||||
|
|
||||||
def login_or_token(fn):
|
def login_or_token(fn : Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorated_view(*args, **kwargs):
|
def decorated_view(*args : Any, **kwargs : Any) -> Any:
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
payload = get_header_jwt_payload()
|
payload = get_header_jwt_payload()
|
||||||
|
|
|
@ -88,11 +88,6 @@ engine.io@~6.1.0:
|
||||||
engine.io-parser "~5.0.0"
|
engine.io-parser "~5.0.0"
|
||||||
ws "~8.2.3"
|
ws "~8.2.3"
|
||||||
|
|
||||||
font-linux@^0.6.1:
|
|
||||||
version "0.6.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/font-linux/-/font-linux-0.6.1.tgz#d586f46336b7da06ea3b7f10f7aee2b6346eed4f"
|
|
||||||
integrity sha1-1Yb0Yza32gbqO38Q967itjRu7U8=
|
|
||||||
|
|
||||||
gentelella@^1.4.0:
|
gentelella@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5"
|
resolved "https://registry.yarnpkg.com/gentelella/-/gentelella-1.4.0.tgz#b3d15fd9c40c6ea47dc7f36290c8f89aee95efc5"
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"web": {
|
|
||||||
"auth_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/auth",
|
|
||||||
"client_id": "adminapp",
|
|
||||||
"client_secret": "8a9e5a2e-3be9-43e3-9c47-1796f0d5ab72",
|
|
||||||
"redirect_uris": [
|
|
||||||
"https://sso.[[DOMAIN]]/oidc_callback"
|
|
||||||
],
|
|
||||||
"userinfo_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/userinfo",
|
|
||||||
"token_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token",
|
|
||||||
"token_introspection_uri": "https://sso.[[DOMAIN]]/auth/realms/master/protocol/openid-connect/token/introspect"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,187 @@
|
||||||
|
#
|
||||||
|
# Copyright © 2022 Evilham
|
||||||
|
#
|
||||||
|
# This file is part of DD
|
||||||
|
#
|
||||||
|
# DD is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
# option) any later version.
|
||||||
|
#
|
||||||
|
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with DD. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import logging as log
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from lib.keycloak_client import KeycloakClient
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python 3.9+
|
||||||
|
from functools import cache # type: ignore # Currently targetting 3.8
|
||||||
|
except ImportError:
|
||||||
|
# Up to python 3.8
|
||||||
|
from functools import cached_property as cache
|
||||||
|
|
||||||
|
|
||||||
|
app: Dict[str, Dict[str, str]] = {}
|
||||||
|
app["config"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SamlService(object):
|
||||||
|
"""
|
||||||
|
Generic class to manage a SAML service on keycloak
|
||||||
|
"""
|
||||||
|
|
||||||
|
keycloak: KeycloakClient
|
||||||
|
domain: str = os.environ["DOMAIN"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.keycloak = KeycloakClient()
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def public_cert(self) -> str:
|
||||||
|
"""
|
||||||
|
Read the public SAML certificate as used by keycloak
|
||||||
|
"""
|
||||||
|
ready = False
|
||||||
|
basepath = os.path.dirname(__file__)
|
||||||
|
while not ready:
|
||||||
|
# TODO: Check why they were using a loop
|
||||||
|
try:
|
||||||
|
with open(
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(basepath, "../saml_certs/public.cert")
|
||||||
|
),
|
||||||
|
"r",
|
||||||
|
) as crt:
|
||||||
|
app["config"]["PUBLIC_CERT"] = crt.read()
|
||||||
|
ready = True
|
||||||
|
except IOError:
|
||||||
|
log.warning("Could not get public certificate for SAML. Retrying...")
|
||||||
|
log.warning(
|
||||||
|
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
except:
|
||||||
|
log.error(traceback.format_exc())
|
||||||
|
log.info("Got public SAML certificate")
|
||||||
|
return app["config"]["PUBLIC_CERT"]
|
||||||
|
|
||||||
|
def get_client(self, client_id: str) -> Any:
|
||||||
|
# TODO: merge with keycloak_config.py
|
||||||
|
self.keycloak.connect()
|
||||||
|
k = self.keycloak.keycloak_admin
|
||||||
|
|
||||||
|
clients = k.get_clients()
|
||||||
|
client = next(filter(lambda c: c["clientId"] == client_id, clients), None)
|
||||||
|
return (k, client)
|
||||||
|
|
||||||
|
def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str:
|
||||||
|
(k, client) = self.get_client(client_id)
|
||||||
|
if client is None:
|
||||||
|
client_uid = k.create_client(client_overrides)
|
||||||
|
else:
|
||||||
|
client_uid = client["id"]
|
||||||
|
k.update_client(client_uid, client_overrides)
|
||||||
|
return client_id
|
||||||
|
|
||||||
|
def configure(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSaml(SamlService):
|
||||||
|
client_name: str = "correu"
|
||||||
|
client_description: str = "Client for the DD-managed email service"
|
||||||
|
email_domain: str
|
||||||
|
|
||||||
|
def __init__(self, email_domain: str, enabled: bool = False):
|
||||||
|
super(EmailSaml, self).__init__()
|
||||||
|
self.email_domain = email_domain
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self.email_domain)
|
||||||
|
|
||||||
|
def configure(self) -> None:
|
||||||
|
srv_base = f"https://correu.{self.domain}"
|
||||||
|
client_id = f"{srv_base}/metadata/"
|
||||||
|
client_overrides: Dict[str, Any] = {
|
||||||
|
"name": self.client_name,
|
||||||
|
"description": self.client_description,
|
||||||
|
"clientId": client_id,
|
||||||
|
"baseUrl": f"{srv_base}/login",
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"redirectUris": [
|
||||||
|
f"{srv_base}/*",
|
||||||
|
],
|
||||||
|
"webOrigins": [srv_base],
|
||||||
|
"consentRequired": False,
|
||||||
|
"protocol": "saml",
|
||||||
|
"attributes": {
|
||||||
|
"saml.assertion.signature": True,
|
||||||
|
"saml_idp_initiated_sso_relay_state": f"{srv_base}/login",
|
||||||
|
"saml_assertion_consumer_url_redirect": f"{srv_base}/acs",
|
||||||
|
"saml.force.post.binding": True,
|
||||||
|
"saml.multivalued.roles": False,
|
||||||
|
"saml.encrypt": False,
|
||||||
|
"saml_assertion_consumer_url_post": f"{srv_base}/acs",
|
||||||
|
"saml.server.signature": True,
|
||||||
|
"saml_idp_initiated_sso_url_name": f"{srv_base}/acs",
|
||||||
|
"saml.server.signature.keyinfo.ext": False,
|
||||||
|
"exclude.session.state.from.auth.response": False,
|
||||||
|
"saml_single_logout_service_url_redirect": f"{srv_base}/ls",
|
||||||
|
"saml.signature.algorithm": "RSA_SHA256",
|
||||||
|
"saml_force_name_id_format": False,
|
||||||
|
"saml.client.signature": False,
|
||||||
|
"tls.client.certificate.bound.access.tokens": False,
|
||||||
|
"saml.authnstatement": True,
|
||||||
|
"display.on.consent.screen": False,
|
||||||
|
"saml_name_id_format": "username",
|
||||||
|
"saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||||
|
"saml.onetimeuse.condition": False,
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"protocol": "saml",
|
||||||
|
"protocolMapper": "saml-user-property-mapper",
|
||||||
|
"consentRequired": False,
|
||||||
|
"config": {
|
||||||
|
"attribute.nameformat": "Basic",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"friendly.name": "username",
|
||||||
|
"attribute.name": "username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "saml",
|
||||||
|
"protocolMapper": "saml-user-property-mapper",
|
||||||
|
"consentRequired": False,
|
||||||
|
"config": {
|
||||||
|
"attribute.nameformat": "Basic",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"friendly.name": "email",
|
||||||
|
"attribute.name": "email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.set_client(client_id, client_overrides)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "")
|
||||||
|
log.info("Configuring SAML client for Email")
|
||||||
|
EmailSaml(email_domain).configure()
|
|
@ -1,5 +1,6 @@
|
||||||
#
|
#
|
||||||
# Copyright © 2021,2022 IsardVDI S.L.
|
# Copyright © 2021,2022 IsardVDI S.L.
|
||||||
|
# Copyright © 2022 Evilham <contact@evilham.com>
|
||||||
#
|
#
|
||||||
# This file is part of DD
|
# This file is part of DD
|
||||||
#
|
#
|
||||||
|
@ -39,14 +40,17 @@ from flask_socketio import (
|
||||||
send,
|
send,
|
||||||
)
|
)
|
||||||
|
|
||||||
from admin import app
|
from admin import get_app
|
||||||
|
# Set up the app
|
||||||
|
app = get_app()
|
||||||
|
app.setup()
|
||||||
|
|
||||||
app.socketio = SocketIO(app)
|
app.socketio = SocketIO(app)
|
||||||
|
|
||||||
|
|
||||||
@app.socketio.on("connect", namespace="/sio")
|
@app.socketio.on("connect", namespace="/sio")
|
||||||
@login_required
|
@login_required
|
||||||
def socketio_connect():
|
def socketio_connect() -> None:
|
||||||
if current_user.id:
|
if current_user.id:
|
||||||
join_room("admin")
|
join_room("admin")
|
||||||
app.socketio.emit(
|
app.socketio.emit(
|
||||||
|
@ -57,12 +61,12 @@ def socketio_connect():
|
||||||
|
|
||||||
|
|
||||||
@app.socketio.on("disconnect", namespace="/sio")
|
@app.socketio.on("disconnect", namespace="/sio")
|
||||||
def socketio_disconnect():
|
def socketio_disconnect() -> None:
|
||||||
leave_room("admin")
|
leave_room("admin")
|
||||||
|
|
||||||
|
|
||||||
@app.socketio.on("connect", namespace="/sio/events")
|
@app.socketio.on("connect", namespace="/sio/events")
|
||||||
def socketio_connect():
|
def socketio_connect() -> None:
|
||||||
jwt = get_token_payload(request.args.get("jwt"))
|
jwt = get_token_payload(request.args.get("jwt"))
|
||||||
|
|
||||||
join_room("events")
|
join_room("events")
|
||||||
|
@ -75,7 +79,7 @@ def socketio_connect():
|
||||||
|
|
||||||
|
|
||||||
@app.socketio.on("disconnect", namespace="/sio/events")
|
@app.socketio.on("disconnect", namespace="/sio/events")
|
||||||
def socketio_events_disconnect():
|
def socketio_events_disconnect() -> None:
|
||||||
leave_room("events")
|
leave_room("events")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -29,8 +29,8 @@ import requests
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
## SETUP
|
## SETUP
|
||||||
domain = "admin.[YOURDOMAIN]"
|
domain = f"admin.{ os.environ['DOMAIN'] }"
|
||||||
secret = "[your API_SECRET]"
|
secret = os.environ['API_SECRET']
|
||||||
## END SETUP
|
## END SETUP
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -43,8 +43,12 @@ services:
|
||||||
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
|
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
|
||||||
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw
|
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw
|
||||||
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
|
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
|
||||||
|
- ${DATA_FOLDER}/dd-admin:/data:rw
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- VERIFY="false" # In development do not verify certificates
|
- VERIFY="false" # In development do not verify certificates
|
||||||
- DOMAIN=${DOMAIN}
|
- DOMAIN=${DOMAIN}
|
||||||
|
- MANAGED_EMAIL_DOMAIN=${MANAGED_EMAIL_DOMAIN}
|
||||||
|
- DATA_FOLDER=/data
|
||||||
|
- CUSTOM_FOLDER=/admin/custom
|
||||||
|
|
|
@ -21,3 +21,6 @@ version: '3.7'
|
||||||
networks:
|
networks:
|
||||||
dd_net:
|
dd_net:
|
||||||
name: dd_net
|
name: dd_net
|
||||||
|
driver: bridge
|
||||||
|
driver_opts:
|
||||||
|
com.docker.network.driver.mtu: ${NETWORK_MTU:-1500}
|
||||||
|
|
|
@ -22,6 +22,8 @@
|
||||||
TITLE="DD"
|
TITLE="DD"
|
||||||
TITLE_SHORT="DD"
|
TITLE_SHORT="DD"
|
||||||
DOMAIN=mydomain.com
|
DOMAIN=mydomain.com
|
||||||
|
# If defined, DD will be managing email for this domain
|
||||||
|
#MANAGED_EMAIL_DOMAIN=${DOMAIN}
|
||||||
LETSENCRYPT_DNS=
|
LETSENCRYPT_DNS=
|
||||||
LETSENCRYPT_EMAIL=
|
LETSENCRYPT_EMAIL=
|
||||||
# Generate letsencrypt certificate for root domain
|
# Generate letsencrypt certificate for root domain
|
||||||
|
@ -195,3 +197,6 @@ POSTGRESQL_IMG=postgres:14.1-alpine3.15
|
||||||
|
|
||||||
## MINIO
|
## MINIO
|
||||||
#MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z
|
#MINIO_IMG=mino/minio:RELEASE.2022-01-25T19-56-04Z
|
||||||
|
|
||||||
|
## Network settings
|
||||||
|
#NETWORK_MTU=1500
|
||||||
|
|
|
@ -55,6 +55,39 @@ Aquí tens alguns recursos per guiar-te més:
|
||||||
- [Post-instal·lació](post-install.ca.md)
|
- [Post-instal·lació](post-install.ca.md)
|
||||||
- [Codi font](https://gitlab.com/DD-workspace/DD)
|
- [Codi font](https://gitlab.com/DD-workspace/DD)
|
||||||
|
|
||||||
|
# Per què comença la història de git aquí?
|
||||||
|
|
||||||
|
<details><summary>Per què comença la història de git aquí</summary>
|
||||||
|
|
||||||
|
Hi vam fer molta feina per estabilitzar el codi i netejar el repositori abans
|
||||||
|
de l'anunci públic al <a href='https://curs.digitalitzacio-democratica.xnet-x.net/'>I Curs Internacional d'Educació Digital Democràtica i Open Edtech</a>.
|
||||||
|
|
||||||
|
Fent servir aquella versió com a punt de partida ens ha deixat amb el repo que
|
||||||
|
trobeu aquí, on els canvis seran revisats abans d'acceptar-los i qualsevol
|
||||||
|
persona és benvinguda.
|
||||||
|
|
||||||
|
Si mai hi ha dubtes respecte l'autoria, si us plau comproveu la capcelera de llicència de cada fitxer.
|
||||||
|
|
||||||
|
L'autoria dels <i>commits</i> previs és de:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Josep Maria Viñolas Auquer</li>
|
||||||
|
<li>Simó Albert i Beltran</li>
|
||||||
|
<li>Alberto Larraz Dalmases</li>
|
||||||
|
<li>Yoselin Ribero</li>
|
||||||
|
<li>Elena Barrios Galán</li>
|
||||||
|
<li>Melina Gamboa</li>
|
||||||
|
<li>Antonio Manzano</li>
|
||||||
|
<li>Cecilia Bayo</li>
|
||||||
|
<li>Naomi Hidalgo</li>
|
||||||
|
<li>Joan Cervan Andreu</li>
|
||||||
|
<li>Jose Antonio Exposito Garcia</li>
|
||||||
|
<li>Raúl FS</li>
|
||||||
|
<li>Unai Tolosa Pontesta</li>
|
||||||
|
<li>Evilham</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs).
|
Aquest web està fet amb [MkDocs](https://gitlab.com/pages/mkdocs).
|
||||||
Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD).
|
Podeu [veure i modificar el codi font](https://gitlab.com/DD-workspace/DD).
|
||||||
|
|
|
@ -54,5 +54,43 @@ Aquí tienes algunos recursos para guiarte más:
|
||||||
- [Post-instalación](post-install.ca.md)
|
- [Post-instalación](post-install.ca.md)
|
||||||
- [Código fuente](https://gitlab.com/DD-workspace/DD)
|
- [Código fuente](https://gitlab.com/DD-workspace/DD)
|
||||||
|
|
||||||
|
|
||||||
|
# ¿Por qué comienza la historia de git aquí?
|
||||||
|
|
||||||
|
<details><summary>¿Por qué comienza la historia de git aquí?</summary>
|
||||||
|
|
||||||
|
Se hizo mucho trabajo para estabilizar el código y limpiar el repositorio antes
|
||||||
|
del anuncio público en el
|
||||||
|
<a href='https://curso.digitalizacion-democratica.xnet-x.net/'>I Curso Internacional de Educación Digital Democrática y Open Edtech</a>.
|
||||||
|
|
||||||
|
Usar esa versión como punto de partida nos dejó con el repositorio que tenemos
|
||||||
|
aquí, donde los cambios serán revisados antes de aceptarlos y cualquier persona
|
||||||
|
es bienvenida.
|
||||||
|
|
||||||
|
Si en algún momento hay dudas respecto a la autoría, por favor comprovad las
|
||||||
|
cabeceras de licencia en cada fichero.
|
||||||
|
|
||||||
|
La autoría de los <i>commits</i> previos es de:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Josep Maria Viñolas Auquer</li>
|
||||||
|
<li>Simó Albert i Beltran</li>
|
||||||
|
<li>Alberto Larraz Dalmases</li>
|
||||||
|
<li>Yoselin Ribero</li>
|
||||||
|
<li>Elena Barrios Galán</li>
|
||||||
|
<li>Melina Gamboa</li>
|
||||||
|
<li>Antonio Manzano</li>
|
||||||
|
<li>Cecilia Bayo</li>
|
||||||
|
<li>Naomi Hidalgo</li>
|
||||||
|
<li>Joan Cervan Andreu</li>
|
||||||
|
<li>Jose Antonio Exposito Garcia</li>
|
||||||
|
<li>Raúl FS</li>
|
||||||
|
<li>Unai Tolosa Pontesta</li>
|
||||||
|
<li>Evilham</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Página creada con [MkDocs](https://gitlab.com/pages/mkdocs).
|
Página creada con [MkDocs](https://gitlab.com/pages/mkdocs).
|
||||||
Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD).
|
Puedes [ver y modificar su código](https://gitlab.com/DD-workspace/DD).
|
||||||
|
|
|
@ -52,5 +52,41 @@ resources to aid you further:
|
||||||
- [Post-install](post-install.ca.md)
|
- [Post-install](post-install.ca.md)
|
||||||
- [Source code](https://gitlab.com/DD-workspace/DD)
|
- [Source code](https://gitlab.com/DD-workspace/DD)
|
||||||
|
|
||||||
|
# Why does git history start here?
|
||||||
|
|
||||||
|
<details><summary>Why does git history start here?</summary>
|
||||||
|
|
||||||
|
A lot of work went into stabilising the code and cleaning the repo before the
|
||||||
|
public announcement on the
|
||||||
|
<a href='https://congress.democratic-digitalisation.xnet-x.net/'>1st International Congress on Democratic Digital Education and Open Edtech</a>.
|
||||||
|
|
||||||
|
Using that version as a clean slate got us to the repo you see here, where
|
||||||
|
changes will be reviewed before going in and anyone is welcome.
|
||||||
|
|
||||||
|
When in doubt about authorship, please check each file's license headers.
|
||||||
|
|
||||||
|
The authorship of the previous commits is from:
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Josep Maria Viñolas Auquer</li>
|
||||||
|
<li>Simó Albert i Beltran</li>
|
||||||
|
<li>Alberto Larraz Dalmases</li>
|
||||||
|
<li>Yoselin Ribero</li>
|
||||||
|
<li>Elena Barrios Galán</li>
|
||||||
|
<li>Melina Gamboa</li>
|
||||||
|
<li>Antonio Manzano</li>
|
||||||
|
<li>Cecilia Bayo</li>
|
||||||
|
<li>Naomi Hidalgo</li>
|
||||||
|
<li>Joan Cervan Andreu</li>
|
||||||
|
<li>Jose Antonio Exposito Garcia</li>
|
||||||
|
<li>Raúl FS</li>
|
||||||
|
<li>Unai Tolosa Pontesta</li>
|
||||||
|
<li>Evilham</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
This site is built with [MkDocs](https://gitlab.com/pages/mkdocs).
|
This site is built with [MkDocs](https://gitlab.com/pages/mkdocs).
|
||||||
You can [browse and modify its source code](https://gitlab.com/DD-workspace/DD).
|
You can [browse and modify its source code](https://gitlab.com/DD-workspace/DD).
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,11 @@ capaç d'executar docker-compose v1.28 o més nova.
|
||||||
|
|
||||||
[dd.conf.sample]: https://gitlab.com/DD-workspace/DD/-/blob/main/dd.conf.sample
|
[dd.conf.sample]: https://gitlab.com/DD-workspace/DD/-/blob/main/dd.conf.sample
|
||||||
|
|
||||||
|
La instal·lació modifica el fitxer dd.conf per dd.conf.sample (el substitueix). Idealment inicialitza les variables des de la comanda dd-install.sh:
|
||||||
|
|
||||||
|
```
|
||||||
|
> DD_NETWORK_MTU=1450 ./dd-install.sh
|
||||||
|
```
|
||||||
## Interactivament
|
## Interactivament
|
||||||
|
|
||||||
Podem fer servir l'script [`dd-install.sh`][dd-install.sh] sense arguments per
|
Podem fer servir l'script [`dd-install.sh`][dd-install.sh] sense arguments per
|
||||||
|
@ -42,14 +46,15 @@ Under which DOMAIN will you install DD? example.org
|
||||||
|
|
||||||
|
|
||||||
You will need to setup DNS entries for:
|
You will need to setup DNS entries for:
|
||||||
- [ ] moodle.example.org
|
- [ ] moodle.dd.004.es
|
||||||
- [ ] nextcloud.example.org
|
- [ ] nextcloud.dd.004.es
|
||||||
- [ ] wp.example.org
|
- [ ] wp.dd.004.es
|
||||||
- [ ] oof.example.org
|
- [ ] oof.dd.004.es
|
||||||
- [ ] sso.example.org
|
- [ ] sso.dd.004.es
|
||||||
- [ ] pad.example.org
|
- [ ] pad.dd.004.es
|
||||||
- [ ] admin.example.org
|
- [ ] admin.dd.004.es
|
||||||
- [ ] api.example.org
|
- [ ] api.dd.004.es
|
||||||
|
- [ ] correu.dd.004.es
|
||||||
|
|
||||||
|
|
||||||
What is the short title of the DD instance? [DD]
|
What is the short title of the DD instance? [DD]
|
||||||
|
@ -77,6 +82,9 @@ Opcionalment es poden indicar els logos ubicant els fitxers <code>.png</code>
|
||||||
al servidor i indicant la seva ruta quan l'instal·lador les demana.
|
al servidor i indicant la seva ruta quan l'instal·lador les demana.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details><summary>Certificat preexistent</summary>
|
||||||
|
Tens la posibilitat d'utilitzar el teu propi certificat, ja sigui wildcard o bé SAN. Pots llegir-ne mes a l'apartat [wildcard](wildcard.ca.md).
|
||||||
|
</details>
|
||||||
|
|
||||||
## Automatitzat
|
## Automatitzat
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Instal·lació d'un certificat propi wildcard
|
||||||
|
|
||||||
|
Abans de res, atura la suite mitjançant la comanda del dd-ctl down:
|
||||||
|
|
||||||
|
`/opt/src/DD# ./dd-ctl down`
|
||||||
|
|
||||||
|
Per que el certificat sigui compatible amb DD, heu de fusionar el fullchain amb la key privada del certificat, la opció preferida es simplement imprimir els dos fitxers en un:
|
||||||
|
|
||||||
|
`/tmp/certificat# cat fullchain.pem cert.key > /opt/DD/src/haproxy/certs/chain.pem`
|
||||||
|
|
||||||
|
El fitxer fullchain.pem ha de contenir tota la cadena del certificat, la cert.key es la clau privada, ha de quedar mes o menys així:
|
||||||
|
|
||||||
|
```
|
||||||
|
> cat /opt/DD/src/haproxy/certs/chain.pem
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
YDC ...
|
||||||
|
...
|
||||||
|
... PnQP
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
5dSf ...
|
||||||
|
...
|
||||||
|
... Hwgs
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
sI3q ...
|
||||||
|
...
|
||||||
|
... vZas
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
vzKJ ...
|
||||||
|
...
|
||||||
|
... 2dLs
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
```
|
||||||
|
|
||||||
|
Reviseu bé la ruta on heu copiat el fitxer chain.pem, que ha de ser /opt/DD/src/haproxy/certs
|
||||||
|
|
||||||
|
Un cop fet això arrenquem de nou la suite amb la comanda dd-ctl up:
|
||||||
|
|
||||||
|
`/opt/src/DD# ./dd-ctl up`
|
||||||
|
|
||||||
|
I ja podem disfrutar del nostre DD correctamente instal·lat amb un certificat propi.
|
Loading…
Reference in New Issue