feat: add calendar support

master
c-cal 4 years ago
parent 0ce76dc645
commit 765153e925
Signed by: watcha
GPG Key ID: 87DD78E7F7A1581D
  1. 8
      Makefile
  2. 28
      appinfo/info.xml
  3. 73
      appinfo/routes.php
  4. 2
      composer.json
  5. 3
      css/style.css
  6. 56
      img/app.svg
  7. 3
      js/.prettierrc.yaml
  8. 64
      js/refine-iframe.js
  9. 0
      js/script.js
  10. 68
      lib/AppInfo/Application.php
  11. 679
      lib/Controller/CalendarController.php
  12. 31
      lib/Controller/PageController.php
  13. 137
      lib/Dav.php
  14. 45
      lib/Exception/GenericException.php
  15. 35
      lib/Exception/NotServiceAccountException.php
  16. 57
      lib/Middleware/SecurityMiddleware.php
  17. BIN
      screenshots/watcha1-thumbnail.png
  18. BIN
      screenshots/watcha1.png
  19. BIN
      screenshots/watcha2-thumbnail.png
  20. BIN
      screenshots/watcha2.png
  21. 1
      templates/content/index.php
  22. 18
      templates/index.php
  23. 10
      templates/navigation/index.php
  24. 10
      templates/settings/index.php
  25. 31
      tests/Unit/Controller/PageControllerTest.php

@ -113,20 +113,22 @@ dist:
source:
rm -rf $(source_build_directory)
mkdir -p $(source_build_directory)
tar cvzf $(source_package_name).tar.gz ../$(app_name) \
tar cvzf $(source_package_name).tar.gz \
--exclude-vcs \
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/js/node_modules" \
--exclude="../$(app_name)/node_modules" \
--exclude="../$(app_name)/*.log" \
--exclude="../$(app_name)/js/*.log" \
--exclude="../$(app_name)/screenshots" \
../$(app_name)
# Builds the source package for the app store, ignores php and js tests
.PHONY: appstore
appstore:
rm -rf $(appstore_build_directory)
mkdir -p $(appstore_build_directory)
tar cvzf $(appstore_package_name).tar.gz ../$(app_name) \
tar cvzf $(appstore_package_name).tar.gz \
--exclude-vcs \
--exclude="../$(app_name)/build" \
--exclude="../$(app_name)/tests" \
@ -148,6 +150,8 @@ appstore:
--exclude="../$(app_name)/protractor\.*" \
--exclude="../$(app_name)/.*" \
--exclude="../$(app_name)/js/.*" \
--exclude="../$(app_name)/screenshots" \
../$(app_name)
.PHONY: test
test: composer

@ -1,23 +1,25 @@
<?xml version="1.0"?>
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>watcha</id>
<name>Watcha</name>
<summary>Watcha connector</summary>
<description><![CDATA[This connector allows the integration of document, calendar, task and poll sharing features from Nextcloud into Watcha. It is thus possible to link these resources with Watcha rooms through Nextcloud groups.]]></description>
<version>0.0.1</version>
<summary lang="en">Watcha connector</summary>
<summary lang="fr">Connecteur Watcha</summary>
<description lang="en">This connector allows the integration of the document sharing, calendars and tasks features of Nextcloud in [Watcha](https://watcha.fr). It is thus possible to link the resources of these applications to Watcha rooms thanks to Nextcloud groups.</description>
<description lang="fr">Ce connecteur permet l'intégration des fonctionnalités de partage de documents, de calendriers et de tâches de Nextcloud dans [Watcha](https://watcha.fr). Il est ainsi possible de lier les ressources de ces application aux salons Watcha grâce aux groupes Nextcloud.</description>
<version>0.1.0</version>
<licence>agpl</licence>
<author mail="c-cal@watcha.fr" homepage="https://watcha.fr">Charlie Calendre</author>
<author homepage="https://github.com/watcha-fr">Charlie Calendre</author>
<namespace>Watcha</namespace>
<category>integration</category>
<bugs>https://github.com/watcha-fr/nextcloud-watcha-connector/issues</bugs>
<website>https://watcha.fr</website>
<discussion>https://watcha.fr/#Contactez-nous</discussion>
<bugs>https://github.com/watcha-fr/nextcloud-connector/issues</bugs>
<repository>https://github.com/watcha-fr/nextcloud-connector</repository>
<screenshot small-thumbnail="https://raw.githubusercontent.com/watcha.fr/nextcloud-connector/master/screenshots/watcha1-thumbnail.png">https://raw.githubusercontent.com/watcha.fr/nextcloud-connector/master/screenshots/watcha1.png</screenshot>
<screenshot small-thumbnail="https://raw.githubusercontent.com/watcha.fr/nextcloud-connector/master/screenshots/watcha2-thumbnail.png">https://raw.githubusercontent.com/watcha.fr/nextcloud-connector/master/screenshots/watcha2.png</screenshot>
<dependencies>
<nextcloud min-version="20" max-version="21"/>
</dependencies>
<navigations>
<navigation>
<name>Watcha</name>
<route>watcha.page.index</route>
</navigation>
</navigations>
</info>

@ -1,4 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* Create your routes in here. The name is the lowercase name of the controller
* without the controller part, the stuff after the hash is the method.
@ -7,9 +32,53 @@
* The controller class has to be registered in the application.php file since
* it's instantiated in there
*/
return [
'routes' => [
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
['name' => 'page#do_echo', 'url' => '/echo', 'verb' => 'POST'],
[
'name' => 'calendar#addUser',
'url' => '/users',
'verb' => 'POST',
],
[
'name' => 'calendar#removeUser',
'url' => '/users/{userId}',
'verb' => 'DELETE',
],
[
'name' => 'calendar#list',
'url' => '/users/{userId}/calendars',
'verb' => 'GET',
],
[
'name' => 'calendar#get',
'url' => '/users/{userId}/calendars/{calendarId}',
'verb' => 'GET',
],
[
'name' => 'calendar#reorder',
'url' => '/users/{userId}/calendars/{calendarId}/top',
'verb' => 'PUT',
],
[
'name' => 'calendar#share',
'url' => '/users/{userId}/calendars/{calendarId}',
'verb' => 'PUT',
],
[
'name' => 'calendar#createAndShare',
'url' => '/calendars',
'verb' => 'POST',
],
[
'name' => 'calendar#unShare',
'url' => '/calendars',
'verb' => 'DELETE'
],
[
'name' => 'calendar#rename',
'url' => '/calendars/displayname',
'verb' => 'PUT',
],
]
];

