Vse o WEB
Информация и размышления о Web технологиях

Динамическое добавление пароля к PDF файлам

В одном из своих проектов я столкнулся с задачей динамического добавления пароля к PDF файлу для его защиты. Чтобы этого добиться, было решено использовать библиотеку PDFI от Setasign, а точнее класс FpdiProtection. Эта статья рассказывает о том, как это можно сделать, а также дает ответ на вопрос, как обойти ограничение бесплатной библиотеки.

Прежде всего нам понадобится сама библиотека. Для этого устанавливаем ее с помощью composer:

composer require setasign/fpdf "~1.8" 
composer require setasign/fpdi-protection "~2.0"

 

Во время инсталляции также composer установит setasign/fpdi.

 

Создание класса помощника

Чтобы упростить защиту PDF файлов, создадим класс помощник, который назовем PasswordProtectPDF, который приведен ниже. Этот класс будет выполнять импорт каждой страницы из исходного PDF файла в новый и сохранять его по указанному пути.

<?php

namespace App\Helper;

use setasign\Fpdi\Fpdi;
use setasign\Fpdi\PdfReader;
use setasign\FpdiProtection\FpdiProtection;

class PasswordProtectPDF
{
    protected $pdf = null;

    public function __construct($file, $user_pass, $master_pass = null)
    {
        $this->protect($file, $user_pass, $master_pass);
    }

    public function protect($file, $user_pass, $master_pass = null)
    {
        $pdf = new FpdiProtection();

        # указываем путь PDF файла, который защищаем
        $pagecount = $pdf->setSourceFile($file);

        # копируем все страница исходного файла
        for ($i = 1; $i <= $pagecount; $i++) {
            $tplidx = $pdf->importPage($i);

            $specs = $pdf->getTemplateSize($tplidx);

            # устанавливаем правильную ориентация, ширину и высоту 
            $pdf->addPage($specs['orientation'], [ $specs['width'], $specs['height'] ]);
            $pdf->useTemplate($tplidx);
        }

        # устанавливаем пользовательский и мастер пароль
        # мастер пароль позволяет открыть полный доступ к PDF файлу
        $pdf->setProtection([ 
            FpdiProtection::PERM_PRINT, 
            FpdiProtection::PERM_DIGITAL_PRINT 
        ], $user_pass, $master_pass);

        # устанавливает имя приложения как создатель
        $pdf->SetCreator("My Company ltd.");

        $this->pdf = $pdf;

        return $this;
    }

    public function setTitle($title)
    {
        # указываем название документа, отображаемое в окне
        $this->pdf->SetTitle($title);

        return $this;
    }

    /**
     * I = Встроенный PDF
     * S = PDF в виде строки, которую вы можете обрабатывать вручную (например, с ручными заголовками для принудительного рендеринга или загрузки)
     * D = Принудительная загрузка PDF-файла на устройство пользователя с использованием второго параметра в качестве имени файла.
     * F = Сохранение PDF-файла на сервер в виде файла с использованием второго параметра в качестве пути и имени файла.
     */
    public function output($name, $type = 'I')
    {
        # выводим документ
        if ($type === 'S') {
            return $this->pdf->Output($type, $name);
        } else {
            $this->pdf->Output($type, $name);
        }

        return $this;
    }

    # cохранить PDF как файл на сервере
    public function saveAsFile($path)
    {
        $this->pdf->Output('F', $path);
    }

}

 

Пример использования

Как видно из кода класса, он позволяет сохранить защищенный файл различными способами. Конкретно в моем случае защиту PDF файлов нужно было встроить в Magento 2 магазин, и при каждом заказе файл защищался уникальным паролем.

<?php

require "PasswordProtectPDF.php";

function generatePassword() {
    $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
    $pass = array(); //remember to declare $pass as an array
    $alphaLength = strlen($alphabet) - 1; //put the length -1 in cache
    for ($i = 0; $i < 8; $i++) {
        $n = rand(0, $alphaLength);
        $pass[] = $alphabet[$n];
    }
    return implode($pass); //turn the array into a string
}

function saveProtectedPdfAs($file,$protectedFile, $password) {
    try {
        $pdf = new PasswordProtectPDF($file, $password);
        $pdf->setTitle(basename($file));
        $pdf->saveAsFile($protectedFile);
    } catch (\Exception $e) {
        if ($e->getCode() == 267) {
            throw new \Exception('Компрессия данного файла не поддерживается.');
        }
        throw new \Exception('Ошибка генерации PDF файла.');
    }
}

$input = 'magazine.pdf';
$password = generatePassword();

saveProtectedPdfAs($input, 'magazine_protected.pdf', $password);

    

 

Возможные проблемы и их решение

Как видите функция saveProtectedPdfAs содержит конструкцию try...catch и это неспроста. Библиотека setasign/fpdi, которая используется для чтения чтения PDF файла поставляется в виде бесплатной и платной версии. Бесплатная версия всем хороша и выполняет свой функционал, но только для PDF файлов версией не выше 1.4. Платная же версия лишена данного недостатка, но цена ее кусается немного - 100 EUR персональная лицензия и 400 EUR лицензия для команды. Одним словом покупка лицензии была нецелесообразной для одного лишь проекта, а заказчик делал упор на то, что версии файлов могут быть разные.

Немного погуглив возможные решения, я остановился на наиболее простом и приемлемом с моей точки зрения - использование ghostscript для конвертирования PDF файлов (тем более он уже был установлен на сервере). Добавляем совсем немного кода и получаем желаемый результат:
 

function convertPdf($file) {
    $backup = $file . '.back';
    @copy($file, $backup);

    # convert PDF file to version 1.4 using ghostscript
    shell_exec( "gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dNOPAUSE -dQUIET -dBATCH -sOutputFile={$file} {$backup}");
}

 

Вызываем данную функцию перед защитой файла:

<?php

$input = 'magazine.pdf';
$password = generatePassword();


convertPdf($input); # конвертируем исходные PDF в поддерживаемую версию

saveProtectedPdfAs($input, 'magazine_protected.pdf', $password); # защищаем файл паролем

 

Заключение

Приведенный в данной статье код может быть не оптимальным, но достаточен для обучающих целей. Если статья помогла решить проблему и сохранила время, значит она была написана не зря =)

Не стесняйтесь, задавайте вопросы, пишите предложения в комментариях.

Наверх