Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions lib/Db/FileMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,23 @@ public function getParentEnvelope(int $fileId): ?File {
return $this->getById($file->getParentFileId());
}

/**
* The envelope of a document, or null when it has none or the envelope no
* longer exists.
*/
public function findEnvelope(File $file): ?File {
$envelopeId = $file->getParentFileId();
if ($envelopeId === null) {
return null;
}

try {
return $this->getById($envelopeId);
} catch (DoesNotExistException) {
return null;
}
}

public function countChildrenFiles(int $envelopeId): int {
$qb = $this->db->getQueryBuilder();

Expand Down
25 changes: 18 additions & 7 deletions lib/Handler/FooterHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class FooterHandler {
private QrCode $qrCode;
/** @var array<string, mixed> */
private array $requestPolicyOverrides = [];
private ?string $policyValue = null;
private ?string $templateOverride = null;
private ?bool $writeQrcodeOnFooterOverride = null;
private const int MIN_QRCODE_SIZE = 100;
Expand Down Expand Up @@ -125,6 +126,15 @@ public function setTemplateVar(string $name, mixed $value): self {
/** @param array<string, mixed> $requestPolicyOverrides */
public function setRequestPolicyOverrides(array $requestPolicyOverrides): self {
$this->requestPolicyOverrides = $requestPolicyOverrides;
$this->policyValue = null;
return $this;
}

/**
* Render with the exact policy frozen on the request, without re-resolving it.
*/
public function setPolicyValue(?string $policyValue): self {
$this->policyValue = $policyValue;
return $this;
}

Expand All @@ -139,14 +149,12 @@ public function setTemplateOverride(?string $template): self {
}

public function getEffectiveFooterPolicyAsJson(): string {
return (string)$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue();
return (string)$this->getPolicyValue();
}

/** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */
private function resolveFooterPolicy(): array {
return FooterPolicyValue::normalize(
$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue()
);
return FooterPolicyValue::normalize($this->getPolicyValue());
}

private function prepareTemplateVars(bool $forceEnabled = false): array {
Expand Down Expand Up @@ -256,8 +264,11 @@ public function getTemplateVariablesMetadata(): array {
}

private function isFooterEnabled(): bool {
return FooterPolicyValue::isEnabled(
$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue()
);
return FooterPolicyValue::isEnabled($this->getPolicyValue());
}

private function getPolicyValue(): mixed {
return $this->policyValue
?? $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue();
}
}
14 changes: 14 additions & 0 deletions lib/Service/FileStatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ public function markPolicySnapshotFrozen(FileEntity $file, int $newStatus): void
$file->setMetadata($metadata);
}

/**
* A request remains frozen if it returns to draft; documents inherit
* their envelope's freeze state. Legacy sent requests have no marker.
*/
public static function isPolicySnapshotFrozen(FileEntity $file, ?FileEntity $envelope = null): bool {
return self::hasFrozenPolicySnapshot($file)
|| ($envelope !== null && self::hasFrozenPolicySnapshot($envelope));
}

private static function hasFrozenPolicySnapshot(FileEntity $file): bool {
return isset(($file->getMetadata() ?? [])[self::POLICY_SNAPSHOT_FROZEN_AT])
|| $file->getStatus() >= FileStatus::ABLE_TO_SIGN->value;
}

private function touchStatusChangedAt(FileEntity $file): void {
$metadata = $file->getMetadata() ?? [];
$metadata['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM);
Expand Down
11 changes: 11 additions & 0 deletions lib/Service/Policy/AbstractFilePolicyApplier.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
namespace OCA\Libresign\Service\Policy;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Service\FileService;
use OCA\Libresign\Service\FileStatusService;
use OCA\Libresign\Service\Policy\Contract\IFilePolicyApplier;
use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
use OCP\IL10N;
Expand All @@ -20,6 +22,7 @@ public function __construct(
protected readonly PolicyService $policyService,
protected readonly FileService $fileService,
protected readonly ?IL10N $l10n = null,
protected readonly ?FileMapper $snapshotFileMapper = null,
) {
}

Expand Down Expand Up @@ -76,6 +79,14 @@ protected function assertRequestOverrideAllowed(array $requestOverrides, Resolve
throw new LibresignException($translatedMessage, 422);
}

/**
* The request freezes on first send, including legacy sent requests and
* documents of a frozen envelope.
*/
protected function isPolicySnapshotFrozen(FileEntity $file): bool {
return FileStatusService::isPolicySnapshotFrozen($file, $this->snapshotFileMapper?->findEnvelope($file));
}

protected function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy, mixed $effectiveValue = null): void {
$metadata = $file->getMetadata() ?? [];
$policySnapshot = $metadata['policy_snapshot'] ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
namespace OCA\Libresign\Service\Policy\Provider\Footer\FilePolicy;

use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Service\Policy\AbstractFilePolicyApplier;
use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy;
use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
use OCP\AppFramework\Http;
use OCP\IUser;

class FooterFilePolicyApplier extends AbstractFilePolicyApplier {
Expand All @@ -31,6 +34,11 @@ public function apply(FileEntity $file, array $data): void {
#[\Override]
public function sync(FileEntity $file, array $data): void {
$requestOverrides = $this->getOverrides($data);
if ($this->isPolicySnapshotFrozen($file)) {
$this->assertFrozenFooterIsKept($file, $requestOverrides);
return;
}

$activeContext = $this->extractActiveContext($data);
$resolvedPolicy = $activeContext === null
? $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides)
Expand All @@ -47,7 +55,7 @@ public function sync(FileEntity $file, array $data): void {

#[\Override]
public function supportsCoreFlowSync(): bool {
return false;
return true;
}

/** @return array<string, string> */
Expand All @@ -59,4 +67,29 @@ private function getOverrides(array $data): array {
private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void {
$this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Footer template override is blocked by %s.');
}

/**
* A frozen request keeps the footer it was sent with. A client may still send
* that same footer along an unrelated update, which changes nothing.
*
* @param array<string, mixed> $requestOverrides
*/
private function assertFrozenFooterIsKept(FileEntity $file, array $requestOverrides): void {
$submittedFooter = $requestOverrides[FooterPolicy::KEY] ?? null;
if ($submittedFooter === null) {
return;
}

$storedFooter = FooterPolicyValue::getSnapshotEffectiveValue($file);
if ($storedFooter !== null && FooterPolicyValue::normalize($submittedFooter) === FooterPolicyValue::normalize($storedFooter)) {
return;
}

throw new LibresignException(
// TRANSLATORS Error shown when someone tries to change the footer stamped on the signed document after the signing flow already started.
$this->l10n?->t('The footer cannot be changed after the signing flow has started.')
?? 'The footer cannot be changed after the signing flow has started.',
Http::STATUS_UNPROCESSABLE_ENTITY,
);
}
}
10 changes: 7 additions & 3 deletions lib/Service/Policy/Provider/Footer/FooterPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ public function get(string|\BackedEnum $policyKey): IPolicyDefinition {
}

if (!self::canManageTechnicalFooterSettings($context)) {
$normalized = FooterPolicyValue::normalize($decoded);
if ($normalized['validationSite'] !== '') {
// An inherited administrator-configured URL is not a request override.
$submittedFooter = $context->getRequestOverrides()[self::KEY] ?? null;
if ($submittedFooter !== null && FooterPolicyValue::normalize($submittedFooter)['validationSite'] !== '') {
throw new \InvalidArgumentException('Validation URL override is not allowed for this actor');
}
}
},
persistenceValidator: static function (mixed $value, PolicyContext $_context): void {
persistenceValidator: static function (mixed $value, PolicyContext $context): void {
$footerPolicy = FooterPolicyValue::normalize($value);
if (!self::canManageTechnicalFooterSettings($context) && $footerPolicy['validationSite'] !== '') {
throw new \InvalidArgumentException('Validation URL override is not allowed for this actor');
}
// Allow a legacy template to be disabled or replaced without executing it.
if ($footerPolicy['enabled'] && $footerPolicy['customizeFooterTemplate'] && $footerPolicy['footerTemplate'] !== '') {
SandboxedTemplateRenderer::validate($footerPolicy['footerTemplate']);
Expand Down
17 changes: 17 additions & 0 deletions lib/Service/Policy/Provider/Footer/FooterPolicyValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

namespace OCA\Libresign\Service\Policy\Provider\Footer;

use OCA\Libresign\Db\File as FileEntity;

final class FooterPolicyValue {
/** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */
public static function defaults(string $defaultTemplate = ''): array {
Expand Down Expand Up @@ -78,6 +80,21 @@ public static function isQrCodeEnabled(mixed $rawValue): bool {
return $normalized['enabled'] && $normalized['writeQrcodeOnFooter'];
}

public static function getSnapshotEffectiveValue(FileEntity $file): ?string {
$metadata = $file->getMetadata() ?? [];
$policySnapshot = $metadata['policy_snapshot'] ?? null;
if (!is_array($policySnapshot)) {
return null;
}

$effectiveValue = $policySnapshot[FooterPolicy::KEY]['effectiveValue'] ?? null;
if (!is_string($effectiveValue) || trim($effectiveValue) === '') {
return null;
}

return $effectiveValue;
}

private static function toBool(mixed $rawValue): bool {
if (is_bool($rawValue)) {
return $rawValue;
Expand Down
44 changes: 17 additions & 27 deletions lib/Service/SignFileService.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ class SignFileService {
private string $friendlyName = '';
private ?IUser $user = null;
private ?SignEngineHandler $engine = null;
// The stamp and validation URL must share one resolved footer policy.
private ?FileEntity $footerPolicyFile = null;
private string $footerPolicyValue = '';

public function __construct(
protected IL10N $l10n,
Expand Down Expand Up @@ -987,9 +990,7 @@ private function buildBaseSignatureParams(array $certificateData): array {
}

private function buildValidationUrl(string $uuid): string {
$footerPolicy = FooterPolicyValue::normalize(
$this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue()
);
$footerPolicy = FooterPolicyValue::normalize($this->getFooterPolicyValue());
$validationSite = trim($footerPolicy['validationSite']);
if ($validationSite !== '') {
return rtrim($validationSite, '/') . '/' . $uuid;
Expand Down Expand Up @@ -1392,7 +1393,7 @@ protected function getPdfToSign(File $originalFile): File {
return $this->createSignedFile($originalFile, $originalContent);
}
$metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
$this->footerHandler->setRequestPolicyOverrides($this->resolveFooterPolicyRequestOverridesFromFileMetadata());
$this->footerHandler->setPolicyValue($this->getFooterPolicyValue());
$footer = $this->footerHandler
->setTemplateVar('uuid', $this->libreSignFile->getUuid())
->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
Expand Down Expand Up @@ -1425,31 +1426,20 @@ protected function getPdfToSign(File $originalFile): File {
return $this->createSignedFile($originalFile, $pdfContent);
}

/** @return array<string, string> */
private function resolveFooterPolicyRequestOverridesFromFileMetadata(): array {
$metadata = $this->libreSignFile->getMetadata();
if (!is_array($metadata)) {
return [];
}

$policySnapshot = $metadata['policy_snapshot'] ?? null;
if (!is_array($policySnapshot)) {
return [];
}

$footerSnapshot = $policySnapshot[FooterPolicy::KEY] ?? null;
if (!is_array($footerSnapshot)) {
return [];
}

$effectiveValue = $footerSnapshot['effectiveValue'] ?? null;
if (!is_string($effectiveValue) || trim($effectiveValue) === '') {
return [];
/**
* Once frozen, the request signs with the footer it stores: it was resolved
* and validated when stored, so later policy changes do not apply to it.
*/
private function getFooterPolicyValue(): string {
if ($this->footerPolicyFile !== $this->libreSignFile) {
$storedFooter = FooterPolicyValue::getSnapshotEffectiveValue($this->libreSignFile);
$this->footerPolicyValue = $storedFooter !== null && FileStatusService::isPolicySnapshotFrozen($this->libreSignFile, $this->fileMapper->findEnvelope($this->libreSignFile))
? $storedFooter
: (string)$this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue();
$this->footerPolicyFile = $this->libreSignFile;
}

return [
FooterPolicy::KEY => $effectiveValue,
];
return $this->footerPolicyValue;
}

protected function getSignedFile(): ?File {
Expand Down
13 changes: 13 additions & 0 deletions src/components/RightSidebar/RequestSignatureTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,17 @@ const footerPolicy = computed(() => policiesStore.getPolicy('add_footer'))
const canChooseSigningOrderAtRequestLevel = computed(() => policiesStore.canUseRequestOverride('signature_flow'))
const canChooseFooterTemplateAtRequestLevel = computed(() => policiesStore.canUseRequestOverride('add_footer'))
const isAdminFlowForced = computed(() => !canChooseSigningOrderAtRequestLevel.value)
// The snapshot stays frozen even when a request returns to draft. Legacy
// requests that were already sent may not have the freeze timestamp.
const isPolicySnapshotFrozen = computed(() => {
const file = filesStore.getFile()
if (!file) {
return false
}
const metadata = file.metadata as Record<string, unknown> | null | undefined
return (file.status ?? FILE_STATUS.DRAFT) >= FILE_STATUS.ABLE_TO_SIGN
|| typeof metadata?.policy_snapshot_frozen_at === 'string'
})

watch(() => policiesStore.getEffectiveValue('identify_methods'), (value) => {
methods.value = normalizeIdentifyMethodsPolicy(value)
Expand Down Expand Up @@ -513,6 +524,7 @@ const showFooterTemplateSelector = computed(() => {
&& isCurrentFileDetailed.value
&& filesStore.canSave()
&& canChooseFooterTemplateAtRequestLevel.value
&& !isPolicySnapshotFrozen.value
&& footerTemplateSourceOptions.value.length > 1
})
const showRememberFooterTemplate = computed(() => showFooterTemplateSelector.value && canSaveFooterPreference.value)
Expand Down Expand Up @@ -552,6 +564,7 @@ function getFooterPolicyPayloadForSave(): string | null {
canChooseFooterTemplateAtRequestLevel.value,
footerTemplateSourceOptions.value,
selectedFooterTemplateSource.value,
isPolicySnapshotFrozen.value,
)
}

Expand Down
Loading
Loading