@ -1,5 +1,5 @@
{
"name": "Watcha",
"name": "c-cal/watcha",
"description": "Watcha connector",
"type": "project",
"license": "AGPL",

@ -1,3 +0,0 @@
#hello {
color: red;
}

@ -1,56 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
height="32"
width="32"
version="1"
viewBox="0 0 32 32"
id="svg4"
sodipodi:docname="app.svg"
inkscape:version="0.92.1 r">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="789"
inkscape:window-height="480"
id="namedview6"
showgrid="false"
inkscape:zoom="7.375"
inkscape:cx="-8.3389831"
inkscape:cy="16"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<path
d="M13.733 0a.915.915 0 0 0-.933.934V3.6c-1.182.304-2.243.794-3.267 1.4L7.6 3.068a.93.93 0 0 0-1.334 0l-3.2 3.2a.93.93 0 0 0 0 1.334L5 9.535c-.607 1.024-1.097 2.085-1.4 3.267H.933a.915.915 0 0 0-.933.934v4.533c0 .53.403.934.933.934H3.6c.303 1.182.793 2.243 1.4 3.267l-1.934 1.935a.93.93 0 0 0 0 1.333l3.2 3.2a.93.93 0 0 0 1.333 0L9.532 27c1.024.61 2.085 1.097 3.266 1.4v2.667c0 .53.402.933.932.933h4.534c.53 0 .933-.403.933-.935V28.4c1.18-.305 2.24-.795 3.265-1.4L24.4 28.93a.93.93 0 0 0 1.332 0l3.2-3.2a.93.93 0 0 0 0-1.333L27 22.465c.607-1.024 1.096-2.085 1.4-3.266h2.665a.915.915 0 0 0 .935-.933v-4.534a.915.915 0 0 0-.934-.933H28.4c-.304-1.182-.792-2.243-1.4-3.267L28.932 7.6a.93.93 0 0 0 0-1.334l-3.2-3.2a.93.93 0 0 0-1.333 0L22.465 5c-1.024-.607-2.084-1.097-3.266-1.4V.933A.915.915 0 0 0 18.267 0zM16 8.87A7.134 7.134 0 0 1 23.13 16 7.134 7.134 0 0 1 16 23.133c-3.936 0-7.13-3.196-7.13-7.132S12.063 8.87 16 8.87z"
display="block"
fill="#fff"
id="path2"
style="fill:#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

@ -0,0 +1,3 @@
arrowParens: avoid
printWidth: 120
tabWidth: 4

