Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a74f446
feat: add DocMDP database schema
vitormattos Dec 6, 2025
d063990
feat: add docmdpLevel property to SignRequest entity
vitormattos Dec 6, 2025
7e81d9a
feat: add DocMdpConfigService for admin configuration
vitormattos Dec 6, 2025
57f5ba4
feat: integrate DocMDP certification level in JSignPdfHandler
vitormattos Dec 6, 2025
6bc4916
feat: add DocMDP configuration API endpoint
vitormattos Dec 6, 2025
78ddc27
feat: provide DocMDP config to admin settings initial state
vitormattos Dec 6, 2025
c7632ce
feat: add DocMDP admin settings UI component
vitormattos Dec 6, 2025
330bd8b
feat: expose DocMDP data in file validation response
vitormattos Dec 6, 2025
b8599ae
docs: fix OpenAPI documentation for setDocMdpConfig endpoint
vitormattos Dec 6, 2025
f0d08d2
chore: update OpenAPI specs for DocMDP endpoint
vitormattos Dec 6, 2025
553beaf
style: fix code style (phpcs)
vitormattos Dec 6, 2025
ed8f57a
test: fix AdminTest and FileServiceTest for DocMDP implementation
vitormattos Dec 6, 2025
c43de25
refactor: remove duplicate methods and improve DocMDP descriptions
vitormattos Dec 7, 2025
b5bd939
test: add PdfFixtureTrait for shared PDF test fixtures
vitormattos Dec 7, 2025
700ca49
feat: add DocMdpHandler::allowsAdditionalSignatures()
vitormattos Dec 7, 2025
accb4c3
refactor: extract validateFileContent() to TFile trait
vitormattos Dec 7, 2025
d1f7433
feat: inject DocMdpHandler into FileService
vitormattos Dec 7, 2025
fcd1212
test: fix FileServiceTest constructor and validation assertions
vitormattos Dec 7, 2025
29f886d
test: consolidate DocMdpHandlerTest PDF fixtures
vitormattos Dec 7, 2025
06af63e
feat: inject DocMdpHandler into RequestSignatureService
vitormattos Dec 7, 2025
c7f968e
feat: add DocMDP settings UI and validation display
vitormattos Dec 7, 2025
95aa222
style: fix phpcs import order in DocMdpHandlerTest
vitormattos Dec 7, 2025
9d205d4
test: improve testValidateDocMdpAllowsSignatures test structure
vitormattos Dec 8, 2025
edf339e
refactor: extract getLibreSignFileAsResource method to reduce coupling
vitormattos Dec 8, 2025
98d3eb4
feat: add DocMDP validation handler for PDF signature permissions
vitormattos Dec 8, 2025
6190c23
test: add comprehensive tests for DocMdpHandler
vitormattos Dec 8, 2025
e0e6415
fix: cs
vitormattos Dec 8, 2025
2579d3c
fix: add error handling for fopen failure in getLibreSignFileAsResource
vitormattos Dec 8, 2025
9a003f6
chore: add pending fixture file
vitormattos Dec 8, 2025
b6b2afe
fix: add missing DocMdpHandler mock in RequestSignatureServiceTest
vitormattos Dec 8, 2025
a6c5ade
chore: add real_jsignpdf_level1.pdf to REUSE.toml
vitormattos Dec 8, 2025
ee2eac6
Update error message: use more generic phrase for PDF resource creati…
vitormattos Dec 8, 2025
8617b51
Use php://memory instead of php://temp for PDF resource creation (in-…
vitormattos Dec 8, 2025
ce4041e
Use php://memory instead of php://temp in SignFileServiceTest for in-…
vitormattos Dec 8, 2025
aefd42b
chore: use a most generic text
vitormattos Dec 8, 2025
c2be98d
Update DocMdpLevel labels and descriptions for clarity and user-frien…
vitormattos Dec 8, 2025
1bc5e46
fix: update logic in SignFileService.php\n\nChanged implementation at…
vitormattos Dec 8, 2025
c2f7c8d
fix: update logic in TFile.php
vitormattos Dec 8, 2025
6ca0cbf
fix: use generic error messages for DocMDP configuration\n\nChanged e…
vitormattos Dec 8, 2025
119cf32
chore: code style fixes in DocMdpLevel.php
vitormattos Dec 8, 2025
7f3ec66
test: remove exception message validation from DocMDP tests
vitormattos Dec 8, 2025
5ae0683
docs: add TRANSLATORS comment explaining DocMDP for translators
vitormattos Dec 8, 2025
738c5cc
test: update DocMDP level 1 exception test to only check exception type
vitormattos Dec 8, 2025
b5b7574
test: refactor DocMDP level 1 exception test to use getService helper
vitormattos Dec 8, 2025
1a64f45
chore: add pending changes after apply backport
vitormattos Dec 8, 2025
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
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ path = [
"src/types/openapi/openapi.ts",
"tests/php/Unit/Handler/mock/cert.json",
"tests/php/fixtures/cfssl/newcert-with-success.json",
"tests/php/fixtures/real_jsignpdf_level1.pdf",
"tests/php/fixtures/small_valid-signed.pdf",
"tests/php/fixtures/small_valid.pdf",
"tests/integration/composer.json",
Expand Down
40 changes: 40 additions & 0 deletions lib/Controller/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@

use DateTimeInterface;
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
use OCA\Libresign\Helper\ConfigureCheckHelper;
use OCA\Libresign\ResponseDefinitions;
use OCA\Libresign\Service\Certificate\ValidateService;
use OCA\Libresign\Service\CertificatePolicyService;
use OCA\Libresign\Service\DocMdpConfigService;
use OCA\Libresign\Service\FooterService;
use OCA\Libresign\Service\Install\ConfigureCheckService;
use OCA\Libresign\Service\Install\InstallService;
Expand Down Expand Up @@ -64,6 +66,7 @@ public function __construct(
private ValidateService $validateService,
private ReminderService $reminderService,
private FooterService $footerService,
private DocMdpConfigService $docMdpConfigService,
) {
parent::__construct(Application::APP_ID, $request);
$this->eventSource = $this->eventSourceFactory->create();
Expand Down Expand Up @@ -857,4 +860,41 @@ public function footerTemplatePreviewPdf(string $template = '', int $width = 595
], Http::STATUS_BAD_REQUEST);
}
}

