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
68a3e46
feat: add DocMDP database schema
vitormattos Dec 6, 2025
91b1f74
feat: add docmdpLevel property to SignRequest entity
vitormattos Dec 6, 2025
14e1677
feat: add DocMdpConfigService for admin configuration
vitormattos Dec 6, 2025
41f1fd8
feat: integrate DocMDP certification level in JSignPdfHandler
vitormattos Dec 6, 2025
2727f1b
feat: add DocMDP configuration API endpoint
vitormattos Dec 6, 2025
c09efa3
feat: provide DocMDP config to admin settings initial state
vitormattos Dec 6, 2025
f9f4b4d
feat: add DocMDP admin settings UI component
vitormattos Dec 6, 2025
8512505
feat: expose DocMDP data in file validation response
vitormattos Dec 6, 2025
fdf5f78
docs: fix OpenAPI documentation for setDocMdpConfig endpoint
vitormattos Dec 6, 2025
55bcf47
chore: update OpenAPI specs for DocMDP endpoint
vitormattos Dec 6, 2025
68a2fbb
style: fix code style (phpcs)
vitormattos Dec 6, 2025
1ab326d
test: fix AdminTest and FileServiceTest for DocMDP implementation
vitormattos Dec 6, 2025
aeb36b8
refactor: remove duplicate methods and improve DocMDP descriptions
vitormattos Dec 7, 2025
1f7a9d5
test: add PdfFixtureTrait for shared PDF test fixtures
vitormattos Dec 7, 2025
606ab63
feat: add DocMdpHandler::allowsAdditionalSignatures()
vitormattos Dec 7, 2025
dede2fa
refactor: extract validateFileContent() to TFile trait
vitormattos Dec 7, 2025
c54338f
feat: inject DocMdpHandler into FileService
vitormattos Dec 7, 2025
87bf07b
test: fix FileServiceTest constructor and validation assertions
vitormattos Dec 7, 2025
da56578
test: consolidate DocMdpHandlerTest PDF fixtures
vitormattos Dec 7, 2025
053eca7
feat: inject DocMdpHandler into RequestSignatureService
vitormattos Dec 7, 2025
ba777c0
feat: add DocMDP settings UI and validation display
vitormattos Dec 7, 2025
ebf3f0e
style: fix phpcs import order in DocMdpHandlerTest
vitormattos Dec 7, 2025
574d95d
test: improve testValidateDocMdpAllowsSignatures test structure
vitormattos Dec 8, 2025
c4996fd
refactor: extract getLibreSignFileAsResource method to reduce coupling
vitormattos Dec 8, 2025
aa26856
feat: add DocMDP validation handler for PDF signature permissions
vitormattos Dec 8, 2025
82b1472
test: add comprehensive tests for DocMdpHandler
vitormattos Dec 8, 2025
0136def
fix: cs
vitormattos Dec 8, 2025
4b46882
fix: add error handling for fopen failure in getLibreSignFileAsResource
vitormattos Dec 8, 2025
767f806
chore: add pending fixture file
vitormattos Dec 8, 2025
c2e09ac
fix: add missing DocMdpHandler mock in RequestSignatureServiceTest
vitormattos Dec 8, 2025
abb51a8
chore: add real_jsignpdf_level1.pdf to REUSE.toml
vitormattos Dec 8, 2025
f64f0fe
Update error message: use more generic phrase for PDF resource creati…
vitormattos Dec 8, 2025
f1cbcee
Use php://memory instead of php://temp for PDF resource creation (in-…
vitormattos Dec 8, 2025
e072479
Use php://memory instead of php://temp in SignFileServiceTest for in-…
vitormattos Dec 8, 2025
b4a3bd7
chore: use a most generic text
vitormattos Dec 8, 2025
96a788a
Update DocMdpLevel labels and descriptions for clarity and user-frien…
vitormattos Dec 8, 2025
1e4bee4
fix: update logic in SignFileService.php\n\nChanged implementation at…
vitormattos Dec 8, 2025
4181452
fix: update logic in TFile.php
vitormattos Dec 8, 2025
f8590b5
fix: use generic error messages for DocMDP configuration\n\nChanged e…
vitormattos Dec 8, 2025
4ea9d72
chore: code style fixes in DocMdpLevel.php
vitormattos Dec 8, 2025
a903c4d
test: remove exception message validation from DocMDP tests
vitormattos Dec 8, 2025
a3bf899
docs: add TRANSLATORS comment explaining DocMDP for translators
vitormattos Dec 8, 2025
730afd1
test: update DocMDP level 1 exception test to only check exception type
vitormattos Dec 8, 2025
61c0fca
test: refactor DocMDP level 1 exception test to use getService helper
vitormattos Dec 8, 2025
f705ae0
chore: add pending change after 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
2 changes: 1 addition & 1 deletion 3rdparty
Submodule 3rdparty updated 1 files
+37 −0 scoper.inc.php
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