@ -0,0 +1,64 @@
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
"use strict";
window.addEventListener("DOMContentLoaded", () => {
if (isIframe()) {
loop();
}
});
function isIframe() {
return window.location !== window.parent.location;
}
// bypass third-party DOM changes on load
function loop(n = 10) {
refineIframe();
setTimeout(() => {
if (--n) {
loop(n);
}
}, 200);
}
function refineIframe() {
const items = [
{ selector: "#header", propName: "display", value: "none" },
{ selector: "#filelist-header", propName: "display", value: "none" },
{ selector: "#body-user", propName: "height", value: "100%" },
{ selector: "#content", propName: "padding-top", value: 0 },
{ selector: "#content-vue", propName: "padding-top", value: 0 },
{ selector: "#app-navigation", propName: "top", value: 0 },
{ selector: "#app-navigation-vue", propName: "top", value: 0 },
{ selector: "#controls", propName: "top", value: 0 },
{ selector: ".header", propName: "top", value: 0 },
{ selector: "#filestable thead", propName: "top", value: "44px" },
];
for (const { selector, propName, value } of items) {
const element = document.querySelector(selector);
if (element) {
element.style.setProperty(propName, value, "important");
}
}
}

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha\AppInfo;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Util;
use OCA\Watcha\Middleware\SecurityMiddleware;
/**
* Class Application
*
* @package OCA\Watcha\AppInfo
*/
class Application extends App implements IBootstrap {
/** @var string */
public const APP_ID = 'watcha';
/**
* @param array $params
*/
public function __construct(array $params = []) {
parent::__construct(self::APP_ID, $params);
Util::addScript(self::APP_ID, "refine-iframe");
}
/**
* @inheritDoc
*/
public function register(IRegistrationContext $context): void {
$context->registerMiddleware(SecurityMiddleware::class);
}
/**
* @inheritDoc
*/
public function boot(IBootContext $context): void {
}
}