/**
* Set DocMDP configuration
*
* @param bool $enabled Enable or disable DocMDP certification
* @param int $defaultLevel Default DocMDP level (0-3): 0=none, 1=no changes, 2=form fill, 3=form fill + annotations
* @return DataResponse<Http::STATUS_OK, array{message: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Configuration saved successfully
* 400: Invalid DocMDP level provided
* 500: Internal server error
*/
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/docmdp/config', requirements: ['apiVersion' => '(v1)'])]
public function setDocMdpConfig(bool $enabled, int $defaultLevel): DataResponse {
try {
$this->docMdpConfigService->setEnabled($enabled);

if ($enabled) {
$level = DocMdpLevel::tryFrom($defaultLevel);
if ($level === null) {
return new DataResponse([
'error' => $this->l10n->t('Invalid DocMDP level'),
], Http::STATUS_BAD_REQUEST);
}

$this->docMdpConfigService->setLevel($level);
}

return new DataResponse([
'message' => $this->l10n->t('Settings saved'),
]);
} catch (\Exception $e) {
return new DataResponse([
'error' => $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}
4 changes: 4 additions & 0 deletions lib/Db/SignRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
* @method string getDisplayName()
* @method void setMetadata(array $metadata)
* @method ?array getMetadata()
* @method void setDocmdpLevel(int $docmdpLevel)
* @method int getDocmdpLevel()
*/
class SignRequest extends Entity {
protected ?int $fileId = null;
Expand All @@ -40,6 +42,7 @@ class SignRequest extends Entity {
protected ?\DateTime $signed = null;
protected ?string $signedHash = null;
protected ?array $metadata = null;
protected int $docmdpLevel = 0;
public function __construct() {
$this->addType('id', Types::INTEGER);
$this->addType('fileId', Types::INTEGER);
Expand All @@ -50,5 +53,6 @@ public function __construct() {
$this->addType('signed', Types::DATETIME);
$this->addType('signedHash', Types::STRING);
$this->addType('metadata', Types::JSON);
$this->addType('docmdpLevel', Types::SMALLINT);
}
}
12 changes: 6 additions & 6 deletions lib/Enum/DocMdpLevel.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ public function getLabel(IL10N $l10n): string {
return match($this) {
self::NOT_CERTIFIED => $l10n->t('No certification'),
self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('No changes allowed'),
self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling and additional signatures'),
self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling, annotations and additional signatures'),
self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling allowed'),
self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling and commenting allowed'),
};
}

public function getDescription(IL10N $l10n): string {
return match($this) {
self::NOT_CERTIFIED => $l10n->t('Document is not certified. No restrictions on modifications.'),
self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('No changes allowed. Additional approval signatures are prohibited.'),
self::CERTIFIED_FORM_FILLING => $l10n->t('Form filling allowed. Additional approval signatures are allowed.'),
self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('Form filling and annotations allowed. Additional approval signatures are allowed.'),
self::NOT_CERTIFIED => $l10n->t('The document is not certified; edits and new signatures are allowed, but any change will mark previous signatures as modified.'),
self::CERTIFIED_NO_CHANGES_ALLOWED => $l10n->t('After the first signature, no further edits or signatures are allowed; any change invalidates the certification.'),
self::CERTIFIED_FORM_FILLING => $l10n->t('After the first signature, only form filling and additional signatures are allowed; other changes invalidate the certification.'),
self::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $l10n->t('After the first signature, form filling, comments, and additional signatures are allowed; other changes invalidate the certification.'),
};
}
}
23 changes: 12 additions & 11 deletions lib/Handler/DocMdpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public function __construct(
) {
}

public function allowsAdditionalSignatures($resource): bool {
$docmdpLevel = $this->extractDocMdpLevel($resource);

return $docmdpLevel !== DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED;
}

public function extractDocMdpData($resource): array {
if (!is_resource($resource)) {
return [];
Expand Down Expand Up @@ -160,15 +166,15 @@ private function extractPValueFromIndirectReference(string $content, string $ind
* @return array Array of objects with keys: objNum, dict, position
*/
private function parsePdfObjects(string $content): array {
if (!preg_match_all('/(\d+)\s+\d+\s+obj\s*(<<.*?>>)\s*endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
if (!preg_match_all('/(\d+)\s+\d+\s+obj(.*?)endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
return [];
}

$objects = [];
foreach ($matches as $match) {
$objects[] = [
'objNum' => $match[1][0],
'dict' => $match[2][0],
'dict' => trim($match[2][0]),
'position' => $match[2][1],
];
}
Expand Down Expand Up @@ -452,16 +458,11 @@ private function validateSignatureDictionary(string $content): bool {
return $this->validateDictionaryEntries($sigDict);
}

/**
* Find signature dictionary with /Reference entry
*
* @param array $objects Parsed PDF objects
* @return string|null Dictionary content or null
*/
private function findSignatureDictionary(array $objects): ?string {
foreach ($objects as $obj) {
if (preg_match('/\/Reference\s*\[/', $obj['dict'])) {
return $obj['dict'];
$dict = $obj['dict'];
if (preg_match('/\/Type\s*\/Sig\b/', $dict) && preg_match('/\/Reference\s*\[/', $dict)) {
return $dict;
}
}
return null;
Expand All @@ -474,7 +475,7 @@ private function findSignatureDictionary(array $objects): ?string {
* @return bool True if all required entries are valid
*/
private function validateDictionaryEntries(string $dict): bool {
if (preg_match('/\/Type\s*\/(\w+)/', $dict, $typeMatch) && $typeMatch[1] !== 'Sig') {
if (!preg_match('/\/Type\s*\/Sig\b/', $dict)) {
return false;
}

Expand Down
18 changes: 18 additions & 0 deletions lib/Handler/SignEngine/JSignPdfHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
use OCA\Libresign\Helper\JavaHelper;
use OCA\Libresign\Service\DocMdpConfigService;
use OCA\Libresign\Service\Install\InstallService;
use OCA\Libresign\Service\SignatureBackgroundService;
use OCA\Libresign\Service\SignatureTextService;
Expand All @@ -40,6 +41,7 @@ public function __construct(
private SignatureBackgroundService $signatureBackgroundService,
protected CertificateEngineFactory $certificateEngineFactory,
protected JavaHelper $javaHelper,
private DocMdpConfigService $docMdpConfigService,
) {
}

Expand Down Expand Up @@ -86,6 +88,11 @@ public function getJSignParam(): JSignParam {
. ' -Duser.home=' . escapeshellarg($this->getHome()) . ' '
);
}

$certificationLevel = $this->getCertificationLevel();
if ($certificationLevel !== null) {
$this->jSignParam->setJSignParameters(' -cl ' . $certificationLevel);
}
}
return $this->jSignParam;
}
Expand Down Expand Up @@ -147,6 +154,15 @@ private function getHashAlgorithm(): string {
return 'SHA256';
}

private function getCertificationLevel(): ?string {
if (!$this->docMdpConfigService->isEnabled()) {
return null;
}

return $this->docMdpConfigService->getLevel()->name;
}

#[\Override]
public function sign(): File {
$this->beforeSign();

Expand All @@ -155,6 +171,7 @@ public function sign(): File {
return $this->getInputFile();
}

#[\Override]
public function getSignedContent(): string {
$param = $this->getJSignParam()
->setCertificate($this->getCertificate())
Expand Down Expand Up @@ -280,6 +297,7 @@ private function getScaleFactor(float $width): float {
}


#[\Override]
public function readCertificate(): array {
$result = $this->certificateEngineFactory
->getEngine()
Expand Down
30 changes: 21 additions & 9 deletions lib/Migration/Version14000Date20251206120000.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,29 @@ class Version14000Date20251206120000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('libresign_file');

if (!$table->hasColumn('modification_status')) {
$table->addColumn('modification_status', Types::SMALLINT, [
'notnull' => true,
'default' => 0,
'comment' => 'DocMDP modification detection status: 0=unchecked, 1=unmodified, 2=allowed, 3=violation',
]);
return $schema;
if ($schema->hasTable('libresign_sign_request')) {
$tableSignRequest = $schema->getTable('libresign_sign_request');
if (!$tableSignRequest->hasColumn('docmdp_level')) {
$tableSignRequest->addColumn('docmdp_level', Types::SMALLINT, [
'notnull' => true,
'default' => 0,
'comment' => 'DocMDP permission level: 0=none, 1=no changes, 2=form fill, 3=form fill + annotations',
]);
}
}

return null;
if ($schema->hasTable('libresign_file')) {
$tableFile = $schema->getTable('libresign_file');
if (!$tableFile->hasColumn('modification_status')) {
$tableFile->addColumn('modification_status', Types::SMALLINT, [
'notnull' => true,
'default' => 0,
'comment' => 'DocMDP modification detection status: 0=unchecked, 1=unmodified, 2=allowed, 3=violation',
]);
}
}

return $schema;
}
}
63 changes: 63 additions & 0 deletions lib/Service/DocMdpConfigService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service;

use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Enum\DocMdpLevel;
use OCP\IAppConfig;
use OCP\IL10N;

class DocMdpConfigService {
private const CONFIG_KEY_LEVEL = 'docmdp_level';

public function __construct(
private IAppConfig $appConfig,
private IL10N $l10n,
) {
}

public function isEnabled(): bool {
return $this->appConfig->hasKey(Application::APP_ID, self::CONFIG_KEY_LEVEL);
}

public function setEnabled(bool $enabled): void {
if (!$enabled) {
$this->appConfig->deleteKey(Application::APP_ID, self::CONFIG_KEY_LEVEL);
}
}

public function getLevel(): DocMdpLevel {
$level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, DocMdpLevel::NOT_CERTIFIED->value);
return DocMdpLevel::from($level);
}

public function setLevel(DocMdpLevel $level): void {
$this->appConfig->setValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, $level->value);
}

public function getConfig(): array {
return [
'enabled' => $this->isEnabled(),
'defaultLevel' => $this->getLevel()->value,
'availableLevels' => $this->getAvailableLevels(),
];
}

private function getAvailableLevels(): array {
return array_map(
fn (DocMdpLevel $level) => [
'value' => $level->value,
'label' => $level->getLabel($this->l10n),
'description' => $level->getDescription($this->l10n),
],
DocMdpLevel::cases()
);
}
}
Loading
Loading