From 21bd10732864283169f018a94156c5007b59a066 Mon Sep 17 00:00:00 2001
From: Jonas Meurer <jonas@freesources.org>
Date: Sat, 22 Aug 2020 12:26:08 +0200
Subject: [PATCH] Implement skeleton folder support (#69)

When creating a new collective, copy over the skeleton folder instead of
creating an empty folder.
---
 README.md                                     |  15 +++
 lib/AppInfo/Application.php                   |   6 +-
 lib/Mount/CollectiveFolderManager.php         | 101 ++++++++++++++++++
 lib/Mount/CollectiveMountPoint.php            |  26 ++---
 lib/Mount/CollectiveRootPathHelper.php        |  28 -----
 lib/Mount/MountProvider.php                   |  78 ++------------
 lib/Service/CollectiveService.php             |  35 +++---
 skeleton/README.md                            |  23 ++++
 tests/Integration/features/collective.feature |   2 +
 9 files changed, 184 insertions(+), 130 deletions(-)
 create mode 100644 lib/Mount/CollectiveFolderManager.php
 delete mode 100644 lib/Mount/CollectiveRootPathHelper.php
 create mode 100644 skeleton/README.md

diff --git a/README.md b/README.md
index 0d152c5c1..f3f920bf0 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,21 @@ organize together. Come and gather in collectives to build shared knowledge.
 * **Well-known [Markdown](https://en.wikipedia.org/wiki/Markdown) syntax**
   for page formatting
 
+## Usage
+
+### Custom skeletons for new collectives
+
+It's possible to customize the skeletons for new collectives by putting files
+in the app skeleton directory at `data/app_<INSTANCE_ID>/collectives/skeleton`.
+New collectives get the contents of this skeleton directory copied over.
+
+`README.md` is the landing page that is opened automatically when entering a
+collective.
+
+If the skeleton directory doesn't contain a `README.md`, the default landing
+page from `collectives/skeleton/README.md` will be copied into the collectives
+directory instead.
+
 ## Dependencies
 
 For installation (see below), the following tools need to be available:
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 3f58eb99e..e2427e0a3 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -5,7 +5,7 @@
 namespace OCA\Collectives\AppInfo;
 
 use Closure;
-use OCA\Collectives\Mount\CollectiveRootPathHelper;
+use OCA\Collectives\Mount\CollectiveFolderManager;
 use OCA\Collectives\Mount\MountProvider;
 use OCA\Collectives\Service\CollectiveHelper;
 use OCP\AppFramework\App;
@@ -13,7 +13,6 @@
 use OCP\AppFramework\Bootstrap\IBootstrap;
 use OCP\AppFramework\Bootstrap\IRegistrationContext;
 use OCP\Files\Config\IMountProviderCollection;
-use OCP\Files\IRootFolder;
 use OCP\IUserSession;
 use Psr\Container\ContainerInterface;
 
@@ -31,8 +30,7 @@ public function register(IRegistrationContext $context): void {
 		$context->registerService(MountProvider::class, function (ContainerInterface $c) {
 			return new MountProvider(
 				$c->get(CollectiveHelper::class),
-				$c->get(CollectiveRootPathHelper::class),
-				$c->get(IRootFolder::class),
+				$c->get(CollectiveFolderManager::class),
 				$c->get(IUserSession::class)
 			);
 		});
diff --git a/lib/Mount/CollectiveFolderManager.php b/lib/Mount/CollectiveFolderManager.php
new file mode 100644
index 000000000..7f40173a0
--- /dev/null
+++ b/lib/Mount/CollectiveFolderManager.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace OCA\Collectives\Mount;
+
+use OC\Files\Node\LazyFolder;
+use OC\SystemConfig;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+
+class CollectiveFolderManager {
+	public const SKELETON_DIR = 'skeleton';
+	public const LANDING_PAGE = 'README.md';
+
+	/** @var SystemConfig */
+	private $systemConfig;
+
+	/** @var IRootFolder */
+	private $rootFolder;
+
+	/**
+	 * MountProvider constructor.
+	 *
+	 * @param SystemConfig $systemConfig
+	 * @param IRootFolder              $rootFolder
+	 */
+	public function __construct(
+		IRootFolder $rootFolder,
+		SystemConfig $systemConfig) {
+		$this->systemConfig = $systemConfig;
+		$this->rootFolder = $rootFolder;
+	}
+
+	public function getRootPath(): string {
+		$instanceId = $this->systemConfig->getValue('instanceid', null);
+		if (null === $instanceId) {
+			throw new \RuntimeException('no instance id!');
+		}
+
+		return 'appdata_' . $instanceId . '/collectives';
+	}
+
+	/**
+	 * @return Folder
+	 */
+	public function getRootFolder(): Folder {
+		$rootFolder = $this->rootFolder;
+		return (new LazyFolder(function () use ($rootFolder) {
+			try {
+				return $rootFolder->get($this->getRootPath());
+			} catch (NotFoundException $e) {
+				return $rootFolder->newFolder($this->getRootPath());
+			}
+		}));
+	}
+
+	/**
+	 * @param Folder $folder
+	 *
+	 * @return Folder
+	 * @throws NotPermittedException
+	 */
+	public function getSkeletonFolder(Folder $folder): Folder {
+		try {
+			$skeletonFolder = $folder->get(self::SKELETON_DIR);
+			if (!$skeletonFolder instanceof Folder) {
+				throw new NotFoundException('Not a folder: ' . $skeletonFolder->getPath());
+			}
+		} catch (NotFoundException $e) {
+			$skeletonFolder = $folder->newFolder(self::SKELETON_DIR);
+		}
+
+		return $skeletonFolder;
+	}
+
+	/**
+	 * @param int  $id
+	 * @param bool $create
+	 *
+	 * @return Folder|null
+	 * @throws NotPermittedException
+	 */
+	public function getFolder(int $id, bool $create = true): ?Folder {
+		try {
+			$folder = $this->getRootFolder()->get((string)$id);
+			if (!$folder instanceof Folder) {
+				return null;
+			}
+		} catch (NotFoundException $e) {
+			if (!$create) {
+				return null;
+			}
+
+			$folder = $this->getSkeletonFolder($this->getRootFolder())
+				->copy($this->getRootFolder()->getPath() . '/' . (string)$id);
+		}
+
+		return $folder;
+	}
+}
diff --git a/lib/Mount/CollectiveMountPoint.php b/lib/Mount/CollectiveMountPoint.php
index 85df9e6f4..e714fe6d8 100644
--- a/lib/Mount/CollectiveMountPoint.php
+++ b/lib/Mount/CollectiveMountPoint.php
@@ -9,25 +9,25 @@ class CollectiveMountPoint extends MountPoint {
 	/** @var int */
 	private $folderId;
 
-	/** @var CollectiveRootPathHelper */
-	private $collectiveRootPathHelper;
+	/** @var CollectiveFolderManager */
+	private $collectiveFolderManager;
 
 	/**
 	 * CollectiveMountPoint constructor.
 	 *
-	 * @param int|null                 $folderId
-	 * @param CollectiveRootPathHelper $collectiveRootPathHelper
-	 * @param CollectiveStorage        $storage
-	 * @param string                   $mountPoint
-	 * @param array|null               $arguments
-	 * @param IStorageFactory|null     $loader
-	 * @param array|null               $mountOptions
-	 * @param int|null                 $mountId
+	 * @param int|null                $folderId
+	 * @param CollectiveFolderManager $collectiveFolderManager
+	 * @param CollectiveStorage       $storage
+	 * @param string                  $mountPoint
+	 * @param array|null              $arguments
+	 * @param IStorageFactory|null    $loader
+	 * @param array|null              $mountOptions
+	 * @param int|null                $mountId
 	 *
 	 * @throws \Exception
 	 */
 	public function __construct(?int $folderId,
-								CollectiveRootPathHelper $collectiveRootPathHelper,
+								CollectiveFolderManager $collectiveFolderManager,
 								CollectiveStorage $storage,
 								string $mountPoint,
 								array $arguments = null,
@@ -35,7 +35,7 @@ public function __construct(?int $folderId,
 								array $mountOptions = null,
 								int $mountId = null) {
 		$this->folderId = $folderId;
-		$this->collectiveRootPathHelper = $collectiveRootPathHelper;
+		$this->collectiveFolderManager = $collectiveFolderManager;
 		parent::__construct($storage, $mountPoint, $arguments, $loader, $mountOptions, $mountId);
 	}
 
@@ -77,6 +77,6 @@ public function getFolderId(): int {
 	 * @return string
 	 */
 	public function getSourcePath(): string {
-		return '/' . $this->collectiveRootPathHelper->get() . '/' . $this->getFolderId();
+		return '/' . $this->collectiveFolderManager->getRootFolder()->getPath() . '/' . $this->getFolderId();
 	}
 }
diff --git a/lib/Mount/CollectiveRootPathHelper.php b/lib/Mount/CollectiveRootPathHelper.php
deleted file mode 100644
index 4a431993b..000000000
--- a/lib/Mount/CollectiveRootPathHelper.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-namespace OCA\Collectives\Mount;
-
-use OC\SystemConfig;
-
-class CollectiveRootPathHelper {
-	/** @var SystemConfig */
-	private $systemConfig;
-
-	/**
-	 * CollectiveRootPathHelper constructor.
-	 *
-	 * @param SystemConfig $systemConfig
-	 */
-	public function __construct(SystemConfig $systemConfig) {
-		$this->systemConfig = $systemConfig;
-	}
-
-	public function get(): string {
-		$instanceId = $this->systemConfig->getValue('instanceid', null);
-		if (null === $instanceId) {
-			throw new \RuntimeException('no instance id!');
-		}
-
-		return 'appdata_' . $instanceId . '/collectives';
-	}
-}
diff --git a/lib/Mount/MountProvider.php b/lib/Mount/MountProvider.php
index bb11c6cba..9db00bf9c 100644
--- a/lib/Mount/MountProvider.php
+++ b/lib/Mount/MountProvider.php
@@ -2,12 +2,9 @@
 
 namespace OCA\Collectives\Mount;
 
-use OC\Files\Node\LazyFolder;
 use OC\Files\Storage\Wrapper\Jail;
 use OCA\Collectives\Service\CollectiveHelper;
 use OCP\Files\Config\IMountProvider;
-use OCP\Files\Folder;
-use OCP\Files\IRootFolder;
 use OCP\Files\Mount\IMountPoint;
 use OCP\Files\NotFoundException;
 use OCP\Files\NotPermittedException;
@@ -16,16 +13,11 @@
 use OCP\IUserSession;
 
 class MountProvider implements IMountProvider {
-	private const LANDING_PAGE = 'README.md';
-
 	/** @var CollectiveHelper */
 	private $collectiveHelper;
 
-	/** @var CollectiveRootPathHelper */
-	private $collectiveRootPathHelper;
-
-	/** @var IRootFolder */
-	private $rootFolder;
+	/** @var CollectiveFolderManager */
+	private $collectiveFolderManager;
 
 	/** @var IUserSession */
 	private $userSession;
@@ -34,18 +26,15 @@ class MountProvider implements IMountProvider {
 	 * MountProvider constructor.
 	 *
 	 * @param CollectiveHelper   $collectiveHelper
-	 * @param CollectiveRootPathHelper $collectiveRootPathHelper
-	 * @param IRootFolder              $rootFolder
+	 * @param CollectiveFolderManager $collectiveFolderManager
 	 * @param IUserSession             $userSession
 	 */
 	public function __construct(
 		CollectiveHelper $collectiveHelper,
-		CollectiveRootPathHelper $collectiveRootPathHelper,
-		IRootFolder $rootFolder,
+		CollectiveFolderManager $collectiveFolderManager,
 		IUserSession $userSession) {
 		$this->collectiveHelper = $collectiveHelper;
-		$this->collectiveRootPathHelper = $collectiveRootPathHelper;
-		$this->rootFolder = $rootFolder;
+		$this->collectiveFolderManager = $collectiveFolderManager;
 		$this->userSession = $userSession;
 	}
 
@@ -81,14 +70,6 @@ public function getMountsForUser(IUser $user, IStorageFactory $loader) {
 		}, $folders);
 	}
 
-	/**
-	 * @return string|null
-	 */
-	private function getCurrentUID(): ?string {
-		$user = $this->userSession->getUser();
-		return $user ? $user->getUID() : null;
-	}
-
 	/**
 	 * @param int                  $id
 	 * @param string               $mountPoint
@@ -107,10 +88,10 @@ public function getMount(int $id,
 							 IUser $user = null): IMountPoint {
 		if (!$cacheEntry) {
 			// trigger folder creation
-			$this->getFolder($id);
+			$this->collectiveFolderManager->getFolder($id);
 		}
 
-		$storage = $this->getCollectivesRootFolder()->getStorage();
+		$storage = $this->collectiveFolderManager->getRootFolder()->getStorage();
 
 		$rootPath = $this->getJailPath($id);
 
@@ -128,7 +109,7 @@ public function getMount(int $id,
 
 		return new CollectiveMountPoint(
 			$id,
-			$this->collectiveRootPathHelper,
+			$this->collectiveFolderManager,
 			$collectiveStorage,
 			$mountPoint,
 			null,
@@ -142,47 +123,6 @@ public function getMount(int $id,
 	 * @return string
 	 */
 	public function getJailPath(int $folderId): string {
-		return $this->getCollectivesRootFolder()->getInternalPath() . '/' . $folderId;
-	}
-
-	/**
-	 * @return Folder
-	 */
-	private function getCollectivesRootFolder(): Folder {
-		$rootFolder = $this->rootFolder;
-		return (new LazyFolder(function () use ($rootFolder) {
-			try {
-				return $rootFolder->get($this->collectiveRootPathHelper->get());
-			} catch (NotFoundException $e) {
-				return $rootFolder->newFolder($this->collectiveRootPathHelper->get());
-			}
-		}));
-	}
-
-	/**
-	 * @param int  $id
-	 * @param bool $create
-	 *
-	 * @return Folder|null
-	 * @throws NotPermittedException
-	 */
-	public function getFolder(int $id, bool $create = true): ?Folder {
-		try {
-			$folder = $this->getCollectivesRootFolder()->get((string)$id);
-			if (!$folder instanceof Folder) {
-				return null;
-			}
-		} catch (NotFoundException $e) {
-			if ($create) {
-				$folder = $this->getCollectivesRootFolder()->newFolder((string)$id);
-			}
-			return null;
-		}
-
-		if ($create && !$folder->nodeExists(self::LANDING_PAGE)) {
-			$folder->newFile(self::LANDING_PAGE);
-		}
-
-		return $folder;
+		return $this->collectiveFolderManager->getRootFolder()->getInternalPath() . '/' . $folderId;
 	}
 }
diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php
index fb937a7d8..4e7c753d1 100644
--- a/lib/Service/CollectiveService.php
+++ b/lib/Service/CollectiveService.php
@@ -6,10 +6,9 @@
 use OCA\Collectives\Db\Collective;
 use OCA\Collectives\Db\CollectiveMapper;
 use OCA\Collectives\Fs\NodeHelper;
-use OCA\Collectives\Mount\CollectiveRootPathHelper;
+use OCA\Collectives\Mount\CollectiveFolderManager;
 use OCP\AppFramework\QueryException;
 use OCP\Files\InvalidPathException;
-use OCP\Files\IRootFolder;
 use OCP\Files\NotPermittedException;
 
 class CollectiveService {
@@ -19,10 +18,8 @@ class CollectiveService {
 	private $collectiveHelper;
 	/** @var NodeHelper */
 	private $nodeHelper;
-	/** @var IRootFolder */
-	private $rootFolder;
-	/** @var CollectiveRootPathHelper */
-	private $collectiveRootPathHelper;
+	/** @var CollectiveFolderManager */
+	private $collectiveFolderManager;
 
 	/**
 	 * CollectiveService constructor.
@@ -30,21 +27,17 @@ class CollectiveService {
 	 * @param CollectiveMapper         $collectiveMapper
 	 * @param CollectiveHelper   $collectiveHelper
 	 * @param NodeHelper               $nodeHelper
-	 * @param IRootFolder              $rootFolder
-	 * @param CollectiveRootPathHelper $collectiveRootPathHelper
+	 * @param CollectiveFolderManager  $collectiveFolderManager
 	 */
 	public function __construct(
 		CollectiveMapper $collectiveMapper,
 		CollectiveHelper $collectiveHelper,
 		NodeHelper $nodeHelper,
-		IRootFolder $rootFolder,
-		CollectiveRootPathHelper $collectiveRootPathHelper
-	) {
+		CollectiveFolderManager $collectiveFolderManager) {
 		$this->collectiveMapper = $collectiveMapper;
 		$this->collectiveHelper = $collectiveHelper;
 		$this->nodeHelper = $nodeHelper;
-		$this->rootFolder = $rootFolder;
-		$this->collectiveRootPathHelper = $collectiveRootPathHelper;
+		$this->collectiveFolderManager = $collectiveFolderManager;
 	}
 
 	/**
@@ -62,6 +55,7 @@ public function getCollectives(string $userId): array {
 	 * @param string $name
 	 *
 	 * @return Collective
+	 * @throws NotPermittedException
 	 */
 	public function createCollective(string $userId, string $name): Collective {
 		if (empty($name)) {
@@ -87,6 +81,15 @@ public function createCollective(string $userId, string $name): Collective {
 		$collective->setCircleUniqueId($circle->getUniqueId());
 		$collective = $this->collectiveMapper->insert($collective);
 
+		// Create folder for collective and optionally copy default landing page
+		$collectiveFolder = $this->collectiveFolderManager->getFolder($collective->getId());
+		if (null !== $collectiveFolder &&
+			!$collectiveFolder->nodeExists(CollectiveFolderManager::LANDING_PAGE)) {
+			if (false !== $content = file_get_contents(__DIR__ . '/../../skeleton/' . CollectiveFolderManager::LANDING_PAGE)) {
+				$collectiveFolder->newFile(CollectiveFolderManager::LANDING_PAGE, $content);
+			}
+		}
+
 		return $collective;
 	}
 
@@ -101,7 +104,6 @@ public function deleteCollective(string $userId, int $id): Collective {
 		if (null === $collective = $this->collectiveMapper->findById($id, $userId)) {
 			throw new NotFoundException('Collective not found: '. $id);
 		}
-		$folder = $this->collectiveMapper->getCollectiveFolder($collective, $userId);
 
 		try {
 			Circles::destroyCircle($collective->getCircleUniqueId());
@@ -110,8 +112,9 @@ public function deleteCollective(string $userId, int $id): Collective {
 		}
 
 		try {
-			$collectiveFolder = $this->rootFolder->get($this->collectiveRootPathHelper->get() . '/' . $collective->getId());
-			$collectiveFolder->delete();
+			if (null !== $collectiveFolder = $this->collectiveFolderManager->getFolder($collective->getId(), false)) {
+				$collectiveFolder->delete();
+			}
 		} catch (InvalidPathException | \OCP\Files\NotFoundException | NotPermittedException $e) {
 			throw new NotFoundException('Failed to delete collective folder');
 		}
diff --git a/skeleton/README.md b/skeleton/README.md
new file mode 100644
index 000000000..5034015f2
--- /dev/null
+++ b/skeleton/README.md
@@ -0,0 +1,23 @@
+# Welcome to your new collective
+
+*Come, organize and build shared knowledge!*
+
+
+### 🐾 Add your comrades to the collective
+
+Members can be managed in the [Circle App](/index.php/apps/circles/)
+
+### 🌱 Bring life to your collective
+
+Create pages and share the knowledge that really matters
+
+### 🛋️ Edit this landing page to feel like home
+
+Push the pencil button to get started ↗️
+
+
+## Also good to know
+
+* Link local pages by selecting text and choosing "link file"
+* Multiple people can edit the same page simultaneously
+* Find out more about this App in the [documentation](https://gitlab.com/collectivecloud/collectives)
diff --git a/tests/Integration/features/collective.feature b/tests/Integration/features/collective.feature
index be9cc2c32..83b609365 100644
--- a/tests/Integration/features/collective.feature
+++ b/tests/Integration/features/collective.feature
@@ -5,6 +5,8 @@ Feature: collective
     And user "alice" is member of circle "mycollective" with admin "jane"
     Then user "jane" sees collective "mycollective"
     And user "alice" sees collective "mycollective"
+    And user "jane" sees page "README" in "mycollective"
+    And user "alice" sees page "README" in "mycollective"
     And user "john" doesn't see collective "mycollective"
 
   Scenario: Fail to delete a foreign collective