libresign/lib/Service/RequestSignatureService.php
Vitor Mattos 6bd828e9ed
fix: respect status 0 (DRAFT) when adding new signers
When adding a new signer with status 0, the backend was ignoring it
because empty() treated 0 as falsy. Changed to isset() to properly
handle status 0.

Also updated determineInitialStatus() to allow new signers to be
added in DRAFT mode even when the file is not in DRAFT status,
allowing gradual signer addition before requesting signatures.

Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
2025-12-11 15:14:34 -03:00

444 lines
14 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Libresign\Service;
use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\FileElementMapper;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\IdentifyMethodMapper;
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
use OCA\Libresign\Db\SignRequestMapper;
use OCA\Libresign\Handler\DocMdpHandler;
use OCA\Libresign\Helper\ValidateHelper;
use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Files\IMimeTypeDetector;
use OCP\Files\Node;
use OCP\Http\Client\IClientService;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
use Sabre\DAV\UUIDUtil;
class RequestSignatureService {
use TFile;
public function __construct(
protected IL10N $l10n,
protected IdentifyMethodService $identifyMethod,
protected SignRequestMapper $signRequestMapper,
protected IUserManager $userManager,
protected FileMapper $fileMapper,
protected IdentifyMethodMapper $identifyMethodMapper,
protected PdfParserService $pdfParserService,
protected FileElementService $fileElementService,
protected FileElementMapper $fileElementMapper,
protected FolderService $folderService,
protected IMimeTypeDetector $mimeTypeDetector,
protected ValidateHelper $validateHelper,
protected IClientService $client,
protected DocMdpHandler $docMdpHandler,
protected LoggerInterface $logger,
protected SequentialSigningService $sequentialSigningService,
) {
}
public function save(array $data): FileEntity {
$file = $this->saveFile($data);
$this->saveVisibleElements($data, $file);
if (!isset($data['status'])) {
$data['status'] = $file->getStatus();
}
$this->associateToSigners($data, $file->getId());
return $file;
}
/**
* Save file data
*
* @param array{?userManager: IUser, ?signRequest: SignRequest, name: string, callback: string, uuid?: ?string, status: int, file?: array{fileId?: int, fileNode?: Node}} $data
*/
public function saveFile(array $data): FileEntity {
if (!empty($data['uuid'])) {
$file = $this->fileMapper->getByUuid($data['uuid']);
return $this->updateStatus($file, $data['status'] ?? 0);
}
$fileId = null;
if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) {
$fileId = $data['file']['fileNode']->getId();
} elseif (!empty($data['file']['fileId'])) {
$fileId = $data['file']['fileId'];
}
if (!is_null($fileId)) {
try {
$file = $this->fileMapper->getByFileId($fileId);
return $this->updateStatus($file, $data['status'] ?? 0);
} catch (\Throwable) {
}
}
$node = $this->getNodeFromData($data);
$file = new FileEntity();
$file->setNodeId($node->getId());
if ($data['userManager'] instanceof IUser) {
$file->setUserId($data['userManager']->getUID());
} elseif ($data['signRequest'] instanceof SignRequestEntity) {
$file->setSignRequestId($data['signRequest']->getId());
}
$file->setUuid(UUIDUtil::getUUID());
$file->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
$metadata = $this->getFileMetadata($node);
$file->setName($this->removeExtensionFromName($data['name'], $metadata));
$file->setMetadata($metadata);
if (!empty($data['callback'])) {
$file->setCallback($data['callback']);
}
if (isset($data['status'])) {
$file->setStatus($data['status']);
} else {
$file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN);
}
$this->fileMapper->insert($file);
return $file;
}
private function updateStatus(FileEntity $file, int $status): FileEntity {
if ($status > $file->getStatus()) {
$file->setStatus($status);
/** @var FileEntity */
return $this->fileMapper->update($file);
}
return $file;
}
private function getFileMetadata(\OCP\Files\Node $node): array {
$metadata = [];
if ($extension = strtolower($node->getExtension())) {
$metadata = [
'extension' => $extension,
];
if ($metadata['extension'] === 'pdf') {
$metadata = array_merge(
$metadata,
$this->pdfParserService
->setFile($node)
->getPageDimensions()
);
}
}
return $metadata;
}
private function removeExtensionFromName(string $name, array $metadata): string {
if (!isset($metadata['extension'])) {
return $name;
}
$extensionPattern = '/\.' . preg_quote($metadata['extension'], '/') . '$/i';
$result = preg_replace($extensionPattern, '', $name);
return $result ?? $name;
}
private function deleteIdentifyMethodIfNotExits(array $users, int $fileId): void {
$file = $this->fileMapper->getById($fileId);
$signRequests = $this->signRequestMapper->getByFileId($fileId);
foreach ($signRequests as $key => $signRequest) {
$identifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequest->getId());
if (empty($identifyMethods)) {
$this->unassociateToUser($file->getNodeId(), $signRequest->getId());
continue;
}
foreach ($identifyMethods as $methodName => $list) {
foreach ($list as $method) {
$exists[$key]['identify'][$methodName] = $method->getEntity()->getIdentifierValue();
if (!$this->identifyMethodExists($users, $method)) {
$this->unassociateToUser($file->getNodeId(), $signRequest->getId());
continue 3;
}
}
}
}
}
private function identifyMethodExists(array $users, IIdentifyMethod $identifyMethod): bool {
foreach ($users as $user) {
if (!empty($user['identifyMethods'])) {
foreach ($user['identifyMethods'] as $data) {
if ($identifyMethod->getEntity()->getIdentifierKey() !== $data['method']) {
continue;
}
if ($identifyMethod->getEntity()->getIdentifierValue() === $data['value']) {
return true;
}
}
} else {
foreach ($user['identify'] as $method => $value) {
if ($identifyMethod->getEntity()->getIdentifierKey() !== $method) {
continue;
}
if ($identifyMethod->getEntity()->getIdentifierValue() === $value) {
return true;
}
}
}
}
return false;
}
/**
* @return SignRequestEntity[]
*
* @psalm-return list<SignRequestEntity>
*/
private function associateToSigners(array $data, int $fileId): array {
$return = [];
if (!empty($data['users'])) {
$this->deleteIdentifyMethodIfNotExits($data['users'], $fileId);
$this->sequentialSigningService->resetOrderCounter();
$fileStatus = $data['status'] ?? null;
foreach ($data['users'] as $user) {
$userProvidedOrder = isset($user['signingOrder']) ? (int)$user['signingOrder'] : null;
$signingOrder = $this->sequentialSigningService->determineSigningOrder($userProvidedOrder);
if (isset($user['identifyMethods'])) {
foreach ($user['identifyMethods'] as $identifyMethod) {
$return[] = $this->associateToSigner(
identifyMethods: [
$identifyMethod['method'] => $identifyMethod['value'],
],
displayName: $user['displayName'] ?? '',
description: $user['description'] ?? '',
notify: empty($user['notify']) && $this->isStatusAbleToNotify($fileStatus),
fileId: $fileId,
signingOrder: $signingOrder,
fileStatus: $fileStatus,
);
}
} else {
$return[] = $this->associateToSigner(
identifyMethods: $user['identify'],
displayName: $user['displayName'] ?? '',
description: $user['description'] ?? '',
notify: empty($user['notify']) && $this->isStatusAbleToNotify($fileStatus),
fileId: $fileId,
signingOrder: $signingOrder,
fileStatus: $fileStatus,
);
}
}
}
return $return;
}
private function isStatusAbleToNotify(?int $status): bool {
return in_array($status, [
FileEntity::STATUS_ABLE_TO_SIGN,
FileEntity::STATUS_PARTIAL_SIGNED,
]);
}
private function associateToSigner(
array $identifyMethods,
string $displayName,
string $description,
bool $notify,
int $fileId,
int $signingOrder = 0,
?int $fileStatus = null,
): SignRequestEntity {
$identifyMethodsIncances = $this->identifyMethod->getByUserData($identifyMethods);
if (empty($identifyMethodsIncances)) {
throw new \Exception($this->l10n->t('Invalid identification method'));
}
$signRequest = $this->getSignRequestByIdentifyMethod(
current($identifyMethodsIncances),
$fileId
);
$displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsIncances, $displayName);
$this->setDataToUser($signRequest, $displayName, $description, $fileId);
$signRequest->setSigningOrder($signingOrder);
$isNewSignRequest = !$signRequest->getId();
$currentStatus = $signRequest->getStatusEnum();
if ($isNewSignRequest || $currentStatus === \OCA\Libresign\Enum\SignRequestStatus::DRAFT) {
$initialStatus = $this->determineInitialStatus($signingOrder, $fileStatus);
$signRequest->setStatusEnum($initialStatus);
}
$this->saveSignRequest($signRequest);
$shouldNotify = $notify && $signRequest->getStatusEnum() === \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN;
foreach ($identifyMethodsIncances as $identifyMethod) {
$identifyMethod->getEntity()->setSignRequestId($signRequest->getId());
$identifyMethod->willNotifyUser($shouldNotify);
$identifyMethod->save();
}
return $signRequest;
}
private function determineInitialStatus(int $signingOrder, ?int $fileStatus = null): \OCA\Libresign\Enum\SignRequestStatus {
// If fileStatus is explicitly DRAFT (0), keep signer as DRAFT
// This allows adding new signers in DRAFT mode even when file is not in DRAFT status
if ($fileStatus === FileEntity::STATUS_DRAFT) {
return \OCA\Libresign\Enum\SignRequestStatus::DRAFT;
}
if (!$this->sequentialSigningService->isOrderedNumericFlow()) {
return \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN;
}
return $signingOrder === 1
? \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN
: \OCA\Libresign\Enum\SignRequestStatus::DRAFT;
}
/**
* @param IIdentifyMethod[] $identifyMethodsIncances
* @param string $displayName
* @return string
*/
private function getDisplayNameFromIdentifyMethodIfEmpty(array $identifyMethodsIncances, string $displayName): string {
if (!empty($displayName)) {
return $displayName;
}
foreach ($identifyMethodsIncances as $identifyMethod) {
if ($identifyMethod->getName() === 'account') {
return $this->userManager->get($identifyMethod->getEntity()->getIdentifierValue())->getDisplayName();
}
}
foreach ($identifyMethodsIncances as $identifyMethod) {
if ($identifyMethod->getName() !== 'account') {
return $identifyMethod->getEntity()->getIdentifierValue();
}
}
return '';
}
private function saveVisibleElements(array $data, FileEntity $file): array {
if (empty($data['visibleElements'])) {
return [];
}
$elements = $data['visibleElements'];
foreach ($elements as $key => $element) {
$element['fileId'] = $file->getId();
$elements[$key] = $this->fileElementService->saveVisibleElement($element);
}
return $elements;
}
public function validateNewRequestToFile(array $data): void {
$this->validateNewFile($data);
$this->validateUsers($data);
$this->validateHelper->validateFileStatus($data);
}
public function validateNewFile(array $data): void {
if (empty($data['name'])) {
throw new \Exception($this->l10n->t('Name is mandatory'));
}
$this->validateHelper->validateNewFile($data);
}
public function validateUsers(array $data): void {
if (empty($data['users'])) {
throw new \Exception($this->l10n->t('Empty users list'));
}
if (!is_array($data['users'])) {
// TRANSLATION This message will be displayed when the request to API with the key users has a value that is not an array
throw new \Exception($this->l10n->t('User list needs to be an array'));
}
foreach ($data['users'] as $user) {
if (!array_key_exists('identify', $user)) {
throw new \Exception('Identify key not found');
}
$this->identifyMethod->setAllEntityData($user);
}
}
public function saveSignRequest(SignRequestEntity $signRequest): void {
if ($signRequest->getId()) {
$this->signRequestMapper->update($signRequest);
} else {
$this->signRequestMapper->insert($signRequest);
}
}
/**
* @psalm-suppress MixedMethodCall
*/
private function setDataToUser(SignRequestEntity $signRequest, string $displayName, string $description, int $fileId): void {
$signRequest->setFileId($fileId);
if (!$signRequest->getUuid()) {
$signRequest->setUuid(UUIDUtil::getUUID());
}
if (!empty($displayName)) {
$signRequest->setDisplayName($displayName);
}
if (!empty($description)) {
$signRequest->setDescription($description);
}
if (!$signRequest->getId()) {
$signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
}
}
private function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, int $fileId): SignRequestEntity {
try {
$signRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId($identifyMethod, $fileId);
} catch (DoesNotExistException) {
$signRequest = new SignRequestEntity();
}
return $signRequest;
}
public function unassociateToUser(int $fileId, int $signRequestId): void {
$signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
$deletedOrder = $signRequest->getSigningOrder();
try {
$this->signRequestMapper->delete($signRequest);
$groupedIdentifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequestId);
foreach ($groupedIdentifyMethods as $identifyMethods) {
foreach ($identifyMethods as $identifyMethod) {
$identifyMethod->delete();
}
}
$visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
foreach ($visibleElements as $visibleElement) {
$this->fileElementMapper->delete($visibleElement);
}
$this->sequentialSigningService->reorderAfterDeletion($fileId, $deletedOrder);
} catch (\Throwable) {
}
}
public function deleteRequestSignature(array $data): void {
if (!empty($data['uuid'])) {
$signatures = $this->signRequestMapper->getByFileUuid($data['uuid']);
$fileData = $this->fileMapper->getByUuid($data['uuid']);
} elseif (!empty($data['file']['fileId'])) {
$signatures = $this->signRequestMapper->getByNodeId($data['file']['fileId']);
$fileData = $this->fileMapper->getByFileId($data['file']['fileId']);
} else {
throw new \Exception($this->l10n->t('Please provide either UUID or File object'));
}
foreach ($signatures as $signRequest) {
$this->signRequestMapper->delete($signRequest);
}
$this->fileMapper->delete($fileData);
$this->fileElementService->deleteVisibleElements($fileData->getId());
}
}