forms/lib/Service/FormsService.php

602 строки
16 KiB
PHP

<?php
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
* @author Jonas Rittershofer <jotoeri@users.noreply.github.com>
*
* @license AGPL-3.0-or-later
*
* 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\Forms\Service;
use OCA\Forms\Activity\ActivityManager;
use OCA\Forms\Constants;
use OCA\Forms\Db\Form;
use OCA\Forms\Db\FormMapper;
use OCA\Forms\Db\OptionMapper;
use OCA\Forms\Db\QuestionMapper;
use OCA\Forms\Db\Share;
use OCA\Forms\Db\ShareMapper;
use OCA\Forms\Db\SubmissionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\IMapperException;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
/**
* Trait for getting forms information in a service
*/
class FormsService {
private ?IUser $currentUser;
public function __construct(
IUserSession $userSession,
private ActivityManager $activityManager,
private FormMapper $formMapper,
private OptionMapper $optionMapper,
private QuestionMapper $questionMapper,
private ShareMapper $shareMapper,
private SubmissionMapper $submissionMapper,
private ConfigService $configService,
private IGroupManager $groupManager,
private LoggerInterface $logger,
private IUserManager $userManager,
private ISecureRandom $secureRandom,
private CirclesService $circlesService,
) {
$this->currentUser = $userSession->getUser();
}
/**
* Create a new Form Hash
*/
public function generateFormHash(): string {
return $this->secureRandom->generate(
16,
ISecureRandom::CHAR_HUMAN_READABLE
);
}
/**
* Load options corresponding to question
*
* @param integer $questionId
* @return array
*/
public function getOptions(int $questionId): array {
$optionList = [];
try {
$optionEntities = $this->optionMapper->findByQuestion($questionId);
foreach ($optionEntities as $optionEntity) {
$optionList[] = $optionEntity->read();
}
} catch (DoesNotExistException $e) {
//handle silently
} finally {
return $optionList;
}
}
/**
* Load questions corresponding to form
*
* @param integer $formId
* @return array
*/
public function getQuestions(int $formId): array {
$questionList = [];
try {
$questionEntities = $this->questionMapper->findByForm($formId);
foreach ($questionEntities as $questionEntity) {
$question = $questionEntity->read();
$question['options'] = $this->getOptions($question['id']);
$questionList[] = $question;
}
} catch (DoesNotExistException $e) {
//handle silently
} finally {
return $questionList;
}
}
/**
* Load shares corresponding to form
*
* @param integer $formId
* @return array
*/
public function getShares(int $formId): array {
$shareList = [];
$shareEntities = $this->shareMapper->findByForm($formId);
foreach ($shareEntities as $shareEntity) {
$share = $shareEntity->read();
$share['displayName'] = $this->getShareDisplayName($share);
$shareList[] = $share;
}
return $shareList;
}
/**
* Get a form data
*
* @param Form $form
* @return array
* @throws IMapperException
*/
public function getForm(Form $form): array {
$result = $form->read();
$result['questions'] = $this->getQuestions($form->getId());
$result['shares'] = $this->getShares($form->getId());
// Append permissions for current user.
$result['permissions'] = $this->getPermissions($form);
// Append canSubmit, to be able to show proper EmptyContent on internal view.
$result['canSubmit'] = $this->canSubmit($form);
// Append submissionCount if currentUser has permissions to see results
if (in_array(Constants::PERMISSION_RESULTS, $result['permissions'])) {
$result['submissionCount'] = $this->submissionMapper->countSubmissions($form->getId());
}
return $result;
}
/**
* Create partial form, as returned by Forms-Lists.
*
* @param Form $form
* @return array
* @throws IMapperException
*/
public function getPartialFormArray(Form $form): array {
$result = [
'id' => $form->getId(),
'hash' => $form->getHash(),
'title' => $form->getTitle(),
'expires' => $form->getExpires(),
'lastUpdated' => $form->getLastUpdated(),
'permissions' => $this->getPermissions($form),
'partial' => true
];
// Append submissionCount if currentUser has permissions to see results
if (in_array(Constants::PERMISSION_RESULTS, $result['permissions'])) {
$result['submissionCount'] = $this->submissionMapper->countSubmissions($form->getId());
}
return $result;
}
/**
* Get a form data without sensitive informations
*
* @param Form $form
* @return array
* @throws IMapperException
*/
public function getPublicForm(Form $form): array {
$formData = $this->getForm($form);
// Remove sensitive data
unset($formData['access']);
unset($formData['ownerId']);
unset($formData['shares']);
return $formData;
}
/**
* Get current users permissions on a form
*
* @param Form $form
* @return array
*/
public function getPermissions(Form $form): array {
if (!$this->currentUser) {
return [];
}
// Owner is allowed to do everything
if ($this->currentUser->getUID() === $form->getOwnerId()) {
return Constants::PERMISSION_ALL;
}
$permissions = [];
$shares = $this->getSharesWithUser($form->getId(), $this->currentUser->getUID());
foreach ($shares as $share) {
$permissions = array_merge($permissions, $share->getPermissions());
}
// Fall back to submit permission if access is granted to all users
if (count($permissions) === 0) {
$access = $form->getAccess();
if ($access['permitAllUsers'] && $this->configService->getAllowPermitAll()) {
$permissions = [Constants::PERMISSION_SUBMIT];
}
}
return array_values(array_unique($permissions));
}
/**
* Can the current user see results of a form
*
* @param Form $form
* @return boolean
*/
public function canSeeResults(Form $form): bool {
return in_array(Constants::PERMISSION_RESULTS, $this->getPermissions($form));
}
/**
* Can the user submit a form
*
* @param Form $form
* @return boolean
*/
public function canSubmit(Form $form): bool {
// We cannot control how many time users can submit if public link / legacyLink available
if ($this->hasPublicLink($form)) {
return true;
}
// Owner is always allowed to submit
if ($this->currentUser->getUID() === $form->getOwnerId()) {
return true;
}
// Refuse access, if SubmitMultiple is not set and user already has taken part.
if (!$form->getSubmitMultiple()) {
$participants = $this->submissionMapper->findParticipantsByForm($form->getId());
foreach ($participants as $participant) {
if ($participant === $this->currentUser->getUID()) {
return false;
}
}
}
return true;
}
/**
* Searching Shares for public link
*
* @param Form $form
* @return boolean
*/
public function hasPublicLink(Form $form): bool {
$access = $form->getAccess();
if (isset($access['legacyLink'])) {
return true;
}
$shareEntities = $this->shareMapper->findByForm($form->getId());
foreach ($shareEntities as $shareEntity) {
if ($shareEntity->getShareType() === IShare::TYPE_LINK) {
return true;
}
}
return false;
}
/**
* Check if current user has access to this form
*
* @param Form $form
* @return boolean
*/
public function hasUserAccess(Form $form): bool {
$access = $form->getAccess();
$ownerId = $form->getOwnerId();
// Refuse access, if no user logged in.
if (!$this->currentUser) {
return false;
}
// Always grant access to owner.
if ($ownerId === $this->currentUser->getUID()) {
return true;
}
// Now all remaining users are allowed, if permitAll is set.
if ($access['permitAllUsers'] && $this->configService->getAllowPermitAll()) {
return true;
}
// Selected Access remains.
if ($this->isSharedToUser($form->getId())) {
return true;
}
// None of the possible access-options matched.
return false;
}
/**
* Is the form shown on sidebar to the user.
*
* @param Form $form
* @return bool
*/
public function isSharedFormShown(Form $form): bool {
$access = $form->getAccess();
// Dont show here to owner, as its in the owned list anyways.
if ($form->getOwnerId() === $this->currentUser->getUID()) {
return false;
}
// Dont show expired forms.
if ($this->hasFormExpired($form)) {
return false;
}
// Shown if permitall and showntoall are both set.
if ($access['permitAllUsers'] &&
$access['showToAllUsers'] &&
$this->configService->getAllowPermitAll()) {
return true;
}
// Shown if user in List of Shared Users/Groups
if ($this->isSharedToUser($form->getId())) {
return true;
}
// No Reason found to show form.
return false;
}
/**
* Checking all selected shares
*
* @param int $formId
* @return bool
*/
public function isSharedToUser(int $formId): bool {
$shareEntities = $this->getSharesWithUser($formId, $this->currentUser->getUID());
return count($shareEntities) > 0;
}
/*
* Has the form expired?
*
* @param Form $form
* @return boolean
*/
public function hasFormExpired(Form $form): bool {
return ($form->getExpires() !== 0 && $form->getExpires() < time());
}
/**
* Get DisplayNames to Shares
*
* @param array $share
* @return string
*/
public function getShareDisplayName(array $share): string {
$displayName = '';
switch ($share['shareType']) {
case IShare::TYPE_USER:
$user = $this->userManager->get($share['shareWith']);
if ($user instanceof IUser) {
$displayName = $user->getDisplayName();
}
break;
case IShare::TYPE_GROUP:
$group = $this->groupManager->get($share['shareWith']);
if ($group instanceof IGroup) {
$displayName = $group->getDisplayName();
}
break;
case IShare::TYPE_CIRCLE:
$circle = $this->circlesService->getCircle($share['shareWith']);
if (!is_null($circle)) {
$displayName = $circle->getDisplayName();
}
break;
default:
// Preset Empty.
}
return $displayName;
}
/**
* Creates activities for sharing to users.
* @param Form $form Related Form
* @param Share $share The new Share
*/
public function notifyNewShares(Form $form, Share $share): void {
switch ($share->getShareType()) {
case IShare::TYPE_USER:
$this->activityManager->publishNewShare($form, $share->getShareWith());
break;
case IShare::TYPE_GROUP:
$this->activityManager->publishNewGroupShare($form, $share->getShareWith());
break;
case IShare::TYPE_CIRCLE:
$this->activityManager->publishNewCircleShare($form, $share->getShareWith());
break;
default:
// Do nothing.
}
}
/**
* Creates activities for new submissions on a form
*
* @param Form $form Related Form
* @param string $submitter The ID of the user who submitted the form. Can also be our 'anon-user-'-ID
*/
public function notifyNewSubmission(Form $form, string $submitter): void {
$shares = $this->getShares($form->getId());
$this->activityManager->publishNewSubmission($form, $submitter);
foreach ($shares as $share) {
if (!in_array(Constants::PERMISSION_RESULTS, $share['permissions'])) {
continue;
}
$this->activityManager->publishNewSharedSubmission($form, $share['shareType'], $share['shareWith'], $submitter);
}
}
/**
* Return shares of a form shared with given user
*
* @param int $formId The form to query shares for
* @param string $userId The user to check if shared with
* @return array
*/
protected function getSharesWithUser(int $formId, string $userId): array {
$shareEntities = $this->shareMapper->findByForm($formId);
return array_filter($shareEntities, function ($shareEntity) use ($userId) {
$share = $shareEntity->read();
// Needs different handling for shareTypes
switch ($share['shareType']) {
case IShare::TYPE_USER:
if ($share['shareWith'] === $userId) {
return true;
}
break;
case IShare::TYPE_GROUP:
if ($this->groupManager->isInGroup($userId, $share['shareWith'])) {
return true;
}
break;
case IShare::TYPE_CIRCLE:
if ($this->circlesService->isUserInCircle($share['shareWith'], $userId)) {
return true;
}
break;
default:
return false;
}
});
}
/**
* Update lastUpdated timestamp for the given form
*
* @param int $formId The form to update
*/
public function setLastUpdatedTimestamp(int $formId): void {
$form = $this->formMapper->findById($formId);
$form->setLastUpdated(time());
$this->formMapper->update($form);
}
/*
* Validates the extraSettings
*
* @param array $extraSettings input extra settings
* @param string $questionType the question type
* @return bool if the settings are valid
*/
public function areExtraSettingsValid(array $extraSettings, string $questionType) {
if (count($extraSettings) === 0) {
return true;
}
// Ensure only allowed keys are set
switch ($questionType) {
case Constants::ANSWER_TYPE_DROPDOWN:
$allowed = Constants::EXTRA_SETTINGS_DROPDOWN;
break;
case Constants::ANSWER_TYPE_MULTIPLE:
case Constants::ANSWER_TYPE_MULTIPLEUNIQUE:
$allowed = Constants::EXTRA_SETTINGS_MULTIPLE;
break;
case Constants::ANSWER_TYPE_SHORT:
$allowed = Constants::EXTRA_SETTINGS_SHORT;
break;
default:
$allowed = [];
}
// Number of keys in extraSettings but not in allowed (but not the other way round)
$diff = array_diff(array_keys($extraSettings), $allowed);
if (count($diff) > 0) {
return false;
}
// Special handling of short input for validation
if ($questionType === Constants::ANSWER_TYPE_SHORT && isset($extraSettings['validationType'])) {
// Ensure input validation type is known
if (!in_array($extraSettings['validationType'], Constants::SHORT_INPUT_TYPES)) {
return false;
}
// For custom validation we need to sanitize the regex
if ($extraSettings['validationType'] === 'regex') {
// regex is required for "custom" input validation type
if (!isset($extraSettings['validationRegex'])) {
return false;
}
// regex option must be a string
if (!is_string($extraSettings['validationRegex'])) {
return false;
}
// empty regex matches every thing, this happens also when a new question is created
if (strlen($extraSettings['validationRegex']) === 0) {
return true;
}
// general pattern of a valid regex
$VALID_REGEX = '/^\/(.+)\/([smi]{0,3})$/';
// pattern to look for unescaped slashes
$REGEX_UNESCAPED_SLASHES = '/(?<=(^|[^\\\\]))(\\\\\\\\)*\\//';
$matches = [];
// only pattern with delimiters and supported modifiers (by PHP *and* JS)
if (@preg_match($VALID_REGEX, $extraSettings['validationRegex'], $matches) !== 1) {
return false;
}
// We use slashes as delimters, so unescaped slashes within the pattern are **not** allowed
if (@preg_match($REGEX_UNESCAPED_SLASHES, $matches[1]) === 1) {
return false;
}
// Try to compile the given pattern, `preg_match` will return false if the pattern is invalid
if (@preg_match($extraSettings['validationRegex'], 'some string') === false) {
return false;
}
}
}
return true;
}
}