@ -0,0 +1,679 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha\Controller;
use Psr\Log\LoggerInterface;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
use Sabre\DAV\Exception\NotFound;
use Sabre\Uri;
use OCA\Watcha\Dav;
use OCA\Watcha\Exception\GenericException;
const CALENDAR_ORDER_KEY = "{http://apple.com/ns/ical/}calendar-order";
const DISPLAYNAME_KEY = "{DAV:}displayname";
const OWNER_PRINCIPAL_KEY = "{" . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . "}owner-principal";
const SUPPORTED_CALENDAR_COMPONENT_SET_KEY = "{" . \OCA\DAV\CalDAV\Plugin::NS_CALDAV . "}supported-calendar-component-set";
class CalendarController extends Controller {
/** @var string */
private $userId;
/** @var LoggerInterface */
private $logger;
/** @var CalDavBackend */
private $caldav;
/** @var IDBConnection */
private $connection;
/** @var IUserManager */
private $userManager;
/** @var IGroupManager */
private $groupManager;
/** @var IConfig */
private $config;
public function __construct(
string $AppName,
string $UserId,
IRequest $request,
LoggerInterface $logger,
CalDavBackend $caldav,
IDBConnection $connection,
IUserManager $userManager,
IGroupManager $groupManager,
IConfig $config
) {
parent::__construct($AppName, $request);
$this->userId = $UserId;
$this->logger = $logger;
$this->caldav = $caldav;
$this->connection = $connection;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->config = $config;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @return JSONResponse
*/
public function list(string $userId) {
$principalUri = "principals/users/$userId";
$calendars = $this->caldav->getUsersOwnCalendars($principalUri);
$fmtCalendars = [];
foreach ($calendars as $calendar) {
$fmtCalendars[] = [
"id" => $calendar["id"],
"displayname" => $calendar[DISPLAYNAME_KEY],
"components" => $calendar[SUPPORTED_CALENDAR_COMPONENT_SET_KEY]->getValue()
];
}
usort($fmtCalendars, function (array $a, array $b) {
return strcmp(strtolower($a['displayname']), strtolower($b['displayname']));
});
return new JSONResponse($fmtCalendars);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @param int $calendarId
* @return JSONResponse
*/
public function get(string $userId, int $calendarId) {
$calendar = $this->getFormatedCalendar($calendarId);
try {
$displayName = $this->getDisplayName($userId, $calendarId);
} catch (GenericException $e) {
return new JSONResponse(["message" => $e->getMessage()], $e->getHTTPCode());
}
$calendar["displayname"] = $displayName;
return new JSONResponse($calendar);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @param int $calendarId
* @return JSONResponse
*/
public function reorder(string $userId, int $calendarId) {
$calendar = $this->caldav->getCalendarById($calendarId);
if (is_null($calendar)) {
$this->logger->warning("calendar $calendarId no found, can't reorder it for user $userId");
return;
}
$components = $calendar[SUPPORTED_CALENDAR_COMPONENT_SET_KEY]->getValue();
if (in_array("VTODO", $components)) {
list(, $ownerId) = Uri\split($calendar["principaluri"]);
$calendarUri = $ownerId === $userId ? $calendar["uri"] : $this->computeUriForSharedCalendar($calendar["uri"], $ownerId);
$value = "/calendars/$calendarUri";
$this->config->setUserValue($userId, "tasks", "various_initialRoute", $value);
$this->logger->info("todo list with URI $calendarUri defined as initial route for user $userId");
}
$query = $this->connection->getQueryBuilder();
$fields = ["propertypath", "propertyvalue"];
$query->select($fields)
->from("properties")
->where($query->expr()->eq("userid", $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq("propertyname", $query->createNamedParameter(CALENDAR_ORDER_KEY)))
->orderBy("propertyvalue", "ASC");
$cursor = $query->execute();
$ocProperties = array();
while ($row = $cursor->fetch()) {
$order = (int) $row["propertyvalue"];
$path = (string) $row["propertypath"];
$ocProperties[$path] = [
"order" => $order
];
}
$cursor->closeCursor();
$principalUri = "principals/users/$userId";
$calendars = $this->caldav->getCalendarsForUser($principalUri);
$orderedCalendars = array();
foreach ($calendars as $calendar) {
$owner = $calendar[OWNER_PRINCIPAL_KEY];
$uri = $calendar["uri"];
$path = "calendars/$userId/$uri";
if ($owner !== $principalUri and array_key_exists($path, $ocProperties)) {
$ocProperties[$path]["calendar"] = $calendar;
} else {
$orderedCalendars[] = $calendar;
}
}
foreach ($ocProperties as $propertie) {
$order = $propertie["order"];
$calendar = $propertie["calendar"];
array_splice($orderedCalendars, $order, 0, array($calendar));
}
$user = $this->userManager->get($userId);
$server = Dav::getServerInstance($this->connection, $user);
$i = 1;
foreach ($orderedCalendars as $calendar) {
$uri = $calendar["uri"];
$path = "calendars/$userId/$uri";
$order = $calendar["id"] === $calendarId ? 0 : $i++;
$properties = [CALENDAR_ORDER_KEY => $order];
$server->updateProperties($path, $properties);
}
$this->logger->info("calendar $calendarId moved to top of list for user $userId");
return new JSONResponse((object)[]);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $mxRoomId
* @param string $displayName
* @param string[] $userIds (optional)
* @return JSONResponse
*/
public function createAndShare(string $mxRoomId, string $displayName, array $userIds = []) {
$userId = $this->userId;
$calendarUri = $this->computeIdFromMxRoomId($mxRoomId);
$calendarId = $this->create($userId, $calendarUri, $displayName);
$userIds[] = $this->userId;
return $this->share($userId, $calendarId, $mxRoomId, $displayName, $userIds);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @param int $calendarId
* @param string $mxRoomId
* @param string $displayName
* @param string[] $userIds (optional)
* @return JSONResponse
*/
public function share(string $userId, int $calendarId, string $mxRoomId, string $displayName, array $userIds = []) {
$ownerId = $this->getUserIdFromCalendarId($calendarId);
if (is_null($ownerId)) {
$message = "calendar $calendarId not found, can't share it";
$this->logger->warning($message);
return new JSONResponse(["message" => $message], Http::STATUS_NOT_FOUND);
}
if ($ownerId !== $userId) {
$message = "calendar $calendarId is not owned by $userId, can't share it";
$this->logger->warning($message);
return new JSONResponse(["message" => $message], Http::STATUS_FORBIDDEN);
}
$groupId = $this->computeIdFromMxRoomId($mxRoomId);
try {
$this->createGroup($groupId, $displayName);
} catch (GenericException $e) {
return new JSONResponse(["message" => $e->getMessage()], $e->getHTTPCode());
}
# workaround for an upstream bug: users must be added to the group before
# sharing the calendar to avoid it appearing empty when its members are listed later
$this->addUsersToGroup($groupId, $userIds, $ownerId);
$add = [
array(
"href" => "principal:principals/groups/$groupId",
"commonName" => null,
"summary" => null,
"readOnly" => false,
)
];
try {
$this->updateShares($calendarId, $add);
} catch (GenericException | NotFound $e) {
return new JSONResponse(["message" => $e->getMessage()], $e->getHTTPCode());
}
$this->renameForGroupMembers($groupId, $calendarId, $displayName);
$calendar = $this->getFormatedCalendar($calendarId);
return new JSONResponse($calendar);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int[] $calendarIds
* @param string $mxRoomId
* @param bool $deleteGroup (optional)
* @return JSONResponse
*/
public function unShare(array $calendarIds, string $mxRoomId, bool $deleteGroup = False) {
$groupId = $this->computeIdFromMxRoomId($mxRoomId);
foreach ($calendarIds as $calendarId) {
if ($this->getUserIdFromCalendarId($calendarId) === $this->userId) {
$this->delete($calendarId);
} else {
$remove = ["principal:principals/groups/$groupId"];
$this->updateShares($calendarId, [], $remove);
}
}
if ($deleteGroup) {
try {
$this->deleteGroup($groupId);
} catch (GenericException $e) {
return new JSONResponse(["message" => $e->getMessage()], $e->getHTTPCode());
}
}
return new JSONResponse((object)[]);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @param string $mxRoomId
* @param int[] $calendarIds
* @param string $displayName
* @return JSONResponse
*/
public function addUser(string $userId, string $mxRoomId, array $calendarIds, string $displayName) {
$groupId = $this->computeIdFromMxRoomId($mxRoomId);
foreach ($calendarIds as $calendarId) {
$ownerId = $this->getUserIdFromCalendarId($calendarId);
$this->addUsersToGroup($groupId, [$userId], $ownerId);
try {
$this->renameForUser($userId, $calendarId, $displayName);
} catch (NotFound $e) {
$this->logger->warning($e->getMessage());
}
}
return new JSONResponse((object)[]);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param string $userId
* @param string $mxRoomId
* @return JSONResponse
*/
public function removeUser(string $userId, string $mxRoomId) {
$groupId = $this->computeIdFromMxRoomId($mxRoomId);
$this->removeUserFromGroup($groupId, $userId);
return new JSONResponse((object)[]);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* @param int[] $calendarIds
* @param string $mxRoomId
* @param string $displayName
* @return JSONResponse
*/
public function rename(array $calendarIds, string $mxRoomId, string $displayName) {
$groupId = $this->computeIdFromMxRoomId($mxRoomId);
$this->renameGroup($groupId, $displayName);
foreach ($calendarIds as $calendarId) {
$this->renameForGroupMembers($groupId, $calendarId, $displayName);
}
return new JSONResponse((object)[]);
}
/**
* @param int $calendarId
* @return array
*/
private function getFormatedCalendar(int $calendarId) {
$calendar = $this->caldav->getCalendarById($calendarId);
return [
"id" => $calendar["id"],
"components" => $calendar[SUPPORTED_CALENDAR_COMPONENT_SET_KEY]->getValue(),
"is_personal" => $this->getUserIdFromCalendarId($calendarId) !== $this->userId
];
}
/**
* @param string $userId
* @param int $calendarId
* @return JSONResponse
*/
private function getDisplayName(string $userId, int $calendarId) {
$user = $this->userManager->get($userId);
$server = Dav::getServerInstance($this->connection, $user);
$propertyNames = [DISPLAYNAME_KEY];
$principalUri = "principals/users/$userId";
$calendars = $this->caldav->getCalendarsForUser($principalUri);
foreach ($calendars as $calendar) {
if ($calendar["id"] !== $calendarId) {
continue;
}
$uri = $calendar["uri"];
$path = "calendars/$userId/$uri";
$properties = $server->getProperties($path, $propertyNames);
return $properties[DISPLAYNAME_KEY];
}
$message = "calendar $calendarId not available for user $userId, can't get displayname";
$this->logger->warning($message);
throw new GenericException($message, Http::STATUS_FORBIDDEN);
}
/**
* @param string $userId
* @param string $calendarUri
* @param string $displayName
* @return int
*/
private function create(string $userId, string $calendarUri, string $displayName) {
$server = Dav::getServerInstance();
$path = "calendars/$userId/$calendarUri";
if ($server->tree->nodeExists($path)) {
$this->logger->warning("calendar $path already exists");
return $this->getCalendarIdFromUri($userId, $calendarUri);
}
$principalUri = "principals/users/$userId";
$properties = [DISPLAYNAME_KEY => $displayName];
$calendarId = $this->caldav->createCalendar($principalUri, $calendarUri, $properties);
$this->logger->info("calendar $calendarId ($displayName) created");
return $calendarId;
}
/**
* @param int $calendarId
* @return void
*/
private function delete(int $calendarId) {
if (is_null($this->caldav->getCalendarById($calendarId))) {
$this->logger->warning("calendar $calendarId no found, can't delete it");
return;
}
$this->caldav->deleteCalendar($calendarId);
$this->logger->info("calendar $calendarId deleted");
}
/**
* @param int $calendarId
* @param array $add
* @param array $remove
* @return void
* @throws GenericException
* @throws Sabre\DAV\Exception\NotFound
*/
private function updateShares(int $calendarId, array $add = [], array $remove = []) {
$server = Dav::getServerInstance();
$userId = $this->getUserIdFromCalendarId($calendarId);
if (is_null($userId)) {
if ($add) {
$exception = new GenericException("no calendar $calendarId to add a share to");
$this->logger->error($exception->getMessage(), ["exception" => $exception]);
throw $exception;
}
$this->logger->warning("no calendar $calendarId to remove a share to");
return;
}
$calendar = $this->caldav->getCalendarById($calendarId);
$uri = $calendar["uri"];
$path = "calendars/$userId/$uri";
$node = $server->tree->getNodeForPath($path);
$node->updateShares($add, $remove);
$this->logger->info("share(s) for calendar $calendarId updated (add: {add} | remove: {remove})", [
"add" => json_encode($add, JSON_UNESCAPED_SLASHES), "remove" => json_encode($remove, JSON_UNESCAPED_SLASHES)
]);
}
/**
* @param string $groupId
* @param string $displayName
* @return void
* @throws GenericException
*/
private function createGroup(string $groupId, string $displayName) {
if ($this->groupManager->groupExists($groupId)) {
$this->logger->warning("group $groupId already exists");
return;
}
$group = $this->groupManager->createGroup($groupId);
if (!$group instanceof IGroup) {
$exception = new GenericException("can't create $groupId");
$this->logger->error($exception->getMessage(), ["exception" => $exception]);
throw $exception;
}
$group->setDisplayName($displayName);
$this->logger->info("group $groupId ($displayName) created");
}
/**
* @param string $groupId
* @return void
*/
private function deleteGroup(string $groupId) {
$group = $this->groupManager->get($groupId);
if (is_null($group)) {
$this->logger->warning("group $groupId no found, can't delete it");
return;
}
if (!$group->delete()) {
$exception = new GenericException("can't delete group $groupId");
$this->logger->error($exception->getMessage(), ["exception" => $exception]);
throw $exception;
}
$this->logger->info("group $groupId deleted");
}
/**
* @param string $groupId
* @param string $displayName
* @return void
*/
private function renameGroup(string $groupId, string $displayName) {
$group = $this->groupManager->get($groupId);
if (is_null($group)) {
$this->logger->warning("group $groupId no found, can't rename it");
return;
}
$group->setDisplayName($displayName);
$this->logger->info("group $groupId renamed to $displayName");
}
/**
* @param string $groupId
* @param string[] $userIds
* @param string $ownerId
* @return void
*/
private function addUsersToGroup(string $groupId, array $userIds, string $ownerId) {
$group = $this->groupManager->get($groupId);
if (is_null($group)) {
$this->logger->warning("group $groupId no found, can't add users");
return;
}
foreach ($userIds as $userId) {
# never add calendar owner other than the service account to group
if ($userId === $ownerId and $ownerId !== $this->userId) {
$this->logger->info("user $userId is the calendar owner, addition to group $groupId skipped");
continue;
}
$user = $this->userManager->get($userId);
if (is_null($user)) {
$this->logger->warning("user $userId no found, can't add it to group $groupId");
continue;
}
$group->addUser($user);
$this->logger->info("user $userId added to group $groupId");
}
}
/**
* @param string $groupId
* @param string $userId
* @return void
*/
private function removeUserFromGroup(string $groupId, string $userId) {
$group = $this->groupManager->get($groupId);
if (is_null($group)) {
$this->logger->warning("group $groupId no found, can't remove user");
return;
}
$user = $this->userManager->get($userId);
if (is_null($user)) {
$this->logger->warning("user $userId no found, can't remove it from group $groupId");
return;
}
$group->removeUser($user);
$this->logger->info("user $userId remove from group $groupId");
}
/**
* @param string $groupId
* @param int $calendarId
* @param string $displayName
* @return void
*/
private function renameForGroupMembers(string $groupId, int $calendarId, string $displayName) {
$group = $this->groupManager->get($groupId);
if (is_null($group)) {
$this->logger->warning("group $groupId no found, can't rename calendar $calendarId for its members");
return;
}
$users = $group->getUsers();
foreach ($users as $user) {
$userId = $user->getUID();
try {
$this->renameForUser($userId, $calendarId, $displayName);
} catch (NotFound $e) {
$this->logger->warning($e->getMessage());
}
}
}
/**
* @param string $userId
* @param int $calendarId
* @param string $displayName
* @return void
* @throws GenericException
*/
private function renameForUser(string $userId, int $calendarId, string $displayName) {
$user = $this->userManager->get($userId);
if (is_null($user)) {
$this->logger->warning("user $userId no found, can't rename calendar $calendarId for them");
return;
}
$server = Dav::getServerInstance($this->connection, $user);
$calendar = $this->caldav->getCalendarById($calendarId);
if (is_null($calendar)) {
$this->logger->warning("calendar $calendarId no found, can't rename it for user $userId");
return;
}
list(, $ownerId) = Uri\split($calendar["principaluri"]);
# never rename a personal calendar other than those of the service account
if ($ownerId === $userId and $ownerId !== $this->userId) {
return;
}
$calendarUri = $ownerId === $userId ? $calendar["uri"] : $this->computeUriForSharedCalendar($calendar["uri"], $ownerId);
$path = "calendars/$userId/$calendarUri";
$properties = [DISPLAYNAME_KEY => $displayName];
$result = $server->updateProperties($path, $properties);
if (!in_array($result[DISPLAYNAME_KEY], [200, 204])) {
$exception = new GenericException("can't rename calendar with URI $calendarUri for user $userId");
$this->logger->error($exception->getMessage(), ["exception" => $exception]);
throw $exception;
}
$this->logger->info("calendar $calendarId renamed to $displayName for user $userId");
}
/**
* @param int $calendarId
* @return string|null
*/
private function getUserIdFromCalendarId(int $calendarId) {
$calendar = $this->caldav->getCalendarById($calendarId);
if (is_null($calendar)) {
$this->logger->warning("calendar $calendarId no found, can't parse user ID");
return;
}
$principalUri = $calendar["principaluri"];
list(, $userId) = Uri\split($principalUri);
return $userId;
}
/**
* @param string $userId
* @param string $calendarUri
* @return int|null
*/
private function getCalendarIdFromUri(string $userId, string $calendarUri) {
$principalUri = "principals/users/$userId";
$calendar = $this->caldav->getCalendarByUri($principalUri, $calendarUri);
if (is_null($calendar)) {
$this->logger->warning("calendar $calendarUri owned by $userId no found, can't get ID");
return;
}
return $calendar["id"];
}
/**
* @param string $calendarUri
* @param string $ownerId
* @return string
*/
private function computeUriForSharedCalendar(string $calendarUri, string $ownerId) {
return $calendarUri . "_shared_by_" . $ownerId;
}
/**
* @param string $mxRoomId
* @return string
*/
private function computeIdFromMxRoomId(string $mxRoomId) {
return hash("sha256", $mxRoomId);
}
}

@ -1,31 +0,0 @@
<?php
namespace OCA\Watcha\Controller;
use OCP\IRequest;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Controller;
class PageController extends Controller {
private $userId;
public function __construct($AppName, IRequest $request, $UserId){
parent::__construct($AppName, $request);
$this->userId = $UserId;
}
/**
* CAUTION: the @Stuff turns off security checks; for this page no admin is
* required and no CSRF check. If you don't know what CSRF is, read
* it up in the docs or you might create a security hole. This is
* basically the only required method to add this exemption, don't
* add it to any other method if you don't exactly know what it does
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function index() {
return new TemplateResponse('watcha', 'index'); // templates/index.php
}
}

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha;
use OCP\IDBConnection;
use OCP\IUser;
// Backends
use OC\KnownUser\KnownUserService;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarRoot;
use OCA\DAV\Connector\LegacyDAVACL;
use OCA\DAV\Connector\Sabre\Auth;
use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
use OCA\DAV\Connector\Sabre\MaintenancePlugin;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\CustomPropertiesBackend;
/**
* Sabre DAV Server
*
* @package OCA\Watcha
*/
class Dav {
/**
* @return \Sabre\DAV\Server
*/
public static function getServerInstance(IDBConnection $connection = null, IUser $user = null) {
$authBackend = new Auth(
\OC::$server->getSession(),
\OC::$server->getUserSession(),
\OC::$server->getRequest(),
\OC::$server->getTwoFactorAuthManager(),
\OC::$server->getBruteForceThrottler(),
'principals/'
);
$principalBackend = new Principal(
\OC::$server->getUserManager(),
\OC::$server->getGroupManager(),
\OC::$server->getShareManager(),
\OC::$server->getUserSession(),
\OC::$server->getAppManager(),
\OC::$server->query(\OCA\DAV\CalDAV\Proxy\ProxyMapper::class),
\OC::$server->get(KnownUserService::class),
\OC::$server->getConfig(),
'principals/'
);
$db = \OC::$server->getDatabaseConnection();
$userManager = \OC::$server->getUserManager();
$random = \OC::$server->getSecureRandom();
$logger = \OC::$server->getLogger();
$dispatcher = \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class);
$legacyDispatcher = \OC::$server->getEventDispatcher();
$calDavBackend = new CalDavBackend($db, $principalBackend, $userManager, \OC::$server->getGroupManager(), $random, $logger, $dispatcher, $legacyDispatcher, false);
$debugging = \OC::$server->getConfig()->getSystemValue('debug', false);
$sendInvitations = \OC::$server->getConfig()->getAppValue('dav', 'sendInvitations', 'yes') === 'yes';
// Root nodes
$principalCollection = new \Sabre\CalDAV\Principal\Collection($principalBackend);
$principalCollection->disableListing = !$debugging; // Disable listing
$addressBookRoot = new CalendarRoot($principalBackend, $calDavBackend);
$addressBookRoot->disableListing = !$debugging; // Disable listing
$nodes = [
$principalCollection,
$addressBookRoot,
];
// Fire up server
$server = new \Sabre\DAV\Server($nodes);
$server::$exposeVersion = false;
$server->httpRequest->setUrl(\OC::$server->getRequest()->getRequestUri());
$baseuri = \OC::$WEBROOT . '/remote.php/dav/';
$server->setBaseUri($baseuri);
// Add plugins
$server->addPlugin(new MaintenancePlugin(\OC::$server->getConfig(), \OC::$server->getL10N('dav')));
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend, 'ownCloud'));
$server->addPlugin(new \Sabre\CalDAV\Plugin());
if ($debugging) {
$server->addPlugin(new \Sabre\DAV\Browser\Plugin());
}
$server->addPlugin(new \Sabre\DAV\Sync\Plugin());
$server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
$server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OC::$server->getConfig()));
if ($sendInvitations) {
$server->addPlugin(\OC::$server->query(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class));
}
$server->addPlugin(new ExceptionLoggerPlugin('caldav', \OC::$server->getLogger()));
if ($connection && $user) {
$server->addPlugin(
new \Sabre\DAV\PropertyStorage\Plugin(
new CustomPropertiesBackend(
$server->tree,
$connection,
$user
)
)
);
}
return $server;
}
}

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha\Exception;
use OCP\AppFramework\Http;
class GenericException extends \Exception {
public function __construct($message = "", $HTTPCode = Http::STATUS_INTERNAL_SERVER_ERROR) {
parent::__construct($message);
$this->$HTTPCode = $HTTPCode;
}
/**
* Returns the HTTP statuscode for this exception.
*
* @return int
*/
public function getHTTPCode() {
return $this->HTTPCode;
}
}

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha\Exception;
use OCP\AppFramework\Http;
class NotServiceAccountException extends \Exception {
public function __construct() {
parent::__construct("Requester must be a configured service account.", Http::STATUS_FORBIDDEN);
}
}

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2021, Watcha <contact@watcha.fr>
*
* @author Charlie Calendre <c-cal@watcha.fr>
*
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Watcha\Middleware;
use OCP\AppFramework\Middleware;
use OCP\IConfig;
use OCA\Watcha\Exception\NotServiceAccountException;
class SecurityMiddleware extends Middleware {
/** @var string */
private $userId;
/** @var IConfig */
private $config;
public function __construct(string $userId, IConfig $config) {
$this->userId = $userId;
$this->config = $config;
}
/**
* @param Controller $controller
* @param string $methodName
* @throws NotServiceAccountException
*/
public function beforeController($controller, $methodName) {
if ($this->userId !== $this->config->getSystemValue('watcha_service_account', 'watcha')) {
throw new NotServiceAccountException();
};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@ -1 +0,0 @@
<h1>Hello world</h1>

@ -1,18 +0,0 @@
<?php
script('watcha', 'script');
style('watcha', 'style');
?>
<div id="app">
<div id="app-navigation">
<?php print_unescaped($this->inc('navigation/index')); ?>
<?php print_unescaped($this->inc('settings/index')); ?>
</div>
<div id="app-content">
<div id="app-content-wrapper">
<?php print_unescaped($this->inc('content/index')); ?>
</div>
</div>
</div>

@ -1,10 +0,0 @@
<ul>
<li><a href="#">First level entry</a></li>
<li>
<a href="#">First level container</a>
<ul>
<li><a href="#">Second level entry</a></li>
<li><a href="#">Second level entry</a></li>
</ul>
</li>
</ul>

@ -1,10 +0,0 @@
<div id="app-settings">
<div id="app-settings-header">
<button class="settings-button"
data-apps-slide-toggle="#app-settings-content"
></button>
</div>
<div id="app-settings-content">
<!-- Your settings in here -->
</div>
</div>

@ -1,31 +0,0 @@
<?php
namespace OCA\Watcha\Tests\Unit\Controller;
use PHPUnit_Framework_TestCase;
use OCP\AppFramework\Http\TemplateResponse;
use OCA\Watcha\Controller\PageController;
class PageControllerTest extends PHPUnit_Framework_TestCase {
private $controller;
private $userId = 'john';
public function setUp() {
$request = $this->getMockBuilder('OCP\IRequest')->getMock();
$this->controller = new PageController(
'watcha', $request, $this->userId
);
}
public function testIndex() {
$result = $this->controller->index();
$this->assertEquals('index', $result->getTemplateName());
$this->assertTrue($result instanceof TemplateResponse);
}
}
Loading…
Cancel
Save