├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── dependabot.yml
└── workflows
│ └── main_action.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── composer.json
├── composer.lock
├── phpunit.xml
├── src
├── Commands
│ ├── SignPdfCommand.php
│ └── ValidatePdfSignatureCommand.php
├── Entities
│ ├── BaseEntity.php
│ ├── CertificateProcessed.php
│ ├── EncryptedCertificate.php
│ └── ValidatedSignedPDF.php
├── Exceptions
│ ├── CertificateOutputNotFoundException.php
│ ├── FileNotFoundException.php
│ ├── HasNoSignatureOrInvalidPkcs7Exception.php
│ ├── InvalidCertificateContentException.php
│ ├── InvalidImageDriverException.php
│ ├── InvalidPFXException.php
│ ├── InvalidPdfFileException.php
│ ├── InvalidPdfSignModeTypeException.php
│ ├── InvalidX509PrivateKeyException.php
│ └── ProcessRunTimeException.php
├── Helpers
│ └── helpers.php
├── LaravelA1PdfSignServiceProvider.php
├── Resources
│ ├── font
│ │ └── Roboto-Medium.ttf
│ └── img
│ │ ├── sign-seal.pdn
│ │ └── sign-seal.png
├── Sign
│ ├── ManageCert.php
│ ├── SealImage.php
│ ├── SignaturePdf.php
│ └── ValidatePdfSignature.php
└── Temp
│ └── .gitkeep
└── tests
├── CommandsTest.php
├── HelpersTest.php
├── ManageCertTest.php
├── Resources
├── .gitignore
├── CertInfoExamples
│ ├── with-comma.json
│ ├── with-comma.txt
│ ├── without-comma.json
│ └── without-comma.txt
└── test.pdf
├── SealImageTest.php
├── Sign
└── ValidatePdfSignature
│ └── ProcessDataInfoFunctionTest.php
└── TestCase.php
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug, help wanted
6 | assignees: lsnepomuceno
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Versions involved:**
27 | - Laravel: [e.g. 9.21]
28 | - Laravel A1 Pdf Sign [e.g. 0.0.18]
29 | - Composer [e.g. 2.1]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: documentation, enhancement
6 | assignees: lsnepomuceno
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
22 | **Versions involved:**
23 | - Laravel: [e.g. 9.21]
24 | - Laravel A1 Pdf Sign [e.g. 0.0.18]
25 | - Composer [e.g. 2.1]
26 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "composer"
4 | directory: "/"
5 | schedule:
6 | interval: "monthly"
7 |
--------------------------------------------------------------------------------
/.github/workflows/main_action.yml:
--------------------------------------------------------------------------------
1 | name: A1 Pdf Sign Tests
2 |
3 | on:
4 | pull_request:
5 | branches: [ main, dev, v1.x-dev ]
6 |
7 | permissions:
8 | contents: read
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | include:
18 | # Laravel 9 supports PHP 8.1–8.2
19 | - php: 8.1
20 | laravel: 9.*
21 | - php: 8.2
22 | laravel: 9.*
23 |
24 | # Laravel 10 supports PHP 8.1–8.3
25 | - php: 8.1
26 | laravel: 10.*
27 | - php: 8.2
28 | laravel: 10.*
29 | - php: 8.3
30 | laravel: 10.*
31 |
32 | # Laravel 11 supports PHP 8.2–8.4
33 | - php: 8.2
34 | laravel: 11.*
35 | - php: 8.3
36 | laravel: 11.*
37 | - php: 8.4
38 | laravel: 11.*
39 |
40 | # Laravel 12 supports PHP 8.2–8.4
41 | - php: 8.2
42 | laravel: 12.*
43 | - php: 8.3
44 | laravel: 12.*
45 | - php: 8.4
46 | laravel: 12.*
47 |
48 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }}
49 |
50 | steps:
51 | - name: Checkout
52 | uses: actions/checkout@v4
53 |
54 | - name: Setup PHP
55 | uses: shivammathur/setup-php@v2
56 | with:
57 | php-version: ${{ matrix.php }}
58 | extensions: mbstring, dom, fileinfo, openssl, json, imagick, swoole, sqlite3
59 | coverage: none
60 |
61 | - name: Validate PHP and Composer
62 | run: |
63 | php -v
64 | composer -V
65 | composer validate
66 |
67 | - name: Configure Laravel Version
68 | run: |
69 | composer require "illuminate/support:${{ matrix.laravel }}" \
70 | "illuminate/encryption:${{ matrix.laravel }}" \
71 | "illuminate/http:${{ matrix.laravel }}" \
72 | --no-interaction --no-update
73 |
74 | - name: Install dependencies
75 | run: |
76 | composer update --prefer-dist --no-interaction --no-progress
77 |
78 | - name: Run tests
79 | run: composer test
80 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.settings
2 | /.project
3 | /.buildpath
4 | /composer.phar
5 | /vendor
6 | /nbproject
7 | .vagrant
8 | Vagrantfile
9 | .idea
10 | .php_cs.cache
11 | composer.lock
12 | .phpunit.result.cache
13 | .phpunit.cache/
14 | *.bak
15 | demo.php
16 | *Zone.Identifier
17 | Temp
18 | *.pdf
19 | *.pfx
20 | node_modules
21 | dist
22 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | e-mail.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are **welcome** and will be fully **credited**.
4 |
5 | We accept contributions via Pull Requests on [Github](https://github.com/lsnepomuceno/laravel-a1-pdf-sign).
6 |
7 |
8 | ## Pull Requests
9 |
10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer).
11 |
12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests.
13 |
14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
15 |
16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option.
17 |
18 | - **Create feature branches** - Don't ask us to pull from your master branch.
19 |
20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
21 |
22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
23 |
24 |
25 | ## Running Tests
26 |
27 | ``` bash
28 | $ composer test
29 | ```
30 |
31 |
32 | **Happy coding**!
33 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Lucas Nepomuceno
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Sign PDF files with valid x509 certificate
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Reference |
29 |
30 |
31 |
32 | Laravel version |
33 | PHP version |
34 | Package version |
35 | Docs |
36 |
37 |
38 |
39 | ^8 ~8.54 |
40 | ^7.4 |
41 | ^0 ~0.0.11 |
42 | Official Doc |
43 |
44 |
45 |
46 | ^8.56+ |
47 | ^0.0.12 |
48 |
49 |
50 |
51 | ^9 |
52 | ^8.1 || ^8.2 |
53 | ^1 |
54 | Official Doc |
55 |
56 |
57 |
58 | ^10 |
59 | ^8.1 || ^8.2 || ^8.3 |
60 |
61 |
62 |
63 | ^11 || ^12 |
64 | ^8.2 || ^8.3 || ^8.4 |
65 |
66 |
67 |
68 |
69 | Project supported by JetBrains
70 | Special thanks to the team at JetBrains for supporting Open Source projects with licenses to use.
71 |
72 |
73 |
74 |
75 |
76 |
77 | Do you want to support this project?
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lsnepomuceno/laravel-a1-pdf-sign",
3 | "description": "Sign PDF files with valid x509 certificates",
4 | "license": "MIT",
5 | "type": "library",
6 | "homepage": "https://github.com/lsnepomuceno/laravel-a1-pdf-sign",
7 | "support": {
8 | "issues": "https://github.com/lsnepomuceno/laravel-a1-pdf-sign/issues",
9 | "source": "https://github.com/lsnepomuceno/laravel-a1-pdf-sign"
10 | },
11 | "keywords": [
12 | "a1",
13 | "sign pdf",
14 | "sign",
15 | "x509",
16 | "laravel",
17 | "certificate",
18 | "icp brasil"
19 | ],
20 | "authors": [
21 | {
22 | "name": "Lucas Nepomuceno",
23 | "email": "lsn.nepomuceno@gmail.com",
24 | "homepage": "https://github.com/lsnepomuceno"
25 | }
26 | ],
27 | "require": {
28 | "php": ">=8.1 <8.5",
29 | "ext-gd": "*",
30 | "ext-json": "*",
31 | "ext-fileinfo": "*",
32 | "ext-mbstring": "*",
33 | "ext-openssl": "*",
34 | "illuminate/support": "^9 || ^10 || ^11 || ^12",
35 | "illuminate/encryption": "^9 || ^10 || ^11 || ^12",
36 | "illuminate/http": "^9 || ^10 || ^11 || ^12",
37 | "tecnickcom/tc-lib-pdf": "^8",
38 | "tecnickcom/tcpdf": "^6",
39 | "setasign/fpdi": "^2.6",
40 | "symfony/process": "^6 || ^7",
41 | "intervention/image": "^3.11"
42 | },
43 | "autoload": {
44 | "psr-4": {
45 | "LSNepomuceno\\LaravelA1PdfSign\\": "./src"
46 | },
47 | "files": [
48 | "src/Helpers/helpers.php"
49 | ]
50 | },
51 | "autoload-dev": {
52 | "psr-4": {
53 | "LSNepomuceno\\LaravelA1PdfSign\\Tests\\": "./tests"
54 | }
55 | },
56 | "scripts": {
57 | "test": "vendor/bin/testbench package:test"
58 | },
59 | "extra": {
60 | "laravel": {
61 | "providers": [
62 | "LSNepomuceno\\LaravelA1PdfSign\\LaravelA1PdfSignServiceProvider"
63 | ]
64 | }
65 | },
66 | "suggest": {
67 | "ext-gd": "To use GD library based image processing.",
68 | "ext-imagick": "To use Imagick based image processing.",
69 | "lsnepomuceno/laravel-brazilian-ceps": "A package for querying zip codes for Brazilian addresses."
70 | },
71 | "require-dev": {
72 | "orchestra/testbench": "^7 || ^8 || ^9 || ^10",
73 | "nunomaduro/collision": "^6 || ^7 || ^8"
74 | },
75 | "config": {
76 | "allow-plugins": {
77 | "pestphp/pest-plugin": true
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 | ./tests/
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/Commands/SignPdfCommand.php:
--------------------------------------------------------------------------------
1 | line('Your PDF file is being signed!', 'info');
23 |
24 | try {
25 | $pdfPath = $this->argument(key: 'pdfPath');
26 | $pfxPath = $this->argument(key: 'pfxPath');
27 | $password = $this->argument(key: 'password');
28 | $fileName = $this->defineFileName($this->argument(key: 'fileName'));
29 |
30 | $signedFileResource = signPdfFromFile($pfxPath, $password, $pdfPath);
31 |
32 | File::put($fileName, $signedFileResource);
33 |
34 | $this->line("Your file has been signed and is available at: \"{$fileName}\"", 'info');
35 |
36 | return self::SUCCESS;
37 | } catch (\Throwable $th) {
38 | $this->line("Could not sign your file, error occurred: {$th->getMessage()}", 'error');
39 | return self::FAILURE;
40 | }
41 | }
42 |
43 | private function defineFileName(?string $fileName): string
44 | {
45 | if ($fileName && !Str::endsWith(strtolower($fileName), '.pdf')) {
46 | return "{$fileName}.pdf";
47 | }
48 |
49 | if (!$fileName) {
50 | $fileName = a1TempDir(tempFile: true, fileExt: '.pdf');
51 | }
52 |
53 | return $fileName;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Commands/ValidatePdfSignatureCommand.php:
--------------------------------------------------------------------------------
1 | line('Your PDF document is being validated.', 'info');
18 | try {
19 | $pdfPath = $this->argument(key: 'pdfPath');
20 |
21 | $validated = validatePdfSignature($pdfPath);
22 | $validationText = $validated->isValidated ? 'VALID' : 'INVALID';
23 |
24 | $this->line("Your PDF document is {$validationText}", 'info');
25 | return $validated->isValidated ? self::SUCCESS : self::INVALID;
26 | } catch (\Throwable $th) {
27 | $this->line("Unable to validate your file signature, an error occurred: {$th->getMessage()}", 'error');
28 | return self::FAILURE;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Entities/BaseEntity.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Exceptions/FileNotFoundException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Exceptions/HasNoSignatureOrInvalidPkcs7Exception.php:
--------------------------------------------------------------------------------
1 | currentFile = $currentFile;
15 | $message = 'The file is unsigned or the signature is not compatible with the PKCS7 type.';
16 | parent::__construct($message, $code, $previous);
17 | }
18 |
19 | public function __toString(): string
20 | {
21 | return __CLASS__ . ": [{$this->code}]: {$this->message}\n";
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidCertificateContentException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidImageDriverException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidPFXException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidPdfFileException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidPdfSignModeTypeException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/InvalidX509PrivateKeyException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exceptions/ProcessRunTimeException.php:
--------------------------------------------------------------------------------
1 | code}]: {$this->message}\n";
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Helpers/helpers.php:
--------------------------------------------------------------------------------
1 | fromPfx($pfxPath, $password, $usePathEnv),
27 | $mode
28 | ))->signature();
29 | }
30 | }
31 |
32 | if (!function_exists('signPdfFromUpload')) {
33 | /**
34 | * @throws Throwable
35 | */
36 | function signPdfFromUpload(
37 | UploadedFile $uploadedPfx,
38 | string $password,
39 | string $pdfPath,
40 | string $mode = SignaturePdf::MODE_RESOURCE,
41 | bool $usePathEnv = false
42 | ): BinaryFileResponse|string
43 | {
44 | return (new SignaturePdf(
45 | $pdfPath,
46 | (new ManageCert)->fromUpload($uploadedPfx, $password, $usePathEnv),
47 | $mode
48 | ))->signature();
49 | }
50 | }
51 |
52 | if (!function_exists('encryptCertData')) {
53 | /**
54 | * @throws Throwable
55 | */
56 | function encryptCertData(
57 | UploadedFile|string $uploadedOrPfxPath,
58 | string $password,
59 | bool $usePathEnv = false
60 | ): EncryptedCertificate
61 | {
62 | $cert = new ManageCert;
63 |
64 | if ($uploadedOrPfxPath instanceof UploadedFile) {
65 | $cert->fromUpload($uploadedOrPfxPath, $password, $usePathEnv);
66 | } else {
67 | $cert->fromPfx($uploadedOrPfxPath, $password, $usePathEnv);
68 | }
69 |
70 | return new EncryptedCertificate(
71 | certificate: $cert->getEncrypter()->encryptString($cert->getCert()->original),
72 | password: $cert->getEncrypter()->encryptString($password),
73 | hash: $cert->getHashKey() // IMPORTANT, USE ON DECRYPT HELPER
74 | );
75 | }
76 | }
77 |
78 | if (!function_exists('decryptCertData')) {
79 | /**
80 | * @throws Throwable
81 | */
82 | function decryptCertData(
83 | string $hashKey,
84 | string $encryptCert,
85 | string $password,
86 | bool $isBase64 = false,
87 | bool $usePathEnv = false,
88 | ): ManageCert
89 | {
90 | $cert = (new ManageCert)->setHashKey($hashKey);
91 | $uuid = Str::orderedUuid();
92 | $pfxName = "{$cert->getTempDir()}{$uuid}.pfx";
93 |
94 | $decryptedData = $cert->getEncrypter()->decryptString($encryptCert);
95 | File::put($pfxName, $isBase64 ? base64_decode($decryptedData) : $decryptedData);
96 |
97 | return $cert->fromPfx(
98 | $pfxName,
99 | $cert->getEncrypter()->decryptString($password),
100 | $usePathEnv
101 | );
102 | }
103 | }
104 |
105 | if (!function_exists('a1TempDir')) {
106 | function a1TempDir(bool $tempFile = false, string $fileExt = '.pfx'): string
107 | {
108 | $tempDir = dirname(__DIR__) . '/Temp/';
109 |
110 | if (!is_writable($tempDir)) {
111 | $tempDir = sys_get_temp_dir() . '/';
112 | }
113 |
114 | if ($tempFile) {
115 | $tempDir .= Str::orderedUuid() . $fileExt;
116 | }
117 |
118 | return $tempDir;
119 | }
120 | }
121 |
122 | if (!function_exists('validatePdfSignature')) {
123 | /**
124 | * @throws Throwable
125 | */
126 | function validatePdfSignature(string $pdfPath): ValidatedSignedPDF
127 | {
128 | return ValidatePdfSignature::from($pdfPath);
129 | }
130 | }
131 |
132 | if (!function_exists('runCliCommandProcesses')) {
133 | /**
134 | * @throws ProcessRunTimeException
135 | */
136 | function runCliCommandProcesses(string $command, bool $usePathEnv = false): void
137 | {
138 | $env = null;
139 |
140 | if ($usePathEnv) {
141 | $env = [
142 | 'PATH' => getenv('PATH')
143 | ];
144 | }
145 |
146 | $process = Process::fromShellCommandline(
147 | command: $command,
148 | env: $env
149 | );
150 | $process->run();
151 | while ($process->isRunning()) continue;
152 |
153 | if (!$process->isSuccessful()) {
154 | throw new ProcessRunTimeException($process->getErrorOutput());
155 | }
156 |
157 | $process->stop(1);
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/LaravelA1PdfSignServiceProvider.php:
--------------------------------------------------------------------------------
1 | app->runningInConsole()) {
18 | $this->commands([
19 | SignPdfCommand::class,
20 | ValidatePdfSignatureCommand::class
21 | ]);
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Resources/font/Roboto-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lsnepomuceno/laravel-a1-pdf-sign/bdc396dac6f45966a4532b70ae15b7a62643be6f/src/Resources/font/Roboto-Medium.ttf
--------------------------------------------------------------------------------
/src/Resources/img/sign-seal.pdn:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lsnepomuceno/laravel-a1-pdf-sign/bdc396dac6f45966a4532b70ae15b7a62643be6f/src/Resources/img/sign-seal.pdn
--------------------------------------------------------------------------------
/src/Resources/img/sign-seal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lsnepomuceno/laravel-a1-pdf-sign/bdc396dac6f45966a4532b70ae15b7a62643be6f/src/Resources/img/sign-seal.png
--------------------------------------------------------------------------------
/src/Sign/ManageCert.php:
--------------------------------------------------------------------------------
1 | tempDir = a1TempDir();
40 | $this->generateHashKey()->setEncrypter();
41 |
42 | if (!File::exists($this->tempDir)) {
43 | File::makeDirectory($this->tempDir);
44 | }
45 | }
46 |
47 | public function setPreservePfx(bool $preservePfx = true): self
48 | {
49 | $this->preservePfx = $preservePfx;
50 | return $this;
51 | }
52 |
53 | public function setIsLegacy(bool $isLegacy = true): self
54 | {
55 | $this->isLegacy = $isLegacy;
56 | return $this;
57 | }
58 |
59 | /**
60 | * @throws CertificateOutputNotFoundException
61 | * @throws FileNotFoundException
62 | * @throws InvalidCertificateContentException
63 | * @throws InvalidPFXException
64 | * @throws Invalidx509PrivateKeyException
65 | * @throws ProcessRunTimeException
66 | */
67 | public function fromPfx(string $pfxPath, string $password, bool $usePathEnv = false): self
68 | {
69 | if (!Str::of($pfxPath)->lower()->endsWith('.pfx')) {
70 | throw new InvalidPFXException($pfxPath);
71 | }
72 |
73 | if (!File::exists($pfxPath)) {
74 | throw new FileNotFoundException($pfxPath);
75 | }
76 |
77 | $this->password = $password;
78 | $output = a1TempDir(true, '.crt');
79 | $shellPfxPath = escapeshellarg($pfxPath);
80 | $shellOutput = escapeshellarg($output);
81 | $shellArgPassword = escapeshellarg($password);
82 | $legacyFlag = $this->isLegacy ? self::LEGACY_FLAG : '';
83 | $openSslCommand = "openssl pkcs12 -in {$shellPfxPath} -out {$shellOutput} -nodes -password pass:{$shellArgPassword} {$legacyFlag}";
84 |
85 | runCliCommandProcesses($openSslCommand, $usePathEnv);
86 |
87 | if (!File::exists($output)) {
88 | throw new CertificateOutputNotFoundException;
89 | }
90 |
91 | $content = File::get($output);
92 |
93 | $filesToBeDelete = [$output];
94 |
95 | !$this->preservePfx && ($filesToBeDelete[] = $pfxPath);
96 |
97 | File::delete($filesToBeDelete);
98 |
99 | return $this->setCertContent($content);
100 | }
101 |
102 | /**
103 | * @throws CertificateOutputNotFoundException
104 | * @throws FileNotFoundException
105 | * @throws InvalidCertificateContentException
106 | * @throws InvalidPFXException
107 | * @throws Invalidx509PrivateKeyException
108 | * @throws ProcessRunTimeException
109 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
110 | */
111 | public function fromUpload(UploadedFile $uploadedPfx, string $password, bool $usePathEnv = false): self
112 | {
113 | $pfxTemp = a1TempDir(true);
114 |
115 | if (File::exists($pfxTemp)) {
116 | $pfxTemp = microtime() . $pfxTemp;
117 | }
118 |
119 | File::put($pfxTemp, $uploadedPfx->get());
120 |
121 | $this->fromPfx($pfxTemp, $password, $usePathEnv);
122 |
123 | File::delete($pfxTemp);
124 |
125 | return $this;
126 | }
127 |
128 | /**
129 | * @throws InvalidCertificateContentException
130 | * @throws Invalidx509PrivateKeyException
131 | */
132 | public function setCertContent(string $certContent): self
133 | {
134 | $this->originalCertContent = $certContent;
135 | $this->certContent = openssl_x509_read(certificate: $certContent);
136 | $this->parsedData = openssl_x509_parse(certificate: $this->certContent, short_names: false);
137 | $this->validate();
138 | return $this;
139 | }
140 |
141 | /**
142 | * @throws InvalidCertificateContentException
143 | * @throws Invalidx509PrivateKeyException
144 | */
145 | public function validate(): void
146 | {
147 | if (!$this->certContent) {
148 | $this->invalidate();
149 | throw new InvalidCertificateContentException;
150 | }
151 |
152 | if (!openssl_x509_check_private_key(certificate: $this->certContent, private_key: $this->originalCertContent)) {
153 | $this->invalidate();
154 | throw new Invalidx509PrivateKeyException;
155 | }
156 | }
157 |
158 | private function invalidate(): void
159 | {
160 | $this->originalCertContent = '';
161 | $this->certContent = false;
162 | $this->parsedData = [];
163 | $this->password = '';
164 | }
165 |
166 | public function getCert(): CertificateProcessed
167 | {
168 | return new CertificateProcessed(
169 | original: $this->originalCertContent,
170 | openssl: $this->certContent,
171 | data: $this->parsedData,
172 | password: $this->password
173 | );
174 | }
175 |
176 | public function getTempDir(): string
177 | {
178 | return $this->tempDir;
179 | }
180 |
181 | public function generateHashKey(): self
182 | {
183 | $this->hashKey = Encrypter::generateKey(self::CIPHER);
184 | $this->setEncrypter();
185 | return $this;
186 | }
187 |
188 | public function setHashKey(string $hashKey): self
189 | {
190 | $this->hashKey = $hashKey;
191 | $this->setEncrypter();
192 | return $this;
193 | }
194 |
195 | private function setEncrypter(): void
196 | {
197 | $this->encrypter = new Encrypter($this->hashKey, self::CIPHER);
198 | }
199 |
200 | public function getHashKey(): string
201 | {
202 | return $this->encrypter->getKey();
203 | }
204 |
205 | public function getEncrypter(): Encrypter
206 | {
207 | return $this->encrypter;
208 | }
209 |
210 | /**
211 | * @throws EncryptException
212 | */
213 | public function encryptBase64BlobString(string $blobString): string
214 | {
215 | return $this->encrypter->encryptString(base64_encode($blobString));
216 | }
217 |
218 | /**
219 | * @throws DecryptException
220 | */
221 | public function decryptBase64BlobString(string $encryptedBlobString): string
222 | {
223 | $string = $this->encrypter->decryptString($encryptedBlobString);
224 | return base64_decode($string);
225 | }
226 |
227 | /**
228 | * @throws CertificateOutputNotFoundException
229 | * @throws FileNotFoundException
230 | * @throws InvalidCertificateContentException
231 | * @throws InvalidPFXException
232 | * @throws Invalidx509PrivateKeyException
233 | * @throws ProcessRunTimeException
234 | */
235 | public function makeDebugCertificate(bool $returnPathAndPass = false, bool $wrongPass = false): array|static
236 | {
237 | $pass = "example's password with special chars: $ & * ? \" '";
238 | $shellArgPassword = escapeshellarg($pass);
239 | $name = $this->tempDir . Str::orderedUuid();
240 |
241 | $genCommands = [
242 | "openssl req -x509 -newkey rsa:4096 -sha256 -keyout {$name}.key -out {$name}.crt -subj \"/CN=Test Certificate /OU=LucasNepomuceno\" -days 600 -passout pass:{$shellArgPassword}",
243 | "openssl pkcs12 -export -name test.com -out {$name}.pfx -inkey {$name}.key -in {$name}.crt -passin pass:{$shellArgPassword} -passout pass:{$shellArgPassword}"
244 | ];
245 |
246 | foreach ($genCommands as $command) {
247 | runCliCommandProcesses($command);
248 | }
249 |
250 | File::delete(["{$name}.key", "{$name}.crt"]);
251 |
252 | if ($returnPathAndPass) {
253 | return ["{$name}.pfx", $pass];
254 | }
255 |
256 | return $this->fromPfx("{$name}.pfx", $wrongPass ? 'wrongPass' : $pass);
257 | }
258 | }
259 |
--------------------------------------------------------------------------------
/src/Sign/SealImage.php:
--------------------------------------------------------------------------------
1 | setImageDriver($imageDriver);
38 | }
39 |
40 | public static function fromCert(
41 | ManageCert $cert,
42 | string $fontSize = self::FONT_SIZE_LARGE,
43 | bool $showDueDate = false,
44 | string $dueDateFormat = 'd/m/Y H:i:s'
45 | ): string
46 | {
47 | $subject = new Fluent($cert->getCert()->data['subject']);
48 | $firstLine = $subject->commonName ?? $subject->organizationName;
49 | $issuer = new Fluent($cert->getCert()->data['issuer']);
50 | $secondLine = $issuer->organizationalUnitName ?? $issuer->commonName ?? $issuer->organizationName;
51 |
52 | $certDueDate = $showDueDate
53 | ? now()
54 | ->createFromTimestamp(
55 | $cert->getCert()->data['validTo_time_t']
56 | )->format($dueDateFormat)
57 | : null;
58 |
59 | $callback = function ($font) use ($fontSize) {
60 | $font->file(dirname(__DIR__) . '/Resources/font/Roboto-Medium.ttf');
61 |
62 | $size = match ($fontSize) {
63 | self::FONT_SIZE_SMALL => 15,
64 | self::FONT_SIZE_MEDIUM => 20,
65 | default => 28
66 | };
67 |
68 | $font->size($size);
69 | $font->color('#16A085');
70 | };
71 |
72 | $selfObj = new static;
73 |
74 | return $selfObj
75 | ->setImagePath()
76 | ->addTextField(
77 | text: $selfObj->breakText($firstLine ?? $secondLine ?? '', $fontSize),
78 | textX: 160,
79 | textY: 80,
80 | callback: $callback
81 | )
82 | ->addTextField(
83 | text: $selfObj->breakText($firstLine ? $secondLine : '', $fontSize),
84 | textX: 160,
85 | textY: 150,
86 | callback: $callback
87 | )
88 | ->addTextField(
89 | text: $certDueDate ?? '',
90 | textX: 160,
91 | textY: 250,
92 | callback: $callback)
93 | ->generateImage();
94 | }
95 |
96 | private function breakText(string $text, string $fontSize = self::FONT_SIZE_LARGE): string
97 | {
98 | $cropSize = match ($fontSize) {
99 | self::FONT_SIZE_SMALL => 60,
100 | self::FONT_SIZE_MEDIUM => 48,
101 | default => 35
102 | };
103 |
104 | $this->previousTextBreakLine = strlen($text) >= $cropSize;
105 |
106 | if ($this->previousTextBreakLine) {
107 | $textSplit = str_split(string: $text, length: ($cropSize - 3));
108 | $textSplit = array_map(callback: 'trim', array: $textSplit);
109 | $text = join(separator: PHP_EOL, array: $textSplit);
110 | }
111 |
112 | return $text;
113 | }
114 |
115 | /**
116 | * @throws InvalidImageDriverException
117 | */
118 | public function setImageDriver(AbstractDriver $imageDriver): self
119 | {
120 | if (!in_array($imageDriver::class, [GDDriver::class, ImagickDriver::class])) {
121 | throw new InvalidImageDriverException($imageDriver::class);
122 | }
123 |
124 | $this->imageDriver = $imageDriver;
125 |
126 | return $this;
127 | }
128 |
129 | public function setImagePath(string $imagePathOrContent = null): self
130 | {
131 | $this->imagePathOrContent = $imagePathOrContent ?? dirname(__DIR__) . '/Resources/img/sign-seal.png';
132 |
133 | return $this;
134 | }
135 |
136 | /**
137 | * @link http://image.intervention.io/api/text
138 | */
139 | public function addTextField(
140 | string $text,
141 | float $textX,
142 | float $textY,
143 | Closure $callback = null
144 | ): self
145 | {
146 | $newText = [
147 | 'text' => $text,
148 | 'x' => $textX,
149 | 'y' => $textY,
150 | 'callback' => $callback ?? fn() => null
151 | ];
152 |
153 | $this->textFieldsDefinitions[] = $newText;
154 |
155 | return $this;
156 | }
157 |
158 | /**
159 | * @throw \Intervention\Image\ImageManager\Exception\NotReadableException
160 | */
161 | public function generateImage(string $returnType = self::RETURN_IMAGE_CONTENT): string
162 | {
163 | $image = new IMG(driver: $this->imageDriver);
164 | $image = $image->read($this->imagePathOrContent);
165 |
166 | foreach ($this->textFieldsDefinitions as $text) {
167 | ['text' => $text, 'x' => $x, 'y' => $y, 'callback' => $callback] = $text;
168 | $image->text($text, $x, $y, $callback);
169 | }
170 |
171 | if ($returnType === self::RETURN_IMAGE_CONTENT) {
172 | return $image->encode(encoder: new JpegEncoder)->toString();
173 | }
174 |
175 | return $image->encode(encoder: new JpegEncoder)->toDataUri();
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/src/Sign/SignaturePdf.php:
--------------------------------------------------------------------------------
1 | cert = $cert;
60 |
61 | // Throws exception on invalidate certificate
62 | try {
63 | $this->cert->validate();
64 | } catch (Throwable $th) {
65 | throw new $th;
66 | }
67 |
68 | $this->setFileName($fileName)
69 | ->setHasSignedSuffix($hasSignedSuffix)
70 | ->setSealImgOnEveryPages(false);
71 |
72 | $this->mode = $mode;
73 | $this->pdfPath = $pdfPath;
74 | $this->setPdf();
75 | }
76 |
77 | public function setInfo(
78 | ?string $name = null,
79 | ?string $location = null,
80 | ?string $reason = null,
81 | ?string $contactInfo = null
82 | ): SignaturePdf
83 | {
84 | $info = [];
85 | $name && ($info['Name'] = $name);
86 | $location && ($info['Location'] = $location);
87 | $reason && ($info['Reason'] = $reason);
88 | $contactInfo && ($info['ContactInfo'] = $contactInfo);
89 | $this->info = $info;
90 | return $this;
91 | }
92 |
93 | public function getPdfInstance(): Fpdi
94 | {
95 | return $this->pdf;
96 | }
97 |
98 | public function setPdf(
99 | string $orientation = 'P',
100 | string $unit = 'mm',
101 | string $pageFormat = 'A4',
102 | bool $unicode = true,
103 | string $encoding = 'UTF-8'
104 | ): SignaturePdf
105 | {
106 | $this->pdf = new Fpdi($orientation, $unit, $pageFormat, $unicode, $encoding);
107 | return $this;
108 | }
109 |
110 | public function setImage(
111 | string $imagePath,
112 | float $pageX = 155,
113 | float $pageY = 250,
114 | float $imageW = 50,
115 | float $imageH = 0,
116 | int $page = -1
117 | ): SignaturePdf
118 | {
119 | $this->image = compact('imagePath', 'pageX', 'pageY', 'imageW', 'imageH', 'page');
120 | return $this;
121 | }
122 |
123 | public function setSealImgOnEveryPages(bool $hasSealImgOnEveryPages = true): SignaturePdf
124 | {
125 | $this->hasSealImgOnEveryPages = $hasSealImgOnEveryPages;
126 | return $this;
127 | }
128 |
129 | public function setFileName(string $fileName): SignaturePdf
130 | {
131 | $ext = explode('.', $fileName);
132 | $ext = end($ext);
133 | $this->fileName = str_replace(".{$ext}", '', $fileName);
134 | return $this;
135 | }
136 |
137 | public function setHasSignedSuffix(bool $hasSignedSuffix): SignaturePdf
138 | {
139 | $this->hasSignedSuffix = $hasSignedSuffix;
140 | return $this;
141 | }
142 |
143 | private function implementSignatureImage(?int $currentPage = null): void
144 | {
145 | if ($this->image) {
146 | /**
147 | * @var string $imagePath
148 | * @var float|null $pageX
149 | * @var float|null $pageY
150 | * @var float $imageW
151 | * @var float $imageH
152 | * @var int $page
153 | * @see setImage()
154 | */
155 | extract($this->image);
156 | $this->pdf->Image($imagePath, $pageX, $pageY, $imageW, $imageH, 'PNG');
157 | $this->pdf->setSignatureAppearance($pageX, $pageY, $imageW, $imageH, $currentPage ?? $page);
158 | }
159 | }
160 |
161 | /**
162 | * @throws CrossReferenceException
163 | * @throws FilterException
164 | * @throws PdfParserException
165 | * @throws PdfTypeException
166 | * @throws PdfReaderException
167 | */
168 | public function signature(): string|BinaryFileResponse
169 | {
170 | $pageCount = $this->pdf->setSourceFile($this->pdfPath);
171 |
172 | for ($i = 1; $i <= $pageCount; $i++) {
173 | $pageIndex = $this->pdf->importPage($i);
174 | $this->pdf->SetPrintHeader(false);
175 | $this->pdf->SetPrintFooter(false);
176 |
177 | $templateSize = $this->pdf->getTemplateSize($pageIndex);
178 | ['width' => $width, 'height' => $height] = $templateSize;
179 |
180 | $this->pdf->AddPage($width > $height ? 'L' : 'P', [$width, $height]);
181 | $this->pdf->useTemplate($pageIndex);
182 |
183 | $insertImageOnLastPage = !empty($this->image['page']) && $this->image['page'] === -1 && $i === $pageCount;
184 | if ($this->hasSealImgOnEveryPages ||
185 | $i === ($this->image['page'] ?? 0) ||
186 | $insertImageOnLastPage
187 | ) {
188 | $this->implementSignatureImage($i);
189 | }
190 | }
191 |
192 | $certificate = $this->cert->getCert()->original;
193 | $password = $this->cert->getCert()->password;
194 |
195 | $this->pdf->setSignature(
196 | $certificate,
197 | $certificate,
198 | $password,
199 | '',
200 | 3,
201 | $this->info,
202 | 'A' // Authorize certificate
203 | );
204 |
205 | if (empty($this->fileName)) $this->fileName = Str::orderedUuid();
206 | if ($this->hasSignedSuffix) $this->fileName .= '_signed';
207 |
208 | $this->fileName .= '.pdf';
209 |
210 | $output = "{$this->cert->getTempDir()}{$this->fileName}";
211 |
212 | // Required to receive data from the server, such as timestamp and allocation hash.
213 | if (!File::exists($output)) File::put($output, $this->pdf->output($this->fileName, 'S'));
214 |
215 | switch ($this->mode) {
216 | case self::MODE_RESOURCE:
217 | $content = File::get($output);
218 | File::delete([$output]);
219 | return $content;
220 | break;
221 |
222 | case self::MODE_DOWNLOAD:
223 | default:
224 | return response()->download($output)->deleteFileAfterSend();
225 | break;
226 | }
227 | }
228 | }
229 |
--------------------------------------------------------------------------------
/src/Sign/ValidatePdfSignature.php:
--------------------------------------------------------------------------------
1 | setPdfPath($pdfPath)
24 | ->extractSignatureData()
25 | ->convertSignatureDataToPlainText()
26 | ->convertPlainTextToObject();
27 | }
28 |
29 | /**
30 | * @throws FileNotFoundException
31 | * @throws InvalidPdfFileException
32 | */
33 | private function setPdfPath(string $pdfPath): self
34 | {
35 | if (!Str::of($pdfPath)->lower()->endsWith('.pdf')) {
36 | throw new InvalidPdfFileException($pdfPath);
37 | }
38 |
39 | if (!File::exists($pdfPath)) {
40 | throw new FileNotFoundException($pdfPath);
41 | }
42 |
43 | $this->pdfPath = $pdfPath;
44 |
45 | return $this;
46 | }
47 |
48 | /**
49 | * @throws HasNoSignatureOrInvalidPkcs7Exception
50 | */
51 | private function extractSignatureData(): self
52 | {
53 | $content = File::get($this->pdfPath);
54 | $regexp = '#ByteRange\[\s*(\d+) (\d+) (\d+)#'; // subexpressions are used to extract b and c
55 | $result = [];
56 | preg_match_all($regexp, $content, $result);
57 |
58 | // $result[2][0] and $result[3][0] are b and c
59 | if (!isset($result[2][0]) && !isset($result[3][0])) {
60 | throw new HasNoSignatureOrInvalidPkcs7Exception($this->pdfPath);
61 | }
62 |
63 | $start = $result[2][0];
64 | $end = $result[3][0];
65 |
66 | if ($stream = fopen($this->pdfPath, 'rb')) {
67 | $signature = stream_get_contents($stream, $end - $start - 2, $start + 1); // because we need to exclude < and > from start and end
68 | fclose($stream);
69 | $this->pkcs7Path = a1TempDir(tempFile: true, fileExt: '.pkcs7');
70 | File::put($this->pkcs7Path, hex2bin($signature));
71 | }
72 |
73 | return $this;
74 | }
75 |
76 | /**
77 | * @throws FileNotFoundException
78 | * @throws HasNoSignatureOrInvalidPkcs7Exception
79 | * @throws ProcessRunTimeException
80 | */
81 | private function convertSignatureDataToPlainText(): self
82 | {
83 | if (!$this->pkcs7Path) {
84 | throw new HasNoSignatureOrInvalidPkcs7Exception($this->pdfPath);
85 | }
86 |
87 | $output = a1TempDir(tempFile: true, fileExt: '.txt');
88 | $openSslCommand = "openssl pkcs7 -in {$this->pkcs7Path} -inform DER -print_certs > {$output}";
89 |
90 | runCliCommandProcesses($openSslCommand);
91 |
92 | if (!File::exists($output)) {
93 | throw new FileNotFoundException($output);
94 | }
95 |
96 | $this->plainTextContent = File::get($output);
97 |
98 | File::delete([$output, $this->pkcs7Path]);
99 |
100 | return $this;
101 | }
102 |
103 | private function convertPlainTextToObject(): ValidatedSignedPDF
104 | {
105 | $finalContent = [];
106 | $delimiter = '|CROP|';
107 | $content = $this->plainTextContent;
108 | $content = preg_replace('/(-----BEGIN .+?-----(?s).+?-----END .+?-----)/mi', $delimiter, $content);
109 | $content = preg_replace('/(\s\s+|\\n|\\r)/', ' ', $content);
110 | $content = array_filter(explode($delimiter, $content), 'trim');
111 | $content = (array)array_map(fn($data) => $this->processDataToInfo($data), $content)[0];
112 |
113 | foreach ($content as $value) {
114 | $val = $value[key($value)];
115 | $key = &$finalContent[key($value)];
116 |
117 | !in_array($val, ($key ?? [])) && ($key[] = $val);
118 | }
119 |
120 | $finalContent['validated'] = !!count(array_intersect_key(array_flip(['OU', 'CN']), $finalContent));
121 | return new ValidatedSignedPDF($finalContent['validated'], Arr::except($finalContent, 'validated'));
122 | }
123 |
124 | private function processDataToInfo(string $data): array
125 | {
126 | /** it allows to split by "," except when "," inside of quoutes */
127 | $data = preg_split('/\s*,\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/', trim($data));
128 |
129 | $finalData = [];
130 |
131 | foreach ($data as $info) {
132 | $infoTemp = explode(' = ', trim($info));
133 | if (isset($infoTemp[0]) && $infoTemp[1]) {
134 | $finalData[] = [$infoTemp[0] => $infoTemp[1]];
135 | }
136 | }
137 | return $finalData;
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/Temp/.gitkeep:
--------------------------------------------------------------------------------
1 | ~
--------------------------------------------------------------------------------
/tests/CommandsTest.php:
--------------------------------------------------------------------------------
1 | makeDebugCertificate(true);
28 | $pdfPath = __DIR__ . '/Resources/test.pdf';
29 | $fileName = a1TempDir(true, '.pdf');
30 | $parameters = [
31 | 'pdfPath' => $pdfPath,
32 | 'pfxPath' => $pfxPath,
33 | 'password' => $pass,
34 | 'fileName' => $fileName
35 | ];
36 |
37 | $this->artisan('pdf:sign', $parameters)
38 | ->assertSuccessful()
39 | ->expectsOutput('Your PDF file is being signed!')
40 | ->expectsOutput("Your file has been signed and is available at: \"{$fileName}\"");
41 | }
42 |
43 | public function testWhenTheSignatureCommandDoesNotFinishSuccessfully()
44 | {
45 | $parameters = [
46 | 'pdfPath' => a1TempDir(true, '.pdf'),
47 | 'pfxPath' => a1TempDir(true, '.pfx'),
48 | 'password' => Str::random(32),
49 | 'fileName' => a1TempDir(true, '.pdf')
50 | ];
51 |
52 | $this->artisan('pdf:sign', $parameters)
53 | // ->assertFailed()
54 | ->expectsOutput('Your PDF file is being signed!')
55 | ->expectsOutputToContain('Could not sign your file, error occurred:');
56 |
57 | }
58 |
59 | /**
60 | * @throws FileNotFoundException
61 | * @throws ProcessRunTimeException
62 | * @throws Invalidx509PrivateKeyException
63 | * @throws Throwable
64 | * @throws InvalidCertificateContentException
65 | * @throws CertificateOutputNotFoundException
66 | * @throws InvalidPFXException
67 | */
68 | public function testWhenASignedPdfIsSuccessfullyValidated()
69 | {
70 | $cert = new ManageCert;
71 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
72 |
73 | $signed = signPdfFromFile($pfxPath, $pass, __DIR__ . '/Resources/test.pdf');
74 | $pdfPath = a1TempDir(true, '.pdf');
75 |
76 | File::put($pdfPath, $signed);
77 | $fileExists = File::exists($pdfPath);
78 |
79 | $this->assertTrue($fileExists);
80 |
81 | $parameters = [
82 | 'pdfPath' => $pdfPath
83 | ];
84 |
85 | $this->artisan('pdf:validate-signature', $parameters)
86 | ->assertSuccessful()
87 | ->expectsOutput('Your PDF document is being validated.')
88 | ->expectsOutput('Your PDF document is VALID');
89 | }
90 |
91 | public function testWhenAnUnsignedDocumentThrowsAnErrorWhenRunningAValidationCommand()
92 | {
93 | $pdfPath = __DIR__ . '/Resources/test.pdf';
94 | $parameters = [
95 | 'pdfPath' => $pdfPath
96 | ];
97 |
98 | $this->artisan('pdf:validate-signature', $parameters)
99 | ->assertFailed()
100 | ->expectsOutput('Your PDF document is being validated.')
101 | ->expectsOutputToContain('Unable to validate your file signature, an error occurred:');
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/tests/HelpersTest.php:
--------------------------------------------------------------------------------
1 | makeDebugCertificate(true);
32 |
33 | $signed = signPdfFromFile($pfxPath, $pass, __DIR__ . '/Resources/test.pdf');
34 | $pdfPath = a1TempDir(true, '.pdf');
35 |
36 | File::put($pdfPath, $signed);
37 | $fileExists = File::exists($pdfPath);
38 |
39 | $this->assertTrue($fileExists);
40 | File::delete([$pfxPath, $pdfPath]);
41 | }
42 |
43 | /**
44 | * @throws FileNotFoundException
45 | * @throws ProcessRunTimeException
46 | * @throws Invalidx509PrivateKeyException
47 | * @throws Throwable
48 | * @throws InvalidCertificateContentException
49 | * @throws InvalidPFXException
50 | * @throws CertificateOutputNotFoundException
51 | */
52 | public function testWhenAFileIsSignedByTheSignPdfFromFileHelperUsingPathEnv()
53 | {
54 | $cert = new ManageCert;
55 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
56 |
57 | $signed = signPdfFromFile(
58 | pfxPath: $pfxPath,
59 | password: $pass,
60 | pdfPath: __DIR__ . '/Resources/test.pdf',
61 | usePathEnv: true
62 | );
63 | $pdfPath = a1TempDir(true, '.pdf');
64 |
65 | File::put($pdfPath, $signed);
66 | $fileExists = File::exists($pdfPath);
67 |
68 | $this->assertTrue($fileExists);
69 | File::delete([$pfxPath, $pdfPath]);
70 | }
71 |
72 | /**
73 | * @throws FileNotFoundException
74 | * @throws ProcessRunTimeException
75 | * @throws Invalidx509PrivateKeyException
76 | * @throws Throwable
77 | * @throws InvalidCertificateContentException
78 | * @throws CertificateOutputNotFoundException
79 | * @throws InvalidPFXException
80 | */
81 | public function testWhenAFileIsSignedByTheSignPdfFromUploadHelper()
82 | {
83 | $cert = new ManageCert;
84 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
85 |
86 | $uploadedFile = new UploadedFile($pfxPath, 'testCertificate.pfx', null, null, true);
87 | $signed = signPdfFromUpload($uploadedFile, $pass, __DIR__ . '/Resources/test.pdf');
88 | $pdfPath = a1TempDir(true, '.pdf');
89 |
90 | File::put($pdfPath, $signed);
91 | $fileExists = File::exists($pdfPath);
92 |
93 | $this->assertTrue($fileExists);
94 | File::delete([$pfxPath, $pdfPath]);
95 | }
96 |
97 | /**
98 | * @throws FileNotFoundException
99 | * @throws ProcessRunTimeException
100 | * @throws Invalidx509PrivateKeyException
101 | * @throws Throwable
102 | * @throws InvalidCertificateContentException
103 | * @throws CertificateOutputNotFoundException
104 | * @throws InvalidPFXException
105 | */
106 | public function testWhenAFileIsSignedByTheSignPdfFromUploadHelperUsingPathEnv()
107 | {
108 | $cert = new ManageCert;
109 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
110 |
111 | $uploadedFile = new UploadedFile($pfxPath, 'testCertificate.pfx', null, null, true);
112 | $signed = signPdfFromUpload(
113 | uploadedPfx: $uploadedFile,
114 | password: $pass,
115 | pdfPath: __DIR__ . '/Resources/test.pdf',
116 | usePathEnv: true
117 | );
118 | $pdfPath = a1TempDir(true, '.pdf');
119 |
120 | File::put($pdfPath, $signed);
121 | $fileExists = File::exists($pdfPath);
122 |
123 | $this->assertTrue($fileExists);
124 | }
125 |
126 | /**
127 | * @throws FileNotFoundException
128 | * @throws ProcessRunTimeException
129 | * @throws Invalidx509PrivateKeyException
130 | * @throws Throwable
131 | * @throws InvalidCertificateContentException
132 | * @throws CertificateOutputNotFoundException
133 | * @throws InvalidPFXException
134 | */
135 | public function testWhenCertificateDataIsEncrypted()
136 | {
137 | $cert = new ManageCert;
138 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
139 |
140 | $encryptedData = encryptCertData($pfxPath, $pass);
141 |
142 | foreach (['certificate', 'password', 'hash'] as $key) {
143 | $this->assertArrayHasKey($key, $encryptedData->toArray());
144 | }
145 | }
146 |
147 | public function testWhenTheA1TempDirHelperCreatesTheFilesCorrectly()
148 | {
149 | $this->assertTrue(
150 | File::isDirectory(a1TempDir())
151 | );
152 |
153 | $this->assertTrue(
154 | Str::endsWith(a1TempDir(true), '.pfx')
155 | );
156 |
157 | $this->assertTrue(
158 | Str::endsWith(
159 | a1TempDir(true, '.pdf'),
160 | '.pdf'
161 | )
162 | );
163 | }
164 |
165 | /**
166 | * @throws FileNotFoundException
167 | * @throws ProcessRunTimeException
168 | * @throws Invalidx509PrivateKeyException
169 | * @throws InvalidCertificateContentException
170 | * @throws Throwable
171 | * @throws CertificateOutputNotFoundException
172 | * @throws InvalidPFXException
173 | */
174 | public function testWhenASignedPdfFileIsCorrectlyValidatedByTheValidatePdfSignatureHelper()
175 | {
176 | $cert = new ManageCert;
177 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
178 |
179 | $signed = signPdfFromFile($pfxPath, $pass, __DIR__ . '/Resources/test.pdf');
180 | $pdfPath = a1TempDir(true, '.pdf');
181 |
182 | File::put($pdfPath, $signed);
183 | $fileExists = File::exists($pdfPath);
184 |
185 | $this->assertTrue($fileExists);
186 |
187 | $validation = validatePdfSignature($pdfPath);
188 | $this->assertTrue($validation->isValidated);
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/tests/ManageCertTest.php:
--------------------------------------------------------------------------------
1 | makeDebugCertificate();
32 |
33 | $this->assertInstanceOf(CertificateProcessed::class, $cert->getCert());
34 |
35 | foreach (['original', 'openssl', 'data', 'password'] as $key) {
36 | $this->assertArrayHasKey($key, $cert->getCert()->toArray());
37 | }
38 |
39 | $this->assertStringContainsStringIgnoringCase('BEGIN CERTIFICATE', $cert->getCert()->original);
40 |
41 | is_object($cert->getCert()->openssl)
42 | ? $this->assertInstanceOf(OpenSSLCertificate::class, $cert->getCert()->openssl)
43 | : $this->assertIsResource($cert->getCert()->openssl);
44 |
45 | $this->assertIsArray($cert->getCert()->data);
46 | $this->assertArrayHasKey('validTo_time_t', $cert->getCert()->data); //important field
47 | $this->assertNotNull($cert->getCert()->password);
48 | }
49 |
50 | /**
51 | * @throws InvalidCertificateContentException
52 | * @throws InvalidPFXException
53 | * @throws CertificateOutputNotFoundException
54 | * @throws ProcessRunTimeException
55 | * @throws Invalidx509PrivateKeyException
56 | */
57 | public function testValidateNotFoundPfxFileException()
58 | {
59 | $this->expectException(FileNotFoundException::class);
60 |
61 | $cert = new ManageCert;
62 | $cert->fromPfx('imaginary/path/to/file.pfx', '12345');
63 | }
64 |
65 | /**
66 | * @throws FileNotFoundException
67 | * @throws InvalidCertificateContentException
68 | * @throws CertificateOutputNotFoundException
69 | * @throws ProcessRunTimeException
70 | * @throws Invalidx509PrivateKeyException
71 | */
72 | public function testValidatePfxFileExtensionException()
73 | {
74 | $this->expectException(InvalidPFXException::class);
75 |
76 | $cert = new ManageCert;
77 | $cert->fromPfx('imaginary/path/to/file.pfz', '12345');
78 | }
79 |
80 | /**
81 | * @throws FileNotFoundException
82 | * @throws InvalidCertificateContentException
83 | * @throws CertificateOutputNotFoundException
84 | * @throws InvalidPFXException
85 | * @throws ProcessRunTimeException
86 | * @throws Invalidx509PrivateKeyException
87 | */
88 | public function testValidateEncryperInstanceAndResources()
89 | {
90 | $cert = new ManageCert;
91 | $cert->makeDebugCertificate();
92 |
93 | $this->assertInstanceOf(Encrypter::class, $cert->getEncrypter());
94 | $this->assertTrue(
95 | $cert->getEncrypter()->supported($cert->getHashKey(), $cert::CIPHER)
96 | );
97 | }
98 |
99 | /**
100 | * @throws FileNotFoundException
101 | * @throws InvalidCertificateContentException
102 | * @throws InvalidPFXException
103 | * @throws CertificateOutputNotFoundException
104 | * @throws Invalidx509PrivateKeyException
105 | */
106 | public function testValidateProcessRunTimeException()
107 | {
108 | $this->expectException(ProcessRunTimeException::class);
109 |
110 | $cert = new ManageCert;
111 | $cert->makeDebugCertificate(false, true);
112 | }
113 |
114 | /**
115 | * @throws FileNotFoundException
116 | * @throws ProcessRunTimeException
117 | * @throws Invalidx509PrivateKeyException
118 | * @throws InvalidCertificateContentException
119 | * @throws InvalidPFXException
120 | * @throws CertificateOutputNotFoundException
121 | */
122 | public function testValidatesIfThePfxFileWillBeDeletedAfterBeingPreserved()
123 | {
124 | $cert = new ManageCert;
125 | list($pfxPath, $pass) = $cert->makeDebugCertificate(true);
126 |
127 | $cert->setPreservePfx()->fromPfx($pfxPath, $pass);
128 |
129 | $this->assertTrue(File::exists($pfxPath));
130 | $this->assertTrue(File::delete($pfxPath));
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/tests/Resources/.gitignore:
--------------------------------------------------------------------------------
1 | !*.pdf
--------------------------------------------------------------------------------
/tests/Resources/CertInfoExamples/with-comma.json:
--------------------------------------------------------------------------------
1 | [
2 | { "subject=CN": "NAME" },
3 | { "name": "NAME" },
4 | { "O": "NAME" },
5 | { "x500UniqueIdentifier": "AAA010101AAA" },
6 | { "serialNumber": "AAA010101AAAA123" },
7 | { "OU": "A NAME issuer=CN" },
8 | { "O": "SERVICIO DE ADMINISTRACION TRIBUTARIA" },
9 | { "OU": "SAT-IES Authority" },
10 | { "emailAddress": "contacto.tecnico@example.com" },
11 | { "street": "\"AV. HIDALGO 77, COL. GUERRERO\"" },
12 | { "postalCode": "06300" },
13 | { "C": "MX" },
14 | { "ST": "CIUDAD DE MEXICO" },
15 | { "L": "CUAUHTEMOC" },
16 | { "x500UniqueIdentifier": "XXX010101XXX" },
17 | {
18 | "unstructuredName": "responsable: ADMINISTRACION CENTRAL DE SERVICIOS TRIBUTARIOS AL CONTRIBUYENTE"
19 | }
20 | ]
21 |
--------------------------------------------------------------------------------
/tests/Resources/CertInfoExamples/with-comma.txt:
--------------------------------------------------------------------------------
1 | subject=CN = NAME, name = NAME , O = NAME, x500UniqueIdentifier = AAA010101AAA, serialNumber = AAA010101AAAA123, OU = A NAME issuer=CN = AUTORIDAD CERTIFICADORA, O = SERVICIO DE ADMINISTRACION TRIBUTARIA, OU = SAT-IES Authority, emailAddress = contacto.tecnico@example.com, street = "AV. HIDALGO 77, COL. GUERRERO", postalCode = 06300, C = MX, ST = CIUDAD DE MEXICO, L = CUAUHTEMOC, x500UniqueIdentifier = XXX010101XXX, unstructuredName = responsable: ADMINISTRACION CENTRAL DE SERVICIOS TRIBUTARIOS AL CONTRIBUYENTE
--------------------------------------------------------------------------------
/tests/Resources/CertInfoExamples/without-comma.json:
--------------------------------------------------------------------------------
1 | [
2 | { "subject=CN": "ESCUELA KEMPER URGATE SA DE CV" },
3 | { "name": "ESCUELA KEMPER URGATE SA DE CV" },
4 | { "O": "ESCUELA KEMPER URGATE SA DE CV" },
5 | { "x500UniqueIdentifier": "EKU9003173C9 / VADA800927DJ3" },
6 | { "serialNumber": "\" / VADA800927HSRSRL05\"" },
7 | { "OU": "Sucursal 1 issuer=CN" },
8 | { "O": "SERVICIO DE ADMINISTRACION TRIBUTARIA" },
9 | { "OU": "SAT-IES Authority" },
10 | { "emailAddress": "oscar.martinez@sat.gob.mx" },
11 | { "street": "3ra cerrada de caliz" },
12 | { "postalCode": "06370" },
13 | { "C": "MX" },
14 | { "ST": "CIUDAD DE MEXICO" },
15 | { "L": "COYOACAN" },
16 | { "x500UniqueIdentifier": "2.5.4.45" },
17 | { "unstructuredName": "responsable: ACDMA-SAT" }
18 | ]
19 |
--------------------------------------------------------------------------------
/tests/Resources/CertInfoExamples/without-comma.txt:
--------------------------------------------------------------------------------
1 | subject=CN = ESCUELA KEMPER URGATE SA DE CV, name = ESCUELA KEMPER URGATE SA DE CV, O = ESCUELA KEMPER URGATE SA DE CV, x500UniqueIdentifier = EKU9003173C9 / VADA800927DJ3, serialNumber = " / VADA800927HSRSRL05", OU = Sucursal 1 issuer=CN = AC UAT, O = SERVICIO DE ADMINISTRACION TRIBUTARIA, OU = SAT-IES Authority, emailAddress = oscar.martinez@sat.gob.mx, street = 3ra cerrada de caliz, postalCode = 06370, C = MX, ST = CIUDAD DE MEXICO, L = COYOACAN, x500UniqueIdentifier = 2.5.4.45, unstructuredName = responsable: ACDMA-SAT
--------------------------------------------------------------------------------
/tests/Resources/test.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lsnepomuceno/laravel-a1-pdf-sign/bdc396dac6f45966a4532b70ae15b7a62643be6f/tests/Resources/test.pdf
--------------------------------------------------------------------------------
/tests/SealImageTest.php:
--------------------------------------------------------------------------------
1 | makeDebugCertificate();
35 |
36 | $image = SealImage::fromCert($cert);
37 |
38 | $interventionImg = new IMG(driver: new GDDriver);
39 | $interventionImg = $interventionImg->read($image);
40 |
41 | $this->assertEqualsIgnoringCase('image/png', $interventionImg->toPng()->mediaType());
42 | $this->assertEquals(590, $interventionImg->width());
43 | $this->assertEquals(295, $interventionImg->height());
44 | }
45 |
46 | /**
47 | * @throws CertificateOutputNotFoundException
48 | * @throws FileNotFoundException
49 | * @throws InvalidCertificateContentException
50 | * @throws InvalidPFXException
51 | * @throws Invalidx509PrivateKeyException
52 | * @throws ProcessRunTimeException
53 | * @throws InvalidPdfSignModeTypeException
54 | * @throws Throwable
55 | * @throw \Intervention\Image\ImageManager\Exception\NotReadableException
56 | */
57 | public function testInsertSealImageOnPdfFile()
58 | {
59 | $cert = new ManageCert;
60 | $cert->makeDebugCertificate();
61 |
62 | $image = SealImage::fromCert($cert);
63 | $imagePath = a1TempDir(true, '.png');
64 | File::put($imagePath, $image);
65 | $this->assertTrue(File::exists($imagePath));
66 |
67 | $pdfPath = a1TempDir(true, '.pdf');
68 | try {
69 | $pdf = new SignaturePdf(__DIR__ . '/Resources/test.pdf', $cert);
70 | $resource = $pdf->setImage($imagePath)->signature();
71 | File::put($pdfPath, $resource);
72 | } catch (Throwable $e) {
73 | throw new $e;
74 | }
75 |
76 | $this->assertTrue(File::exists($pdfPath));
77 |
78 | $validation = validatePdfSignature($pdfPath);
79 | $this->assertTrue($validation->isValidated);
80 |
81 | File::delete([$imagePath, $pdfPath]);
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/Sign/ValidatePdfSignature/ProcessDataInfoFunctionTest.php:
--------------------------------------------------------------------------------
1 | > $expectedResponse
35 | *
36 | * @return void
37 | */
38 | public function testProcessDataToInfoFunction(array $expectedResponse, string $content)
39 | {
40 | $method = new ReflectionMethod(
41 | '\LSNepomuceno\LaravelA1PdfSign\Sign\ValidatePdfSignature',
42 | 'processDataToInfo'
43 | );
44 | $method->setAccessible(true);
45 |
46 | $data = $method->invoke(new ValidatePdfSignature(), $content);
47 | $this->assertSame($expectedResponse, $data);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | getFilename() !== '.gitkeep') {
19 | File::delete($file->getPathname());
20 | }
21 | }
22 | }
23 | parent::tearDown();
24 | }
25 |
26 | protected function getPackageProviders($app): array
27 | {
28 | return [
29 | LaravelA1PdfSignServiceProvider::class,
30 | ];
31 | }
32 | }
33 |
--------------------------------------------------------------------------------