Commit 03e0a2f0 authored by Karamel's avatar Karamel
Browse files

Add archive models, API, tests and access in /fiscal/.

parent 2d465bee
......@@ -7,7 +7,10 @@ The semantic of version number is 'Level.Version'. Level is for compatibility be
## [Unreleased]
WARNING: this version includes changes to the database. Please read the changed section carefully.
WARNING: this version includes changes to the database. Please read the added and changed sections carefully.
### Added
- Archives and GPG signing. Update your databases by running res/database/<type>/addarchives.sql and add add your signing key to GPG and update your configuration file to add the fingerprint of your key (see README.md).
### Changed
- Perpetual CS is added to cash sessions. Update your databases by running bin/addperpetualcs.php on all accounts. The perpetual CS is computed server-side when writing a closed cash without including it to preserve retro-compatibility, but clients should be updated to send it along the other sums.
......
......@@ -11,15 +11,15 @@ It is made in [PHP] with [PDO], [MySQL] and [PostgreSQL]. It is the backbone of
### Prerequisites
Please install the following packages (for a Debian 9 based system)
Please install the following packages (for a Debian 10 based system)
PHP: `php7.0 php7.0-cli php7.0-gd php7.0-intl php7.0-xml php7.0-mbstring php7.0-zip`
PHP (at least 7.0): `php php-cli php-gd php-intl php-xml php-mbstring php-zip php-gnupg`
With Apache: `apache2 libapache2-mod-php7.0`
With Apache: `apache2 libapache2-mod-php`
With Postgresql (recommended): `postgresql postgresql-client php7.0-pgsql`, you will require to set `md5` authentication for pasteque users instead of `peer`
With Postgresql (recommended): `postgresql postgresql-client php-pgsql`, you will require to set `md5` authentication for pasteque users instead of `peer`
With MariaDB (MySQL, slower): `mariadb-server-10.1 mariadb-client-10.1 php7.0-mysql`
With MariaDB (MySQL, slower): `mariadb-server-10.1 mariadb-client-10.1 php-mysql`
### From a release package
......@@ -70,6 +70,39 @@ Make sure the web server user can read the config file and the user ini files.
To initialize the database, use the sql scripts in `res/database/<type>/schema.sql`. The default data required for a running instance are not yet included, you can find a script at https://downloads.pasteque.org/api/scripts/
#### GPG initialization
GPG signing is required to conform to the LF2016. It enables signing archives to make sure they cannot be modified when stored outside Pasteque.
To disable GPG, set `gpg/enabled` to `false` (for testing purposes or when conformity is not required) in your configuration file. When disabled, archive generation is not available.
GPG must be installed. Check [GnuPG website](https://gnupg.org/download/index.html) if it is not already installed.
Then you should create a dedicated key for signing archives. PHP requires the key to have no passphrase to be able to generate archives automatically. Check [guides](https://gnupg.org/documentation/guides.html) on GnuPG's website again to create or import one.
For example to create a key in a dedicated keyring:
```
gpg --homedir <pasteque>/config/gpg --full-generate-key
```
or to import them
```
gpg --homedir <pasteque>/config/gpg --import-key <public key file>
gpg --homedir <pasteque>/config/gpg --import-key <secret key file>
```
If everything went well, you should see your key and it's fingerprint with
```
gpg --homedir <pasteque>/config/gpg --list-keys
```
In your configuration file put the fingerprint of your key in `gpg/fingerprint`. If you used an other homedir, you must also change `gpg/path`.
Archives are not generated from http requests as they would probably not fit in the limit of the web server. Set a cron task to run `php bin/createarchive.php <user id>` to generate archives outside the web server or run the script manually.
#### Web server configuration
##### Apache
......
<?php
// Import script. Public Domain, CC-0, WTFPL or anything you want.
// Licensed under GPLv3 or any later by Pasteque.
namespace Pasteque\bin;
use \Pasteque\Server\AppContext;
use \Pasteque\Server\API\ArchiveAPI;
if ($argc < 2) {
die("createarchive.php <user_id> [max_exec_time]\n"
. " Run the generation of archives one after the other.\n"
. " user_id the pasteque user\n"
. " max_exec_time Stop generating next pending archives when the\n"
. " last generation exceeded time limit (in seconds).\n");
}
$ptUser = $argv[1];
$timeLimit = null;
if ($argc > 2) {
$timeLimet = intval($argv[2]);
}
$projectRoot = dirname(__DIR__);
require_once $projectRoot . '/vendor/autoload.php';
// Set v8
$cfgFile = $projectRoot . '/config/config.ini';
if (!is_readable($cfgFile)) {
// Check for a moved configuration
$envCfgFile = getenv('PT_CONFIG_' . preg_replace('/[^[:alnum:]]/', '_', $projectRoot));
if (($envCfgFile === false) || !is_readable($envCfgFile)) {
die('No config file found');
}
$cfgFile = $envCfgFile;
}
$ptApp = AppContext::loadFromConfig(parse_ini_file($cfgFile));
unset($cfgFile);
if ($ptApp->getDbModule()->getDatabase($ptUser) === false) {
die(sprintf("Pasteque user %s not found\n", $ptUser));
}
$ptApp->login($ptApp->getIdentModule()->getUser($ptUser));
$dao = $ptApp->getDao();
$startTime = new \DateTime();
$startTime = $startTime->getTimestamp();
$api = ArchiveAPI::fromApp($ptApp);
$request = $api->getFirstAvailableRequest();
while ($request != null) {
$now = new \DateTime();
$now = $now->getTimestamp();
if (($timeLimit != null) && ($now - $startTime > $timeLimit)) {
break;
}
$api->createArchive($request->getId());
$request = $api->getFirstAvailableRequest();
}
......@@ -66,6 +66,24 @@ jwt_secret = "<Change me>"
;allowed_origin[] = http://<domain>
;allowed_origin[] = http://<other domain>
; GnuPG Signing
;--------------
; Configure the key to use to sign archives. Both the public and secret keys
; must have been imported or created within your gpg keyring.
; See https://gnupg.org/documentation/guides.html to create and export or
; import keys. Use --homedir to point to the one set in gpg/path.
; The keys must not be protected by a password to work, so please use a
; dedicated key pair here and not your personal or business key.
; When disabled, (enabled by default), compliance with LF2016 is broken.
gpg/enabled = true
; Directory for the gpg keyring. Once the keys are imported there, it can be set
; to read-only.
gpg/path = "./config/gpg"
; The fingerprint of the key to use. This is the hexadecimal id you get with
; gpg --homedir <gpg/path> --list-keys
gpg/fingerprint = "1234123412341234123412341234123412341234"
; Thumbnail compression
;----------------------
; Thumbnail max width in px. Comment or set to 0 to disable thumbnailing.
......
......@@ -7,6 +7,9 @@ database/port = 3306
database/name = "pasteque"
database/user = "user"
database/password = "password"
; GPG config, path are relative to pasteque directory when starting with '.'
gpg/path = "./config/gpg"
gpg/fingerprint = "1234123412341234123412341234123412341234"
; Http tests target a valid local test instance
; that is tore down after each calls, thus must be this one.
; The test user must use the same database than the one
......
......@@ -163,6 +163,36 @@ $app->any('/fiscal/sequence/{sequence}/tickets/',
}
});
function fiscalHomePage($ptApp, $response) {
$data = [];
$apiResult = APICaller::run($ptApp, 'fiscal', 'getSequences');
if ($apiResult->getStatus() == APIResult::STATUS_CALL_OK) {
$data['sequences'] = $apiResult->getContent();
} else {
fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
return $response;
}
if ($ptApp->isGPGEnabled()) {
$data['gpg'] = true;
$apiResult = APICaller::run($ptApp, 'archive', 'listArchives');
if ($apiResult->getStatus() == APIResult::STATUS_CALL_OK) {
$data['archives'] = $apiResult->getContent();
} else {
fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
return $response;
}
$apiResult = APICaller::run($ptApp, 'archive', 'getRequests');
if ($apiResult->getStatus() == APIResult::STATUS_CALL_OK) {
$data['archiverequests'] = $apiResult->getContent();
} else {
fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
return $response;
}
}
fiscalTpl($ptApp, $response, 'menu.php', $data);
return $response;
}
/** Fiscal home page */
$app->any('/fiscal/',
function ($request, $response, $args) {
......@@ -172,14 +202,77 @@ $app->any('/fiscal/',
fiscalTpl($ptApp, $response, 'login.php');
return $response;
} else {
$apiResult = APICaller::run($ptApp, 'fiscal', 'getSequences');
if ($apiResult->getStatus() == APIResult::STATUS_CALL_OK) {
$data = $apiResult->getContent();
fiscalTpl($ptApp, $response, 'menu.php', $data);
} else {
fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
return fiscalHomePage($ptApp, $response);
}
});
$app->POST('/fiscal/createarchive',
function ($request, $response, $args) {
$ptApp = $this->get('settings')['ptApp'];
$response = fiscalLogin($this, $ptApp, $request, $response);
if ($ptApp->getCurrentUser() == null) {
fiscalTpl($ptApp, $response, 'login.php');
return $response;
}
if (!$ptApp->isGPGEnabled()) {
return $response->withStatus(403);
}
$queryParams = $request->getParsedBody();
$dateStart = DateUtils::readDate($queryParams['dateStart']);
$dateStop = DateUtils::readDate($queryParams['dateStop']);
$apiResult = APICaller::run($ptApp, 'archive', 'addRequest',
[$dateStart, $dateStop]);
if ($apiResult->getStatus() == APIResult::STATUS_CALL_OK) {
return $response->withRedirect('../fiscal/');
} else {
return fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
}
});
/** Download an archive */
$app->GET('/fiscal/archive/{number}',
function ($request, $response, $args) {
$ptApp = $this->get('settings')['ptApp'];
$response = fiscalLogin($this, $ptApp, $request, $response);
if ($ptApp->getCurrentUser() == null) {
fiscalTpl($ptApp, $response, 'login.php');
return $response;
}
$apiRes = APICaller::run($ptApp, 'archive', 'getArchive', $args['number']);
if ($apiRes->getStatus() == APIResult::STATUS_CALL_OK) {
$archive = $apiRes->getContent();
if ($archive == null) {
return $response->withStatus(404);
}
$content = $archive->getContent();
$contentLength = null;
switch (gettype($content)) {
case 'resource':
$contentLength = filesize($content);
break;
default:
$contentLength = strlen($content);
}
$response = $response->withHeader('content-type',
'application/pgp-encrypted');
$response = $response->withHeader('content-length', $contentLength);
$response = $response->withHeader('content-disposition',
sprintf('attachment; filename="archive_pasteque_%d.gpg"',
$archive->getNumber()));
$body = $response->getBody();
switch (gettype($content)) {
case 'resource':
while (!feof($content)) {
$body->write(fread($content, 20480));
}
break;
default:
$body->write($content);
}
return $response;
} else {
fiscalTpl($ptApp, $response, 'apierror.php', $apiResult);
return $response;
}
});
......
......@@ -4,11 +4,71 @@ function render($ptApp, $data) {
$ret = '<h1>Consultation des données fiscale</h1>';
$ret .= '<h2>Lister tous les tickets par caisse</h2>';
$ret .= '<ul>';
foreach ($data as $sequence) {
foreach ($data['sequences'] as $sequence) {
$ret .= '<li>' . htmlspecialchars($sequence) . ' : <a href="./sequence/' . htmlspecialchars($sequence) . '/z/" target="_blank">Tickets Z</a> <a href="./sequence/' . htmlspecialchars($sequence) . '/tickets/" target="_blank">Tickets</a></li>';
}
$ret .= '</ul>';
if (!empty($data['gpg'])) {
$ret .= '<h2>Archives</h2>';
if (count($data['archives']) > 0) {
$ret .= '<h3>Archives disponibles</h3>';
$ret .= "<p>Les archives sont à conserver pendant au moins 6 ans à partir de la date de la dernière opération. Veillez à en conserver une ou plusieurs copies en endroit sûr. Celle-ci sont signées via le standard OpenPGP pour assurer leur non altérabilité. Pour lire une archive, déchiffrez là à l'aide d'un client PGP (tel que <a href=\"https://gnupg.org\">GnuPG</a>) avec la clé publique que vous a fourni votre prestataire Pastèque. Conservez l'archive signée ainsi que la clé publique pour garantir l'intégrité par rapport à l'archive originale.</p>";
$ret .= '<ul>';
foreach ($data['archives'] as $archive) {
$num = $archive->get('number');
if ($num == 0) {
continue;
}
$info = $archive->get('info');
$name = "";
if ($info->get('name') != null) {
$name = $info->get('name');
} elseif (($info->get('dateStart') != null)
&& ($info->get('dateStop') != null)) {
$start = \DateTime::createFromFormat('Y-m-d H:i:s',
$info->get('dateStart'));
$stop = \DateTime::createFromFormat('Y-m-d H:i:s',
$info->get('dateStop'));
$name = sprintf('du %s au %s',
$start->format('d/m/Y H:i:s'),
$stop->format('d/m/Y H:i:s'));
}
$generated = "";
if ($info->get('generated') != null) {
$genDate = \DateTime::createFromFormat('Y-m-d H:i:s',
$info->get('generated'));
$generated = sprintf('créée le %s à %s',
$genDate->format('d/m/Y'),
$genDate->format('H:i:s'));
}
$ret .= sprintf('<li><a href="./archive/%d">%d - %s</a> %s</li>',
$num, $num, $name, $generated);
} // TODO: add signature check
$ret .= '</ul>';
} else {
$ret .= "<p>Aucune archive n'a été générée.</p>";
}
$ret .= '<h3>Archives en cours de création</h3>';
if (count($data['archiverequests']) > 0) {
$ret .= '<ul>';
foreach ($data['archiverequests'] as $ar) {
$ret .= '<li>' . $ar->getId() . ' - du ' . $ar->getStartDate()->format('d/m/Y H:i:s') . ' au ' . $ar->getStopDate()->format('d/m/Y H:i:s');
}
$ret .= '</ul>';
} else {
$ret .= "<p>Aucune archive n'est en attente de création.</p>";
}
$ret .= '<h3>Créer une nouvelle archive</h3>';
$ret .= "<p>Choisissez la période à archiver et appuyez sur \"Envoyer\". Une demande de création d'archive sera enregistrée. L'archive elle-même sera disponible plus tard. Contactez votre prestataire Pastèque pour plus d'information sur les délais de génération des archives.</p>";
$ret .= "L'archive ne peut couvrir plus d'une année.</p>";
$ret .= '<form action="createarchive" method="post">';
$ret .= '<label for="period-from">Depuis le </label><input type="date" name="dateStart" id="period-from" placeholder="aaaa/mm/jj" required="true" /> ';
$ret .= "<label for=\"period-to\">jusqu'au </label><input type=\"date\" name=\"dateStop\" id=\"period-to\" placeholder=\"aaaa/mm/jj\" required=\"true\" /> <input type=\"submit\" value=\"Envoyer\" />";
$ret .= '<p>Les dates correspondent aux date de reversement des tickets. Si une caisse est hors ligne à la date de fin de la période, ses tickets seront intégrés dans une archive qui couvrira la date du rétablissement de connexion.</p>';
$ret .= '</form></li>';
}
$ret .= '<h2>Export des tickets</h2>';
$ret .= "<p>L'export des tickets vous permet de transférer une copie sur un miroir. Pour la conservation de vos données, utilisez une archive.</p>";
$ret .= '<ul>';
$ret .= '<li><a href="./export?period=P7D">Exporter une semaine</a></li>';
$ret .= '<li><a href="./export?period=P14D">Exporter deux semaines</a></li>';
......
<?php
// Pastèque API
//
// Copyright (C)
// 2012 Scil (http://scil.coop)
// 2017 Karamel, Association Pastèque (karamel@creativekara.fr, https://pasteque.org)
//
// This file is part of Pastèque.
//
// Pastèque is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pastèque is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Pastèque. If not, see <http://www.gnu.org/licenses/>.
namespace Pasteque\Server\API;
use \Pasteque\Server\Exception\ConfigurationException;
use \Pasteque\Server\Exception\InvalidFieldException;
use \Pasteque\Server\Exception\RecordNotFoundException;
use \Pasteque\Server\Model\Archive;
use \Pasteque\Server\Model\ArchiveRequest;
use \Pasteque\Server\Model\FiscalTicket;
use \Pasteque\Server\Model\GenericModel;
use \Pasteque\Server\System\DateUtils;
use \Pasteque\Server\System\DAO\DAOCondition;
class ArchiveAPI implements API
{
/** The maximum number of fiscal tickets in a single file of an archive. */
const BATCH_SIZE = 100;
protected $dao;
protected $gpg;
protected $account;
protected $fingerprint;
protected $signEnabled;
public function __construct($dao, $account, $path, $fingerprint) {
$this->dao = $dao;
$this->account = $account;
putenv(sprintf('GNUPGHOME=%s', $path));
$this->gpg = new \gnupg();
if (!$this->gpg->addsignkey($fingerprint)) {
$this->signEnabled = false;
} else {
$this->signEnabled = true;
}
$this->gpg->setarmor(0);
$this->gpg->setsignmode(\GNUPG_SIG_MODE_NORMAL);
$this->fingerprint = $fingerprint;
}
public static function fromApp($app) {
if (!$app->isGPGEnabled()) {
return false;
}
return new static($app->getDao(), $app->getCurrentUser()['id'],
$app->getGPGPath(), $app->getKeyFingerprint());
}
public function canSign() {
return $this->signEnabled;
}
public function getFingerprint() {
return $this->fingerprint;
}
public function getAccount() {
return $this->account;
}
/** Get all the requests diregarding their state. */
public function getRequests() {
return $this->dao->search(ArchiveRequest::class, null, null, null,
'id');
}
/**
* Get the first request that is not currently being processed.
* If a request is currently being generated, don't get an other one
* to prevent holding the cpu.
*/
public function getFirstAvailableRequest() {
$searchProcessing = $this->dao->search(ArchiveRequest::class,
new DAOCondition('processing', '=', true), 1);
if (count($searchProcessing) > 0) {
return null;
}
$search = $this->dao->search(ArchiveRequest::class,
new DAOCondition('processing', '=', false),
1, null, 'id');
if (count($search) == 1) {
return $search[0];
} else {
return null;
}
}
/**
* Register a single request.
* @param mixed $start The representation of the starting date, compatible
* with DateUtils::readDate.
* @param mixed $stop The representation of the stop date, compatible with
* DateUtils::readDate.
* @throws InvalidFieldException, with constraint CSTR_INVALID_DATE when
* $start or $stop could not be read as a date; with constraint
* CSTR_INVALID_DATERANGE when stop is anterior to start or when the time
* interval is greater than one year + one day.
*/
public function addRequest($start, $stop) {
// Defensive checks
$startDate = DateUtils::readDate($start);
$stopDate = DateUtils::readDate($stop);
if ($startDate === false || $startDate === null) {
throw new InvalidFieldException(
InvalidFieldException::CSTR_INVALID_DATE,
ArchiveRequest::class, 'startDate',
['startDate' => $start, 'stopDate' => $stop],
$start);
}
if ($stopDate === false || $stopDate === null) {
throw new InvalidFieldException(
InvalidFieldException::CSTR_INVALID_DATE,
ArchiveRequest::class, 'stopDate',
['startDate' => $start, 'stopDate' => $stop],
$stop);
}
$interval = $startDate->diff($stopDate);
if ($interval->invert === 1) {
throw new InvalidFieldException(
InvalidFieldException::CSTR_INVALID_DATERANGE,
ArchiveRequest::class, 'startDate-stopDate',
['startDate' => $startDate->format('Y-m-d H:i:s'),
'stopDate' => $stopDate->format('Y-m-d H:i:s')],
$interval->format('-%y-%m-%d %H:%i:%s'));
}
if ($interval->y > 1
|| (($interval->y == 1) && ($interval->m > 0 ||
($interval->m == 0 && $interval->d > 0)))) {
throw new InvalidFieldException(
InvalidFieldException::CSTR_INVALID_DATERANGE,
ArchiveRequest::class, 'startDate-stopDate',
['startDate' => $start->format('Y-m-d H:i:s'),
'stopDate' => $stop->format('Y-m-d H:i:s')],
$interval->format('%y-%m-%d %H:%i:%s'));
}
// Write the request
$request = new ArchiveRequest();
$request->setStartDate($startDate);
$request->setStopDate($stopDate);
$this->dao->write($request);
$this->dao->commit();
return $request;
}
/**
* Mark an existing request being processed.
* @return True when the request was updated. False when the request
* was not previously registered.
*/
private function markProcessing($request) {
$snap = $this->dao->readSnapshot(ArchiveRequest::class,
$request->getId());
if ($snap === null) {
return false;
}
// Copy to be sure the request is not updated outside processing.
$request->setStartDate($snap->getStartDate());
$request->setStopDate($snap->getStopDate());
$request->setProcessing(true);
$this->dao->write($request);
$this->dao->commit();
return true;
}
/**
* Get the list of [number = <id>, info = [],
* signature = <sign>] of each existing archives.
* @return GenericModel[] The list of archives.
*/
public function listArchives() {
$em = $this->dao->getEntityManager();
$query = $em->createQuery('select a.number, a.info from ' . Archive::class . ' a order by a.number asc');
$archives = $query->getResult();
$ret = [];
foreach ($archives as $a) {
$obj = new GenericModel();
$obj->set('number', $a['number']);
$info = json_decode($a['info'], true);
if ($info != null) {
$infoObj = new GenericModel();
foreach ($info as $k => $v) {
$infoObj->set($k, $v);
}
$obj->set('info', $infoObj);
}
$ret[] = $obj;
}
return $ret;
}
public function getArchive($number) {
return $this->dao->read(Archive::class, $number);
}
public function getArchiveContent($number) {
$archive = $this->dao->read(Archive::class, $number);
if ($archive == null) {
return null;
}
return $archive->getContent();
}
public function getLastArchive() {
$lastArchive = null;
$lastSearch = $this->dao->search(Archive::class, null, 1, null,
'-number');
if (count($lastSearch) > 0) {
$lastArchive = $lastSearch[0];
if ($lastArchive->getNumber() === 0) {
// Ignore EOS.
return null;
}
return $lastArchive;
}
// No archive found.
return null;
}
protected function checkSignature($fTkt, $prevFTkt) {
if ($fTkt->getNumber() == 1) {
return $fTkt->checkSignature(null);
}
if ($prevFTkt == null) {
$prevFTkt = $this->dao->read(FiscalTicket::class,
['type' => $fTkt->getType(),