Compare commits

...
90 Commits
Author SHA1 Message Date
silisio cadb633d51 Make environment loading compatible with Plesk 2026-07-22 10:11:15 +02:00
silisio 9518bb8c6a Load parent environment file automatically 2026-07-22 09:10:59 +02:00
silisio 9501fdcd25 Add deployment environment template 2026-07-22 08:58:29 +02:00
silisio 4b9a5d7e78 Add Turnstile protection and harden export workflow 2026-07-22 08:54:31 +02:00
Roman_Schenk 67c7b5ccff Update taskExecuter.php 2026-05-22 19:00:37 +02:00
Roman_Schenk 7a143dc18a Update index.php 2026-05-21 18:20:36 +02:00
Roman_Schenk 59b1a95e02 Update taskExecuter.php 2026-05-17 16:37:27 +02:00
Roman_Schenk 5011f714e1 Send error Message as E-Mail if no results 2026-05-17 16:22:13 +02:00
Roman_Schenk 8ac18050a0 Update index.php 2026-05-17 16:19:36 +02:00
Roman_Schenk 60d70072b1 don't convert to xlsx, send as csv instead to prevent memory exhausting 2026-05-17 16:02:18 +02:00
Roman_Schenk 3bbf1f8ddc revert 2d7d975c8b
revert Send List as csv instead of converting to xlsx to prevent memory exhausting
2026-05-17 16:00:28 +02:00
Roman_Schenk 2d7d975c8b Send List as csv instead of converting to xlsx to prevent memory exhausting 2026-05-17 15:50:35 +02:00
Schrom01 a914efc011 Frontend Validierung ob mindestens ein Ort und eine Rechtsform gewählt wurde um leeres Ergebnis zu vermeiden. 2026-04-23 11:14:49 +02:00
Roman_Schenk 262943a425 New Silias E-Mail Adress 2026-03-16 17:18:48 +01:00
Roman_Schenk 0148b41353 Button to select and unselect all legal forms 2026-03-11 21:55:59 +01:00
Roman_Schenk 9a8cc0689a added careOf as Information 2026-03-11 20:25:13 +01:00
Roman_Schenk 5f01b21c58 Remove unused alternative names of cummunities to prevent warnings 2026-03-11 20:01:55 +01:00
Roman_Schenk 65fb0f9dce revert d706864eed
revert export careOf
2026-03-11 19:38:03 +01:00
Roman_Schenk d706864eed export careOf 2026-03-11 15:33:59 +01:00
silisio 1bc4e6e36f Upload files to "img" 2026-02-26 12:16:04 +01:00
silisio 0fcfa01262 Update submit.php
UI styling
2026-02-26 12:14:48 +01:00
Roman_Schenk c18294336f Update index.php 2026-02-26 11:43:18 +01:00
silisio df937c43cb Update css/custom.css
ui update
2026-02-26 04:03:20 +01:00
silisio 467fc2219a Update index.php
UI Update
2026-02-26 04:02:50 +01:00
silisio dd08482038 Update cronjobs.sh
set to work also if in different folder structure and improved logging
2026-02-26 02:46:45 +01:00
silisio ac82e9b5cc Update taskExecuter.php
fixed depreciated functions (causing log flodding)
2026-02-26 02:45:50 +01:00
silisio f29f97dd5f Update emailSender.php 2026-02-26 00:46:51 +01:00
Roman_Schenk ac56383edf check if valid email before executing 2025-11-26 20:58:47 +01:00
Roman_Schenk 881e877760 Update "from" E-Mail Adress 2024-11-10 13:44:14 +01:00
schrom01 dbce5f71b9 added some explanation Text 2023-09-30 10:29:00 +02:00
schrom01 6e39c4c71e removed echo in deleteFiles.php 2023-09-28 15:41:43 +02:00
schrom01 c0bfad4445 Task Delete Function 2023-09-26 21:46:44 +02:00
schrom01 33f0836479 update E-Mail Text 2023-09-26 20:39:07 +02:00
schrom01 4f6bd7de91 chaning maximal Execution Time to Hostinger Setting 2023-09-26 18:20:53 +02:00
schrom01 6ec770f27c fixing API Problems when to many results 2023-09-26 17:30:35 +02:00
schrom01 8bd927dd23 reduced minTaskOldness 2023-09-26 15:24:18 +02:00
schrom01 11380e213c Fix Problem legalForm 2023-09-26 14:56:58 +02:00
schrom01 0de1f0dab4 show exit time in shell script 2023-09-26 14:49:39 +02:00
schrom01 d8f6eb147a change times in taskExecuter.php 2023-09-26 14:35:10 +02:00
schrom01 535e374427 type in frontend 2023-09-26 14:32:04 +02:00
schrom01 0b46873ed3 E-Mail sender 2023-09-26 14:27:22 +02:00
schrom01 1ea18c23f8 adding more Info about Company 2023-09-26 14:25:43 +02:00
schrom01 b5c26c48bf fixing E-Mail 2023-09-26 13:37:11 +02:00
schrom01 24a8554368 fixing E-Mail 2023-09-26 13:33:25 +02:00
schrom01 5bddc3be0d fixing E-Mail 2023-09-26 13:26:19 +02:00
schrom01 31b080a2a0 fixing E-Mail 2023-09-26 00:14:34 +02:00
schrom01 9d08aa7b99 fixing E-Mail 2023-09-26 00:13:28 +02:00
schrom01 e82490018c fixing E-Mail 2023-09-26 00:05:49 +02:00
schrom01 74816f193b fixing E-Mail 2023-09-25 23:57:40 +02:00
schrom01 3565c271d4 fixing E-Mail 2023-09-25 23:49:38 +02:00
schrom01 a2c4e969ff fixing E-Mail 2023-09-25 23:31:44 +02:00
schrom01 4faf98e38a showing actual Time in Cronjob Script 2023-09-25 23:23:42 +02:00
schrom01 57c13785b9 showing actual Time in Cronjob Script 2023-09-25 23:11:53 +02:00
schrom01 3370231a63 showing actual Time in Cronjob Script 2023-09-25 23:01:12 +02:00
schrom01 9c74f58bce changed max execution time 2023-09-25 22:54:59 +02:00
schrom01 8c903efb51 sending E-Mail 2023-09-25 22:52:09 +02:00
schrom01 867f2589c7 converting csv to xlsx 2023-09-25 22:22:50 +02:00
schrom01 4f8ea5704e converting csv to xlsx 2023-09-25 20:51:32 +02:00
schrom01 9a59a4116e converting csv to xlsx 2023-09-25 20:18:24 +02:00
schrom01 278a926ddb converting csv to xlsx 2023-09-25 20:16:47 +02:00
schrom01 76fc8f9c37 added Script to start Taskexecuter 2023-09-25 19:44:41 +02:00
schrom01 313a976090 added Script to start Taskexecuter 2023-09-25 19:33:41 +02:00
schrom01 84c7a3ffaf prepared Task Executer 2023-09-25 15:47:24 +02:00
schrom01 3fa9fa6d79 prepared Task Executer 2023-09-25 15:45:26 +02:00
schrom01 b178df10e3 prepared Task Executer 2023-09-25 14:39:00 +02:00
schrom01 bca61d2b9d prepared Task Executer 2023-09-25 14:36:51 +02:00
schrom01 01095093d7 prepared Task Executer 2023-09-25 14:29:53 +02:00
schrom01 91d85232ca api creating task files 2023-09-25 13:49:34 +02:00
schrom01 de681f37b0 api creating task files 2023-09-25 12:43:51 +02:00
schrom01 2f5232e1c7 api creating task files 2023-09-25 12:33:12 +02:00
schrom01 2366370e67 api creating task files 2023-09-25 11:55:08 +02:00
schrom01 9ede060821 Sitze und Rechtsformen aus Zefix API 2023-09-25 11:13:53 +02:00
schrom01 b2332b790b Sitze und Rechtsformen aus Zefix API 2023-08-21 21:20:56 +02:00
schrom01 3dd7361f45 Sitze und Rechtsformen aus Zefix API 2023-08-21 21:20:19 +02:00
schrom01 39f500feec Sitze und Rechtsformen aus Zefix API 2023-08-21 21:19:31 +02:00
schrom01 0d4fe60f45 Sitze und Rechtsformen aus Zefix API 2023-08-21 21:16:49 +02:00
schrom01 d294253a27 Sitze und Rechtsformen aus Zefix API 2023-08-21 21:12:21 +02:00
schrom01 f9701e936f Sitze und Rechtsformen aus Zefix API 2023-08-21 20:58:09 +02:00
schrom01 52287c622f Sitze und Rechtsformen aus Zefix API 2023-08-21 20:56:39 +02:00
schrom01 9493205995 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:54:35 +02:00
schrom01 ca8e9ab474 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:53:53 +02:00
schrom01 1352b1f33a Sitze und Rechtsformen aus Zefix API 2023-08-21 20:53:08 +02:00
schrom01 6d1fcf67b3 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:52:20 +02:00
schrom01 28661a198a Sitze und Rechtsformen aus Zefix API 2023-08-21 20:51:05 +02:00
schrom01 d8e56b297e Sitze und Rechtsformen aus Zefix API 2023-08-21 20:50:29 +02:00
schrom01 f98863ad2a Sitze und Rechtsformen aus Zefix API 2023-08-21 20:49:28 +02:00
schrom01 a5efbc50a6 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:48:36 +02:00
schrom01 8182aed0f7 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:47:59 +02:00
schrom01 abefba9b4d Sitze und Rechtsformen aus Zefix API 2023-08-21 20:46:37 +02:00
schrom01 8a4db6b905 Sitze und Rechtsformen aus Zefix API 2023-08-21 20:46:04 +02:00
30 changed files with 11889 additions and 4050 deletions
+47
View File
@@ -0,0 +1,47 @@
# Copy this file one folder above the project as .env and replace every placeholder.
# The application loads that parent .env automatically.
# Application
APP_ENV=production
PUBLIC_BASE_URL=https://zefix.silias.ch
APP_SECRET=replace-with-output-of-openssl-rand-hex-32
# Leave empty to use ../zefix-private next to the project directory.
ZEFIX_PRIVATE_DIR=
# Cloudflare Turnstile
TURNSTILE_SECRET=replace-with-turnstile-secret
TURNSTILE_ALLOWED_HOSTNAME=zefix.silias.ch
# Zefix API
username=replace-with-zefix-api-username
password=replace-with-zefix-api-password
# SMTP
SMTP_HOST=mxe98c.netcup.net
SMTP_PORT=465
SMTP_SECURE=ssl
SMTP_USERNAME=info@silias.ch
smtppassword=replace-with-smtp-password
EXPORT_BCC_EMAIL=
# Abuse protection and job limits
RATE_LIMIT_IP_MAX=3
RATE_LIMIT_IP_WINDOW_SECONDS=900
RATE_LIMIT_EMAIL_MAX=5
RATE_LIMIT_EMAIL_WINDOW_SECONDS=86400
FORM_MIN_SECONDS=2
FORM_MAX_SECONDS=7200
MAX_SEATS_PER_JOB=500
MAX_LEGAL_FORMS_PER_JOB=50
MAX_REQUESTS_PER_JOB=5000
MAX_QUEUE_SIZE=20
# Retention and background processing
DOWNLOAD_RETENTION_HOURS=48
STALE_TASK_RETENTION_HOURS=168
REFERENCE_CACHE_SECONDS=86400
TASK_EXECUTOR_MAX_SECONDS=120
TASK_MIN_AGE_SECONDS=10
# Only add IP addresses of reverse proxies you control.
TRUSTED_PROXY_IPS=
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+9
View File
@@ -0,0 +1,9 @@
/env_vars.php
/.env
/.env.*
!/.env.example
/tasks/
/download/
/var/
/*.log
/.phpunit.cache/
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* PHPMailer Exception class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
/**
* PHPMailer exception handler.
*
* @author Marcus Bointon <phpmailer@synchromedia.co.uk>
*/
class Exception extends \Exception
{
/**
* Prettify error message output.
*
* @return string
*/
public function errorMessage()
{
return '<strong>' . htmlspecialchars($this->getMessage()) . "</strong><br />\n";
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* PHPMailer - PHP email creation and transport class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
use League\OAuth2\Client\Grant\RefreshToken;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Token\AccessToken;
/**
* OAuth - OAuth2 authentication wrapper class.
* Uses the oauth2-client package from the League of Extraordinary Packages.
*
* @see http://oauth2-client.thephpleague.com
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
*/
class OAuth
{
/**
* An instance of the League OAuth Client Provider.
*
* @var AbstractProvider
*/
protected $provider;
/**
* The current OAuth access token.
*
* @var AccessToken
*/
protected $oauthToken;
/**
* The user's email address, usually used as the login ID
* and also the from address when sending email.
*
* @var string
*/
protected $oauthUserEmail = '';
/**
* The client secret, generated in the app definition of the service you're connecting to.
*
* @var string
*/
protected $oauthClientSecret = '';
/**
* The client ID, generated in the app definition of the service you're connecting to.
*
* @var string
*/
protected $oauthClientId = '';
/**
* The refresh token, used to obtain new AccessTokens.
*
* @var string
*/
protected $oauthRefreshToken = '';
/**
* OAuth constructor.
*
* @param array $options Associative array containing
* `provider`, `userName`, `clientSecret`, `clientId` and `refreshToken` elements
*/
public function __construct($options)
{
$this->provider = $options['provider'];
$this->oauthUserEmail = $options['userName'];
$this->oauthClientSecret = $options['clientSecret'];
$this->oauthClientId = $options['clientId'];
$this->oauthRefreshToken = $options['refreshToken'];
}
/**
* Get a new RefreshToken.
*
* @return RefreshToken
*/
protected function getGrant()
{
return new RefreshToken();
}
/**
* Get a new AccessToken.
*
* @return AccessToken
*/
protected function getToken()
{
return $this->provider->getAccessToken(
$this->getGrant(),
['refresh_token' => $this->oauthRefreshToken]
);
}
/**
* Generate a base64-encoded OAuth token.
*
* @return string
*/
public function getOauth64()
{
//Get a new token if it's not available or has expired
if (null === $this->oauthToken || $this->oauthToken->hasExpired()) {
$this->oauthToken = $this->getToken();
}
return base64_encode(
'user=' .
$this->oauthUserEmail .
"\001auth=Bearer " .
$this->oauthToken .
"\001\001"
);
}
}
+4933
View File
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
<?php
/**
* PHPMailer POP-Before-SMTP Authentication Class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
/**
* PHPMailer POP-Before-SMTP Authentication Class.
* Specifically for PHPMailer to use for RFC1939 POP-before-SMTP authentication.
* 1) This class does not support APOP authentication.
* 2) Opening and closing lots of POP3 connections can be quite slow. If you need
* to send a batch of emails then just perform the authentication once at the start,
* and then loop through your mail sending script. Providing this process doesn't
* take longer than the verification period lasts on your POP3 server, you should be fine.
* 3) This is really ancient technology; you should only need to use it to talk to very old systems.
* 4) This POP3 class is deliberately lightweight and incomplete, implementing just
* enough to do authentication.
* If you want a more complete class there are other POP3 classes for PHP available.
*
* @author Richard Davey (original author) <rich@corephp.co.uk>
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
*/
class POP3
{
/**
* The POP3 PHPMailer Version number.
*
* @var string
*/
const VERSION = '6.3.0';
/**
* Default POP3 port number.
*
* @var int
*/
const DEFAULT_PORT = 110;
/**
* Default timeout in seconds.
*
* @var int
*/
const DEFAULT_TIMEOUT = 30;
/**
* POP3 class debug output mode.
* Debug output level.
* Options:
* @see POP3::DEBUG_OFF: No output
* @see POP3::DEBUG_SERVER: Server messages, connection/server errors
* @see POP3::DEBUG_CLIENT: Client and Server messages, connection/server errors
*
* @var int
*/
public $do_debug = self::DEBUG_OFF;
/**
* POP3 mail server hostname.
*
* @var string
*/
public $host;
/**
* POP3 port number.
*
* @var int
*/
public $port;
/**
* POP3 Timeout Value in seconds.
*
* @var int
*/
public $tval;
/**
* POP3 username.
*
* @var string
*/
public $username;
/**
* POP3 password.
*
* @var string
*/
public $password;
/**
* Resource handle for the POP3 connection socket.
*
* @var resource
*/
protected $pop_conn;
/**
* Are we connected?
*
* @var bool
*/
protected $connected = false;
/**
* Error container.
*
* @var array
*/
protected $errors = [];
/**
* Line break constant.
*/
const LE = "\r\n";
/**
* Debug level for no output.
*
* @var int
*/
const DEBUG_OFF = 0;
/**
* Debug level to show server -> client messages
* also shows clients connection errors or errors from server
*
* @var int
*/
const DEBUG_SERVER = 1;
/**
* Debug level to show client -> server and server -> client messages.
*
* @var int
*/
const DEBUG_CLIENT = 2;
/**
* Simple static wrapper for all-in-one POP before SMTP.
*
* @param string $host The hostname to connect to
* @param int|bool $port The port number to connect to
* @param int|bool $timeout The timeout value
* @param string $username
* @param string $password
* @param int $debug_level
*
* @return bool
*/
public static function popBeforeSmtp(
$host,
$port = false,
$timeout = false,
$username = '',
$password = '',
$debug_level = 0
) {
$pop = new self();
return $pop->authorise($host, $port, $timeout, $username, $password, $debug_level);
}
/**
* Authenticate with a POP3 server.
* A connect, login, disconnect sequence
* appropriate for POP-before SMTP authorisation.
*
* @param string $host The hostname to connect to
* @param int|bool $port The port number to connect to
* @param int|bool $timeout The timeout value
* @param string $username
* @param string $password
* @param int $debug_level
*
* @return bool
*/
public function authorise($host, $port = false, $timeout = false, $username = '', $password = '', $debug_level = 0)
{
$this->host = $host;
//If no port value provided, use default
if (false === $port) {
$this->port = static::DEFAULT_PORT;
} else {
$this->port = (int) $port;
}
//If no timeout value provided, use default
if (false === $timeout) {
$this->tval = static::DEFAULT_TIMEOUT;
} else {
$this->tval = (int) $timeout;
}
$this->do_debug = $debug_level;
$this->username = $username;
$this->password = $password;
//Reset the error log
$this->errors = [];
//Connect
$result = $this->connect($this->host, $this->port, $this->tval);
if ($result) {
$login_result = $this->login($this->username, $this->password);
if ($login_result) {
$this->disconnect();
return true;
}
}
//We need to disconnect regardless of whether the login succeeded
$this->disconnect();
return false;
}
/**
* Connect to a POP3 server.
*
* @param string $host
* @param int|bool $port
* @param int $tval
*
* @return bool
*/
public function connect($host, $port = false, $tval = 30)
{
//Are we already connected?
if ($this->connected) {
return true;
}
//On Windows this will raise a PHP Warning error if the hostname doesn't exist.
//Rather than suppress it with @fsockopen, capture it cleanly instead
set_error_handler([$this, 'catchWarning']);
if (false === $port) {
$port = static::DEFAULT_PORT;
}
//Connect to the POP3 server
$errno = 0;
$errstr = '';
$this->pop_conn = fsockopen(
$host, //POP3 Host
$port, //Port #
$errno, //Error Number
$errstr, //Error Message
$tval
); //Timeout (seconds)
//Restore the error handler
restore_error_handler();
//Did we connect?
if (false === $this->pop_conn) {
//It would appear not...
$this->setError(
"Failed to connect to server $host on port $port. errno: $errno; errstr: $errstr"
);
return false;
}
//Increase the stream time-out
stream_set_timeout($this->pop_conn, $tval, 0);
//Get the POP3 server response
$pop3_response = $this->getResponse();
//Check for the +OK
if ($this->checkResponse($pop3_response)) {
//The connection is established and the POP3 server is talking
$this->connected = true;
return true;
}
return false;
}
/**
* Log in to the POP3 server.
* Does not support APOP (RFC 2828, 4949).
*
* @param string $username
* @param string $password
*
* @return bool
*/
public function login($username = '', $password = '')
{
if (!$this->connected) {
$this->setError('Not connected to POP3 server');
}
if (empty($username)) {
$username = $this->username;
}
if (empty($password)) {
$password = $this->password;
}
//Send the Username
$this->sendString("USER $username" . static::LE);
$pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) {
//Send the Password
$this->sendString("PASS $password" . static::LE);
$pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) {
return true;
}
}
return false;
}
/**
* Disconnect from the POP3 server.
*/
public function disconnect()
{
$this->sendString('QUIT');
//The QUIT command may cause the daemon to exit, which will kill our connection
//So ignore errors here
try {
@fclose($this->pop_conn);
} catch (Exception $e) {
//Do nothing
}
}
/**
* Get a response from the POP3 server.
*
* @param int $size The maximum number of bytes to retrieve
*
* @return string
*/
protected function getResponse($size = 128)
{
$response = fgets($this->pop_conn, $size);
if ($this->do_debug >= self::DEBUG_SERVER) {
echo 'Server -> Client: ', $response;
}
return $response;
}
/**
* Send raw data to the POP3 server.
*
* @param string $string
*
* @return int
*/
protected function sendString($string)
{
if ($this->pop_conn) {
if ($this->do_debug >= self::DEBUG_CLIENT) { //Show client messages when debug >= 2
echo 'Client -> Server: ', $string;
}
return fwrite($this->pop_conn, $string, strlen($string));
}
return 0;
}
/**
* Checks the POP3 server response.
* Looks for for +OK or -ERR.
*
* @param string $string
*
* @return bool
*/
protected function checkResponse($string)
{
if (strpos($string, '+OK') !== 0) {
$this->setError("Server reported an error: $string");
return false;
}
return true;
}
/**
* Add an error to the internal error store.
* Also display debug output if it's enabled.
*
* @param string $error
*/
protected function setError($error)
{
$this->errors[] = $error;
if ($this->do_debug >= self::DEBUG_SERVER) {
echo '<pre>';
foreach ($this->errors as $e) {
print_r($e);
}
echo '</pre>';
}
}
/**
* Get an array of error messages, if any.
*
* @return array
*/
public function getErrors()
{
return $this->errors;
}
/**
* POP3 connection error handler.
*
* @param int $errno
* @param string $errstr
* @param string $errfile
* @param int $errline
*/
protected function catchWarning($errno, $errstr, $errfile, $errline)
{
$this->setError(
'Connecting to the POP3 server raised a PHP warning:' .
"errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline"
);
}
}
+114 -1
View File
@@ -1,2 +1,115 @@
# Zefix_search
# Silias ZEFIX Export
Ein PHP-Dienst, der ZEFIX-Suchaufträge asynchron verarbeitet und den fertigen CSV-Export über einen zeitlich begrenzten Download-Link zustellt.
Vorausgesetzt werden PHP 8.1 oder neuer, die Erweiterungen cURL und JSON sowie ein korrekt konfigurierter CA-Zertifikatsspeicher für die TLS-Prüfung von Cloudflare, ZEFIX und SMTP.
## Missbrauchsschutz
Der Export ist standardmässig **fail-closed**: Ohne vollständig konfigurierte Cloudflare-Turnstile-Schlüssel bleibt der Absende-Button deaktiviert und `submit.php` nimmt keine Aufträge an.
Aktivierte Schutzschichten:
- Cloudflare Turnstile im Managed-Modus, inklusive serverseitiger Siteverify-Prüfung
- Hostname- und Action-Prüfung für Turnstile-Tokens
- signierte Formularzeit und Honeypot-Feld ohne Session-Cookie
- Rate-Limits pro IP-Adresse und E-Mail-Adresse
- harte Grenzen für Orte, Rechtsformen, resultierende API-Aufrufe und Queue-Grösse
- Deduplizierung identischer offener Aufträge
- private Speicherung von Auftragsdaten und Exportdateien ausserhalb des Webroots
- 128-Bit-Download-Token und automatische Ablaufzeit
- CLI-Sperre für Executor, Cleanup und administratives Löschen
- eintägiger Cache für Gemeinden und Rechtsformen
## Erforderliche Umgebungsvariablen
| Variable | Bedeutung |
| --- | --- |
| `TURNSTILE_SECRET` | Geheimer Schlüssel des bestehenden Turnstile-Widgets für Siteverify |
| `APP_SECRET` | Zufälliger geheimer Wert für Formulartoken und pseudonymisierte Rate-Limits |
| `ZEFIX_PRIVATE_DIR` | Optionaler absoluter, dauerhaft beschreibbarer Pfad **ausserhalb** des Webroots; ohne Wert wird `../zefix-private` verwendet |
| `username` | Benutzername der ZEFIX-API |
| `password` | Passwort der ZEFIX-API |
| `smtppassword` | Passwort des SMTP-Kontos |
Verwendet wird ausschliesslich das bestehende Turnstile-Widget `Zefix` mit dem Sitekey `0x4AAAAAAD7CJrWpNiK0-A7h`. Es darf nicht neu erstellt oder rotiert werden. Das Widget muss im Cloudflare-Dashboard als **Managed** mit dem Hostnamen `zefix.silias.ch` konfiguriert sein; Pre-Clearance bleibt deaktiviert. Das Secret gehört ausschliesslich als `TURNSTILE_SECRET` in die Serverumgebung und niemals ins Repository.
Empfohlene Werte:
```text
TURNSTILE_ALLOWED_HOSTNAME=zefix.silias.ch
PUBLIC_BASE_URL=https://zefix.silias.ch
ZEFIX_PRIVATE_DIR=
APP_SECRET=<mindestens 32 zufällige Bytes>
```
Das bisherige, nicht versionierte `env_vars.php` wird für eine schonende Migration weiterhin geladen. Neue Installationen sollten echte Prozess-/PHP-FPM-Umgebungsvariablen verwenden.
Eine vollständige Vorlage ohne echte Zugangsdaten liegt in [`.env.example`](.env.example). Für eine klassische Installation kann sie als `.env` **einen Ordner über dem Projektverzeichnis** abgelegt werden. Diese Datei wird beim Start automatisch geladen. Bereits gesetzte Prozessvariablen und Werte aus der bisherigen `env_vars.php` haben Vorrang.
Beispiel bei einem Projekt unter `/var/www/zefix`:
```text
/var/www/.env # echte, nicht versionierte Zugangsdaten
/var/www/zefix/app.php # Projekt
```
Alternativ kann die Vorlage weiterhin für ein Deployment-Panel, Docker Compose (`env_file`) oder die PHP-FPM-Konfiguration verwendet werden.
## Optionale Konfiguration
| Variable | Standard | Bedeutung |
| --- | ---: | --- |
| `RATE_LIMIT_IP_MAX` | `3` | Aufträge pro IP-Zeitfenster |
| `RATE_LIMIT_IP_WINDOW_SECONDS` | `900` | IP-Zeitfenster |
| `RATE_LIMIT_EMAIL_MAX` | `5` | Aufträge pro E-Mail-Zeitfenster |
| `RATE_LIMIT_EMAIL_WINDOW_SECONDS` | `86400` | E-Mail-Zeitfenster |
| `MAX_SEATS_PER_JOB` | `500` | maximale Orte pro Auftrag |
| `MAX_LEGAL_FORMS_PER_JOB` | `50` | maximale Rechtsformen pro Auftrag |
| `MAX_REQUESTS_PER_JOB` | `5000` | maximales Produkt aus Orten × Rechtsformen |
| `MAX_QUEUE_SIZE` | `20` | maximale offene Aufträge |
| `DOWNLOAD_RETENTION_HOURS` | `48` | Gültigkeit fertiger Exporte |
| `STALE_TASK_RETENTION_HOURS` | `168` | maximale Lebensdauer offener Aufträge |
| `REFERENCE_CACHE_SECONDS` | `86400` | Cache für Gemeinden und Rechtsformen |
| `TRUSTED_PROXY_IPS` | leer | kommaseparierte IPs eigener Reverse-Proxies |
| `EXPORT_BCC_EMAIL` | leer | optionale BCC-Adresse; standardmässig kein BCC |
| `APP_ENV` | leer | nur lokal auf `development` setzen; erlaubt die offiziellen Turnstile-Testschlüssel auf Loopback |
| `SMTP_HOST` | `mxe98c.netcup.net` | SMTP-Server |
| `SMTP_USERNAME` | `info@silias.ch` | SMTP-Benutzer |
| `SMTP_SECURE` | `ssl` | SMTP-Verschlüsselung |
| `SMTP_PORT` | `465` | SMTP-Port |
`TRUSTED_PROXY_IPS` darf nur tatsächlich kontrollierte Proxy-Adressen enthalten. Ohne Eintrag werden vom Browser gelieferte `X-Forwarded-For`- oder `CF-Connecting-IP`-Header bewusst ignoriert.
## Cronjob
`cronjobs.sh` führt zuerst die automatische Bereinigung und danach den Task-Executor aus. Beide PHP-Skripte akzeptieren ausschliesslich CLI-Aufrufe.
Beispiel:
```cron
*/2 * * * * /absoluter/pfad/cronjobs.sh
```
Ein manueller Cleanup kann mit `php cleanup.php` gestartet werden. Alle offenen Aufträge werden nur nach ausdrücklicher Bestätigung gelöscht:
```bash
php deleteFiles.php --confirm
```
## Nginx-Härtung
Die PHP-Skripte sperren Webzugriffe selbst. Zusätzlich sollten Altpfade und Wartungsskripte bereits in Nginx blockiert werden:
```nginx
location ~ ^/(taskExecuter|cleanup|deleteFiles)\.php$ { return 404; }
location ^~ /tasks/ { return 404; }
location ^~ /download/ { return 404; }
location = /env_vars.php { return 404; }
```
Nach dem ersten CLI-Lauf werden alte JSON-Aufträge aus `tasks/` in den privaten Speicher migriert. Anschliessend sollten die alten öffentlichen Verzeichnisse `tasks/` und `download/` nach Kontrolle entfernt werden.
## Datenschutz
Die öffentliche Datenschutzerklärung liegt unter `datenschutz.php`. Sie beschreibt Turnstile, die verarbeiteten Daten und die implementierten Löschfristen. Wenn später Analyse-, Marketing- oder weitere Drittanbieter-Dienste ergänzt werden, müssen Text und gegebenenfalls die Einwilligungsverwaltung erneut geprüft werden.
+1453
View File
File diff suppressed because it is too large Load Diff
+1220
View File
File diff suppressed because it is too large Load Diff
+1015
View File
File diff suppressed because it is too large Load Diff
-69
View File
@@ -1,69 +0,0 @@
<?php
include 'zefixAPI.php';
/*
* Request Types:
* Count
* List
* Export
*/
// Request data
$data = array();
$results = array();
// Wenn Firmenname vorhanden
if(isset($_POST['firma']) && !empty($_POST['firma'])) {
$data["name"] = $_POST['firma'];
}
// Wenn gelöschte auch gesucht werden sollen
if(isset($_POST['geloeschteRechtseinheiten'])) {
$data["activeOnly"] = false;
} else {
$data["activeOnly"] = true;
}
$rechtsformen = array();
if (isset($_POST['rechtsform']) && is_array($_POST['rechtsform']) && count($_POST['rechtsform']) > 0) {
$rechtsformen = $_POST['rechtsform'];
} else {
$rechtsformen = array(); //TODO alle Rechtsformen hinzufügen
$rechtsformen[] = 2;
}
$sitze = array();
if (isset($_POST['sitze']) && is_array($_POST['sitze']) && count($_POST['sitze']) > 0) {
$sitze = $_POST['sitze'];
} else {
$sitze = array(); //TODO alle Sitze hinzufügen
$sitze[] = 27;
}
// Loop through the selected values
foreach ($rechtsformen as $rechtsform) {
$data["legalFormId"] = $rechtsform;
// Loop through the selected values
foreach ($sitze as $sitz) {
$data["legalSeatId"] = $sitz;
$response = sendAPICompanySearchRequest($username, $password, $data);
// echo $response;
echo communityCSV($username, $password);
}
}
// Process the Zefix response and construct your own response
// ...
$constructed_response = $response;
// Send the response back to the client
//header('Content-Type: application/json');
//echo $constructed_response;
?>
-147
View File
@@ -1,147 +0,0 @@
<?php
include 'env_vars.php';
$username = getenv("username");
$password = getenv("password");
/**
* @param string $
* @param string $
* @param array $data
* @return bool|string
*/
function sendAPICompanySearchRequest(string $username, string $password, array $data): string|bool
{
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixPublicREST/api/v1/company/search';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch);
}
// Close cURL session
curl_close($ch);
// Close cURL
curl_close($ch);
return $response;
}
function sendAPICommunityRequest(string $username, string $password): string|bool
{
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixPublicREST/api/v1/community';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch);
}
// Close cURL session
curl_close($ch);
// Close cURL
curl_close($ch);
return $response;
}
function communityCSV(string $username, string $password): string {
$response = sendAPICommunityRequest($username, $password);
$communityArray = json_decode($response, true);
$csvOutput = 'bfsId,Kanton,Gemeindename,registryOfCommerceId\n';
// Create CSV rows
foreach ($communityArray as $item) {
$csvOutput .= implode(',', $item) . "\n";
}
return $csvOutput;
}
function sendAPILegalFormRequest(string $username, string $password): string|bool
{
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixPublicREST/api/v1/legalForm';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // Disable SSL verification
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
echo 'Curl error: ' . curl_error($ch);
}
// Close cURL session
curl_close($ch);
// Close cURL
curl_close($ch);
return $response;
}
function legalFormCSV(string $username, string $password): string {
$response = sendAPILegalFormRequest($username, $password);
$legalformArray = json_decode($response, true);
$csvOutput = 'id,name\n';
// Create CSV rows
foreach ($legalformArray as $item) {
$csvOutput .= $item["id"].",".$item["name"]["de"] . "\n";
}
return $csvOutput;
}
?>
+544
View File
@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
/**
* Shared configuration, storage and abuse-protection helpers.
*
* Secrets are read from process variables or from ../.env. The legacy
* env_vars.php file is loaded when present so existing installations keep
* working during migration.
*/
$appFileEnvironment = [];
function app_load_env_file(string $filename): void
{
global $appFileEnvironment;
if (!is_file($filename)) {
return;
}
if (!is_readable($filename)) {
error_log('Environment file is not readable: ' . $filename);
return;
}
$size = filesize($filename);
if ($size !== false && $size > 65536) {
error_log('Environment file is unexpectedly large: ' . $filename);
return;
}
$lines = file($filename, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
error_log('Environment file could not be read: ' . $filename);
return;
}
foreach ($lines as $lineNumber => $line) {
if ($lineNumber === 0) {
$line = preg_replace('/^\xEF\xBB\xBF/', '', $line) ?? $line;
}
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (strncmp($line, 'export ', 7) === 0) {
$line = ltrim(substr($line, 7));
}
$separator = strpos($line, '=');
if ($separator === false) {
error_log('Ignoring invalid environment entry on line ' . ($lineNumber + 1) . '.');
continue;
}
$name = trim(substr($line, 0, $separator));
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) !== 1) {
error_log('Ignoring invalid environment variable name on line ' . ($lineNumber + 1) . '.');
continue;
}
// Non-empty process variables and legacy configuration take precedence.
$processValue = getenv($name);
if ($processValue !== false && trim((string)$processValue) !== '') {
continue;
}
$value = trim(substr($line, $separator + 1));
$valueLength = strlen($value);
if ($valueLength >= 2 && $value[0] === '"' && $value[$valueLength - 1] === '"') {
$value = stripcslashes(substr($value, 1, -1));
} elseif ($valueLength >= 2 && $value[0] === "'" && $value[$valueLength - 1] === "'") {
$value = substr($value, 1, -1);
} else {
$value = preg_replace('/\s+#.*$/', '', $value) ?? $value;
$value = rtrim($value);
}
if (strpos($value, "\0") !== false) {
error_log('Ignoring environment value containing a null byte on line ' . ($lineNumber + 1) . '.');
continue;
}
$appFileEnvironment[$name] = $value;
$_ENV[$name] = $value;
}
}
$legacyEnvFile = __DIR__ . '/env_vars.php';
if (is_file($legacyEnvFile)) {
require_once $legacyEnvFile;
}
app_load_env_file(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env');
function app_env(string $name, string $default = ''): string
{
global $appFileEnvironment;
$value = getenv($name);
if ($value !== false && trim((string)$value) !== '') {
return trim((string)$value);
}
if (array_key_exists($name, $appFileEnvironment)) {
return trim((string)$appFileEnvironment[$name]);
}
return $default;
}
function app_env_int(string $name, int $default, int $min, int $max): int
{
$value = filter_var(app_env($name), FILTER_VALIDATE_INT);
if ($value === false) {
return $default;
}
return max($min, min($max, (int)$value));
}
function app_private_dir(): string
{
$configured = app_env('ZEFIX_PRIVATE_DIR');
return $configured !== ''
? rtrim($configured, '/\\')
: dirname(__DIR__) . DIRECTORY_SEPARATOR . 'zefix-private';
}
function app_ensure_dir(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Private storage could not be created.');
}
}
function app_task_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'tasks';
app_ensure_dir($directory);
return $directory;
}
function app_download_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'downloads';
app_ensure_dir($directory);
return $directory;
}
function app_rate_limit_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'rate-limits';
app_ensure_dir($directory);
return $directory;
}
function app_cache_dir(): string
{
$directory = app_private_dir() . DIRECTORY_SEPARATOR . 'cache';
app_ensure_dir($directory);
return $directory;
}
function app_send_security_headers(): void
{
if (headers_sent()) {
return;
}
header("Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; upgrade-insecure-requests");
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
}
function app_turnstile_site_key(): string
{
return '0x4AAAAAAD7CJrWpNiK0-A7h';
}
function app_turnstile_secret_key(): string
{
return app_env('TURNSTILE_SECRET');
}
function app_turnstile_is_configured(): bool
{
return app_turnstile_site_key() !== '' && app_turnstile_secret_key() !== '';
}
function app_security_secret(): string
{
$secret = app_env('APP_SECRET');
if ($secret === '') {
$secret = app_turnstile_secret_key();
}
return $secret;
}
function app_base64url_encode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function app_base64url_decode(string $value): string|false
{
$padding = strlen($value) % 4;
if ($padding !== 0) {
$value .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($value, '-_', '+/'), true);
}
function app_create_form_started_token(?int $timestamp = null): string
{
$secret = app_security_secret();
if ($secret === '') {
return '';
}
$timestamp ??= time();
$payload = (string)$timestamp;
$signature = hash_hmac('sha256', $payload, $secret, true);
return app_base64url_encode($payload . '.' . $signature);
}
function app_validate_form_started_token(string $token): bool
{
$secret = app_security_secret();
$decoded = app_base64url_decode($token);
if ($secret === '' || $decoded === false || strlen($decoded) < 34) {
return false;
}
$separator = strpos($decoded, '.');
if ($separator === false) {
return false;
}
$payload = substr($decoded, 0, $separator);
$signature = substr($decoded, $separator + 1);
if (!ctype_digit($payload) || strlen($signature) !== 32) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret, true);
if (!hash_equals($expected, $signature)) {
return false;
}
$age = time() - (int)$payload;
$minimumSeconds = app_env_int('FORM_MIN_SECONDS', 2, 0, 60);
$maximumSeconds = app_env_int('FORM_MAX_SECONDS', 7200, 60, 86400);
return $age >= $minimumSeconds && $age <= $maximumSeconds;
}
function app_client_ip(): string
{
$remoteAddress = $_SERVER['REMOTE_ADDR'] ?? '';
if (!filter_var($remoteAddress, FILTER_VALIDATE_IP)) {
$remoteAddress = 'unknown';
}
$trustedProxies = array_values(array_filter(array_map(
'trim',
explode(',', app_env('TRUSTED_PROXY_IPS'))
)));
if (!in_array($remoteAddress, $trustedProxies, true)) {
return $remoteAddress;
}
$candidates = [];
if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$candidates[] = trim((string)$_SERVER['HTTP_CF_CONNECTING_IP']);
}
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$forwarded = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
$candidates[] = trim($forwarded[0]);
}
foreach ($candidates as $candidate) {
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
return $remoteAddress;
}
/** @return array{allowed: bool, retry_after: int} */
function app_rate_limit(string $scope, string $identity, int $limit, int $windowSeconds): array
{
$now = time();
$secret = app_security_secret();
$hash = $secret !== ''
? hash_hmac('sha256', strtolower($identity), $secret)
: hash('sha256', strtolower($identity));
$filename = app_rate_limit_dir() . DIRECTORY_SEPARATOR . preg_replace('/[^a-z0-9_-]/i', '_', $scope) . '-' . $hash . '.json';
$handle = fopen($filename, 'c+');
if ($handle === false) {
throw new RuntimeException('Rate-limit storage could not be opened.');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('Rate-limit storage could not be locked.');
}
$contents = stream_get_contents($handle);
$state = $contents !== false && $contents !== '' ? json_decode($contents, true) : null;
if (!is_array($state) || !isset($state['started'], $state['count']) || ($now - (int)$state['started']) >= $windowSeconds) {
$state = ['started' => $now, 'count' => 0];
}
$allowed = (int)$state['count'] < $limit;
if ($allowed) {
$state['count'] = (int)$state['count'] + 1;
}
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, json_encode($state, JSON_THROW_ON_ERROR));
fflush($handle);
@chmod($filename, 0600);
flock($handle, LOCK_UN);
return [
'allowed' => $allowed,
'retry_after' => max(1, $windowSeconds - ($now - (int)$state['started'])),
];
} finally {
fclose($handle);
}
}
/** @return array{success: bool, error: string} */
function app_verify_turnstile(string $token, string $remoteIp): array
{
$secret = app_turnstile_secret_key();
if ($secret === '' || $token === '' || strlen($token) > 2048) {
return ['success' => false, 'error' => 'missing-input'];
}
$curl = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify');
if ($curl === false) {
return ['success' => false, 'error' => 'internal-error'];
}
$payload = [
'secret' => $secret,
'response' => $token,
];
if ($remoteIp !== 'unknown') {
$payload['remoteip'] = $remoteIp;
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$response = curl_exec($curl);
$httpCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
$curlError = curl_error($curl);
curl_close($curl);
if (!is_string($response) || $httpCode !== 200) {
error_log('Turnstile request failed: HTTP ' . $httpCode . ($curlError !== '' ? ' / ' . $curlError : ''));
return ['success' => false, 'error' => 'verification-unavailable'];
}
$result = json_decode($response, true);
if (!is_array($result) || ($result['success'] ?? false) !== true) {
$errors = is_array($result['error-codes'] ?? null) ? implode(',', $result['error-codes']) : 'invalid-response';
error_log('Turnstile validation rejected: ' . $errors);
return ['success' => false, 'error' => 'challenge-failed'];
}
$officialTestSecrets = [
'1x0000000000000000000000000000000AA',
'2x0000000000000000000000000000000AA',
'3x0000000000000000000000000000000AA',
];
$isLocalDevelopmentTest = app_env('APP_ENV') === 'development'
&& in_array($remoteIp, ['127.0.0.1', '::1'], true)
&& in_array($secret, $officialTestSecrets, true);
// Cloudflare's dummy testing responses do not always contain the widget's
// hostname and action. This exception is restricted to loopback requests,
// development mode and Cloudflare's documented test secrets.
if ($isLocalDevelopmentTest) {
return ['success' => true, 'error' => ''];
}
$allowedHostname = strtolower(app_env('TURNSTILE_ALLOWED_HOSTNAME', 'zefix.silias.ch'));
$hostname = strtolower((string)($result['hostname'] ?? ''));
if ($allowedHostname !== '' && !hash_equals($allowedHostname, $hostname)) {
error_log('Turnstile hostname mismatch.');
return ['success' => false, 'error' => 'hostname-mismatch'];
}
if (($result['action'] ?? '') !== 'turnstile-spin-v2') {
error_log('Turnstile action mismatch.');
return ['success' => false, 'error' => 'action-mismatch'];
}
return ['success' => true, 'error' => ''];
}
/** @return int[]|null */
function app_normalize_positive_id_list(mixed $value, int $maximumItems): ?array
{
if (!is_array($value) || $value === [] || count($value) > $maximumItems) {
return null;
}
$normalized = [];
foreach ($value as $item) {
if (is_array($item) || !is_scalar($item)) {
return null;
}
$item = (string)$item;
if (!ctype_digit($item)) {
return null;
}
$integer = (int)$item;
if ($integer <= 0) {
return null;
}
$normalized[$integer] = $integer;
}
return array_values($normalized);
}
function app_public_base_url(): string
{
return rtrim(app_env('PUBLIC_BASE_URL', 'https://zefix.silias.ch'), '/');
}
function app_download_path(string $token): string
{
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
throw new InvalidArgumentException('Invalid download token.');
}
return app_download_dir() . DIRECTORY_SEPARATOR . $token . '.csv';
}
function app_cached_string(string $key, int $ttlSeconds, callable $loader): string
{
$safeKey = preg_replace('/[^a-z0-9_-]/i', '_', $key);
$filename = app_cache_dir() . DIRECTORY_SEPARATOR . $safeKey . '.cache';
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
$cached = file_get_contents($filename);
if (is_string($cached) && $cached !== '') {
return $cached;
}
}
$lock = fopen($filename . '.lock', 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
return (string)$loader();
}
try {
clearstatcache(true, $filename);
if (is_file($filename) && filemtime($filename) !== false && filemtime($filename) >= time() - $ttlSeconds) {
return (string)file_get_contents($filename);
}
$fresh = (string)$loader();
if ($fresh !== '') {
$temporary = $filename . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($temporary, $fresh, LOCK_EX);
@chmod($temporary, 0600);
if (!@rename($temporary, $filename)) {
// Windows cannot atomically replace an existing file with rename().
@unlink($filename);
if (!@rename($temporary, $filename)) {
@unlink($temporary);
}
}
return $fresh;
}
return is_file($filename) ? (string)file_get_contents($filename) : '';
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
function app_cleanup_directory(string $directory, int $maximumAgeSeconds, string $extension): int
{
if (!is_dir($directory)) {
return 0;
}
$deleted = 0;
$cutoff = time() - $maximumAgeSeconds;
foreach (glob($directory . DIRECTORY_SEPARATOR . '*.' . $extension) ?: [] as $filename) {
$modified = filemtime($filename);
if ($modified !== false && $modified < $cutoff && is_file($filename) && unlink($filename)) {
$deleted++;
}
}
return $deleted;
}
function app_migrate_legacy_tasks(): int
{
$legacyDirectory = __DIR__ . DIRECTORY_SEPARATOR . 'tasks';
if (!is_dir($legacyDirectory)) {
return 0;
}
$migrated = 0;
foreach (glob($legacyDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [] as $source) {
$target = app_task_dir() . DIRECTORY_SEPARATOR . basename($source);
if (is_file($target)) {
continue;
}
if (@rename($source, $target) || (@copy($source, $target) && @unlink($source))) {
@chmod($target, 0600);
$migrated++;
}
}
return $migrated;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once __DIR__ . '/app.php';
$downloadRetention = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$staleTaskRetention = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160) * 3600;
$migrated = app_migrate_legacy_tasks();
$downloads = app_cleanup_directory(app_download_dir(), $downloadRetention, 'csv');
$legacyDownloads = app_cleanup_directory(__DIR__ . DIRECTORY_SEPARATOR . 'download', $downloadRetention, 'csv');
$tasks = app_cleanup_directory(app_task_dir(), $staleTaskRetention, 'json');
$rateLimits = app_cleanup_directory(app_rate_limit_dir(), 8 * 86400, 'json');
fwrite(STDOUT, sprintf(
"Cleanup complete: %d tasks migrated; %d downloads, %d legacy downloads, %d stale tasks and %d rate-limit files deleted.\n",
$migrated,
$downloads,
$legacyDownloads,
$tasks,
$rateLimits
));
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
BASE_DIR="$(cd "$(dirname "$0")" && pwd)"
PHP_BIN="php"
LOG_FILE="$BASE_DIR/cron.log"
echo "=========================================" >> "$LOG_FILE"
echo "CRON START $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
echo "BASE_DIR: $BASE_DIR" >> "$LOG_FILE"
cd "$BASE_DIR" || { echo "cd failed: $BASE_DIR" >> "$LOG_FILE"; exit 1; }
start_ts=$(date +%s)
$PHP_BIN "$BASE_DIR/cleanup.php" >> "$LOG_FILE" 2>&1
$PHP_BIN "$BASE_DIR/taskExecuter.php" >> "$LOG_FILE" 2>&1
status=$?
end_ts=$(date +%s)
duration=$((end_ts - start_ts))
echo "Exit code: $status" >> "$LOG_FILE"
echo "Duration: ${duration}s" >> "$LOG_FILE"
echo "CRON END $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
exit $status
+298 -67
View File
@@ -1,73 +1,304 @@
.activeWatch {
background-color: lightgray;
}
/* css/custom.css */
/* Keep Bootstrap in control. Only add gentle, modern enhancements. */
h2 {
margin-left: 10px;
margin-top: 25px !important;
}
.table-bordered {
width: 100%;
table-layout: fixed;
text-align: center;
/*border: 1px solid #0e0017 !important;*/
}
td {
overflow-wrap: break-word;
}
button {
/*background-color: lightgray;*/
/*border: none;*/
/*color: white;*/
padding: 1% 5%;
text-align: center;
text-decoration: none;
display: inline-block;
/*font-size: 16px;*/
border-radius: 20px;
}
button, .form-group{
margin: 5px !important;
}
input {
text-align: center;
}
.stopButton, .resetButton {
width: 98% !important;
}
.form-group {
width: 98%;
}
.tableHeader {
min-width: 100px;
:root {
--silias-radius: 14px;
--silias-ink: #0b1236;
--silias-muted: #667085;
--silias-primary: #3b4cca;
--silias-primary-dark: #25339b;
--silias-surface: #ffffff;
--silias-soft: #f3f5ff;
--silias-border: #e4e8f2;
}
html, body {
height: 100%;
margin: 0;
}
.wrapper {
min-height: 100%;
/* Equal to height of footer */
/* But also accounting for potential margin-bottom of last child */
margin-bottom: -240px;
}
footer,
.push {
height: 300px;
min-height: 100%;
}
.footer {
vertical-align: top;
text-align: left;
}
body {
/* Bootstrap uses system font stack, keep it */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: var(--silias-ink);
background:
radial-gradient(circle at 8% 0%, rgba(90, 103, 216, .12), transparent 28rem),
#f7f8fc !important;
background-repeat: no-repeat !important;
background-size: 100% 720px !important;
}
/* Navbar */
.site-header {
background: rgba(255, 255, 255, .94);
backdrop-filter: blur(12px);
}
.navbar-brand {
letter-spacing: 0.2px;
}
.brand-lockup {
display: inline-flex;
align-items: center;
gap: .85rem;
color: var(--silias-ink);
text-decoration: none;
}
.brand-logo-crop {
position: relative;
width: 126px;
height: 45px;
overflow: hidden;
flex: 0 0 auto;
}
.brand-logo-crop img {
position: absolute;
left: -2%;
top: 50%;
width: 104%;
height: auto;
transform: translateY(-50%);
}
.brand-divider {
width: 1px;
height: 28px;
background: var(--silias-border);
}
.brand-product {
font-size: .9rem;
font-weight: 700;
line-height: 1.15;
}
.brand-product small {
display: block;
margin-top: .18rem;
color: var(--silias-muted);
font-size: .72rem;
font-weight: 500;
}
.hero-eyebrow {
color: var(--silias-primary);
font-size: .78rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
.hero-title {
max-width: 760px;
letter-spacing: -.025em;
}
.hero-copy {
max-width: 720px;
color: var(--silias-muted);
}
/* Card polish */
.card {
border-radius: var(--silias-radius);
}
.card.shadow-sm {
box-shadow: 0 10px 25px rgba(0,0,0,.06) !important;
}
.export-shell {
border: 1px solid rgba(228, 232, 242, .9);
box-shadow: 0 18px 48px rgba(21, 31, 80, .09) !important;
}
.form-section {
padding: 1.25rem;
border: 1px solid var(--silias-border);
border-radius: 1rem;
background: var(--silias-surface);
}
.section-heading {
display: flex;
align-items: flex-start;
gap: .8rem;
margin-bottom: 1rem;
}
.section-number {
display: inline-grid;
width: 2rem;
height: 2rem;
place-items: center;
flex: 0 0 auto;
border-radius: .65rem;
background: var(--silias-soft);
color: var(--silias-primary-dark);
font-weight: 800;
}
.section-heading h2 {
margin: 0;
font-size: 1.05rem;
}
.section-heading p {
margin: .2rem 0 0;
color: var(--silias-muted);
font-size: .9rem;
}
.selection-meta {
color: var(--silias-muted);
font-size: .84rem;
font-weight: 600;
}
.privacy-note {
padding: .9rem 1rem;
border: 1px solid var(--silias-border);
border-radius: .85rem;
background: #fafbff;
}
/* Form */
.form-label {
font-weight: 600;
}
.form-text {
color: #6c757d;
}
.form-control,
.form-select {
border-radius: 0.75rem;
}
.form-control:focus,
.form-select:focus {
border-color: #7c89e8;
box-shadow: 0 0 0 .2rem rgba(59, 76, 202, .14);
}
/* Buttons: DO NOT globally style "button" tag */
.btn {
border-radius: 0.85rem;
}
.btn-lg {
padding-top: .85rem;
padding-bottom: .85rem;
}
.btn-primary {
border-color: var(--silias-primary);
background: var(--silias-primary);
}
.btn-primary:hover,
.btn-primary:focus {
border-color: var(--silias-primary-dark);
background: var(--silias-primary-dark);
}
/* Scroll panels (if you keep class from index.php) */
.scrollWindow {
border-radius: 1rem;
scrollbar-color: #b8bfdc transparent;
scrollbar-width: thin;
}
/* Checkbox rows */
.form-check {
padding: .3rem 0 .3rem 1.75rem;
}
.form-check-input {
cursor: pointer;
margin-left: -1.75rem;
}
.form-check-label {
cursor: pointer;
}
/* Alerts */
.alert {
border-radius: 0.9rem;
}
/* Footer */
footer {
margin-top: 0;
}
.site-footer {
color: var(--silias-muted);
background: #fff;
}
footer a {
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Small badges look nicer */
.badge {
border-radius: 999px;
padding: .45rem .7rem;
}
/* Optional: make long checkbox lists easier to scan */
.scrollWindow .form-check + .form-check {
border-top: 1px solid rgba(0,0,0,.04);
}
.scrollWindow .form-check {
padding: .35rem .25rem;
}
/* Ensure links inside muted text are readable */
.text-muted a {
color: inherit;
}
.text-muted a:hover {
color: #0d6efd;
}
/* Mobile tweaks */
@media (max-width: 576px) {
.card-body {
padding: 1.25rem !important;
}
.brand-logo-crop {
width: 104px;
height: 38px;
}
.brand-divider,
.brand-product small {
display: none;
}
.form-section {
padding: 1rem;
}
}
/* Fix: checkboxes not clipped and never overlap text */
.scrollWindow .form-check {
padding: .6rem 1.1rem !important;
margin: 0 !important;
}
.scrollWindow .form-check-input {
margin-left: 0 !important; /* remove negative margin */
float: left; /* classic layout */
}
.scrollWindow .form-check-label {
display: block;
padding-left: 1.9rem;
line-height: 1.4;
}
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
$downloadRetentionHours = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720);
$staleTaskRetentionHours = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160);
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=2.2">
<title>Datenschutzerklärung Silias Zefix Export</title>
</head>
<body class="bg-light">
<nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container">
<a class="brand-lockup" href="index.php" aria-label="Silias Zefix Export Startseite">
<span class="brand-logo-crop" aria-hidden="true"><img src="img/silias-logo.png" alt=""></span>
<span class="brand-divider" aria-hidden="true"></span>
<span class="brand-product">Zefix Export<small>Ein kostenloses Silias-Tool</small></span>
</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-lg-9">
<article class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<h1 class="h3 mb-4">Datenschutzerklärung</h1>
<p class="text-muted">Stand: 22. Juli 2026</p>
<h2 class="h5 mt-4">1. Verantwortlicher</h2>
<p>
Silias KLG<br>
Toggenburgstrasse 31<br>
8245 Feuerthalen, Schweiz<br>
E-Mail: <a href="mailto:info@silias.ch">info@silias.ch</a>
</p>
<h2 class="h5 mt-4">2. Zweck und Umfang der Datenbearbeitung</h2>
<p>
Der Dienst erstellt auf Ihren Wunsch einen Export aus dem Zentralen Firmenindex ZEFIX und sendet
den zugehörigen Download-Link an die von Ihnen angegebene E-Mail-Adresse. Dafür bearbeiten wir die
E-Mail-Adresse, die ausgewählten Suchparameter, den Zeitpunkt des Auftrags sowie technisch notwendige
Sicherheits- und Protokolldaten.
</p>
<p>
Die Bearbeitung dient der Bereitstellung des angeforderten Exports, der Betriebssicherheit sowie der
Erkennung und Verhinderung automatisierter oder missbräuchlicher Anfragen. Soweit die DSGVO anwendbar
ist, stützen wir diese Bearbeitung auf die Durchführung Ihrer Anfrage sowie unser berechtigtes Interesse
an einem sicheren und zuverlässigen Betrieb.
</p>
<h2 class="h5 mt-4">3. Cloudflare Turnstile</h2>
<p>
Zum Schutz des Exportformulars verwenden wir Cloudflare Turnstile im verwalteten Modus. Dabei werden
technisch notwendige Signale wie IP-Adresse, TLS-Fingerprint, User-Agent, Website-Herkunft und
Sicherheitsmerkmale des Browsers an Cloudflare übermittelt. Die Daten werden zur Unterscheidung zwischen
Menschen und automatisierten Zugriffen verwendet. Wir verwenden Turnstile nicht zu Werbe- oder
Marketingzwecken und haben die Pre-Clearance-Funktion deaktiviert.
</p>
<p>
Anbieter ist Cloudflare, Inc., 101 Townsend Street, San Francisco, CA 94107, USA. Cloudflare kann Daten
auch ausserhalb der Schweiz bearbeiten. Informationen zu Zweck, Rollenverteilung und internationalen
Übermittlungen finden Sie im
<a href="https://www.cloudflare.com/turnstile-privacy-policy/" target="_blank" rel="noopener">Turnstile Privacy Addendum</a>,
in der <a href="https://www.cloudflare.com/policies/privacy/" target="_blank" rel="noopener">Datenschutzerklärung von Cloudflare</a>
und im <a href="https://www.cloudflare.com/cloudflare-customer-dpa/" target="_blank" rel="noopener">Data Processing Addendum</a>.
</p>
<h2 class="h5 mt-4">4. Cookies und ähnliche Technologien</h2>
<p>
Diese Website verwendet keine Analyse- oder Marketing-Cookies. Turnstile kann technisch notwendige
Cookies oder lokalen Browserspeicher zur Sicherheitsprüfung einsetzen. Diese Sicherheitsfunktionen sind
für die Nutzung des öffentlich zugänglichen Exportformulars erforderlich. Die Cloudflare-Funktion
«Pre-Clearance», die ein <code>cf_clearance</code>-Cookie auf unserer Domain setzen würde, ist deaktiviert.
</p>
<h2 class="h5 mt-4">5. Empfänger und Datenquellen</h2>
<p>
Daten erhalten nur Dienstleister, soweit dies für Hosting, E-Mail-Versand, Missbrauchsschutz oder Betrieb
erforderlich ist. Die ausgewählten Suchkriterien werden serverseitig an die ZEFIX-Schnittstelle des
Bundesamts für Justiz übermittelt. Ihre E-Mail-Adresse wird nicht an ZEFIX übermittelt. Eine Weitergabe
zu Werbezwecken findet nicht statt.
</p>
<h2 class="h5 mt-4">6. Aufbewahrungsdauer</h2>
<ul>
<li>Offene Aufträge einschliesslich E-Mail-Adresse: bis zur Verarbeitung, spätestens <?php echo $staleTaskRetentionHours; ?> Stunden.</li>
<li>Fertige Exportdateien: <?php echo $downloadRetentionHours; ?> Stunden nach ihrer Erstellung.</li>
<li>Pseudonymisierte Rate-Limit-Einträge: höchstens acht Tage.</li>
<li>Technische Serverprotokolle: nur so lange, wie dies für Sicherheit, Fehleranalyse und Betrieb erforderlich ist.</li>
</ul>
<p>Nach Ablauf der jeweiligen Frist werden die Daten automatisch gelöscht oder überschrieben.</p>
<h2 class="h5 mt-4">7. Datensicherheit</h2>
<p>
Die Übertragung erfolgt verschlüsselt über HTTPS. Auftragsdaten und Exportdateien werden ausserhalb des
öffentlichen Webverzeichnisses gespeichert. Download-Links enthalten ein zufälliges Zugriffstoken und
sind zeitlich begrenzt. Bitte leiten Sie einen Download-Link nicht an unbefugte Personen weiter.
</p>
<h2 class="h5 mt-4">8. Ihre Rechte</h2>
<p>
Im Rahmen des anwendbaren Datenschutzrechts können Sie Auskunft, Berichtigung, Löschung oder
Einschränkung der Bearbeitung verlangen sowie einer Bearbeitung widersprechen. Zur Ausübung Ihrer Rechte
kontaktieren Sie uns unter <a href="mailto:info@silias.ch">info@silias.ch</a>. Sie können sich ausserdem
an den Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten oder eine andere zuständige
Datenschutzaufsichtsbehörde wenden.
</p>
<h2 class="h5 mt-4">9. Änderungen</h2>
<p>
Wir können diese Datenschutzerklärung anpassen, wenn sich der Dienst oder die rechtlichen Anforderungen
ändern. Es gilt die jeweils auf dieser Seite veröffentlichte Fassung.
</p>
<a class="btn btn-primary mt-3" href="index.php">Zurück zur Startseite</a>
</div>
</article>
</div>
</div>
</main>
<footer class="site-footer border-top">
<div class="container py-3 text-center text-muted small">
© <?php echo date('Y'); ?> Silias KLG
</div>
</footer>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once __DIR__ . '/app.php';
if (($argv[1] ?? '') !== '--confirm') {
fwrite(STDERR, "This command deletes every pending export task. Run: php deleteFiles.php --confirm\n");
exit(2);
}
$deleted = 0;
foreach (glob(app_task_dir() . DIRECTORY_SEPARATOR . '*.json') ?: [] as $filename) {
if (is_file($filename) && unlink($filename)) {
$deleted++;
}
}
fwrite(STDOUT, $deleted . " pending task(s) deleted.\n");
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header('X-Robots-Tag: noindex, nofollow, noarchive');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') {
header('Allow: GET');
http_response_code(405);
exit('Methode nicht erlaubt.');
}
$tokenValue = $_GET['token'] ?? '';
$token = is_scalar($tokenValue) ? strtolower(trim((string)$tokenValue)) : '';
if (!preg_match('/^[a-f0-9]{32}$/', $token)) {
http_response_code(404);
exit('Download nicht gefunden.');
}
$filename = app_download_path($token);
$retentionSeconds = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$modified = is_file($filename) ? filemtime($filename) : false;
if ($modified === false || $modified < time() - $retentionSeconds) {
if (is_file($filename)) {
@unlink($filename);
}
http_response_code(404);
exit('Der Download ist nicht vorhanden oder bereits abgelaufen.');
}
$size = filesize($filename);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="zefix-export.csv"');
if ($size !== false) {
header('Content-Length: ' . $size);
}
$handle = fopen($filename, 'rb');
if ($handle === false) {
http_response_code(500);
exit('Der Download konnte nicht geöffnet werden.');
}
fpassthru($handle);
fclose($handle);
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
require_once "PHPMailer.php";
require_once "SMTP.php";
require_once "Exception.php";
use PHPMailer\PHPMailer\PHPMailer;
function sendEmail(string $emailAddress, string $downloadToken, string $smtpPassword): bool
{
$mail = new PHPMailer();
$mail->isSMTP();
$mail->Host = app_env('SMTP_HOST', 'mxe98c.netcup.net');
$mail->SMTPAuth = true;
$mail->Username = app_env('SMTP_USERNAME', 'info@silias.ch');
$mail->Password = $smtpPassword;
$mail->SMTPSecure = app_env('SMTP_SECURE', 'ssl');
$mail->Port = app_env_int('SMTP_PORT', 465, 1, 65535);
$mail->CharSet = 'UTF-8';
$mail->isHTML(true);
$mail->setFrom('info@silias.ch', 'Silias Zefix Export');
$mail->addAddress($emailAddress);
$bccAddress = app_env('EXPORT_BCC_EMAIL');
if ($bccAddress !== '' && filter_var($bccAddress, FILTER_VALIDATE_EMAIL)) {
$mail->addBCC($bccAddress);
}
$mail->addReplyTo('info@silias.ch', 'Silias KLG');
$mail->Subject = 'Ihr Export von Zefix ist bereit';
$downloadUrl = app_public_base_url() . '/download.php?token=' . rawurlencode($downloadToken);
$escapedUrl = htmlspecialchars($downloadUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$retentionHours = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720);
$mail->Body = 'Nutzen Sie den folgenden Link, um Ihre Daten herunterzuladen:<br><a href="' . $escapedUrl . '">' . $escapedUrl . '</a><br><br>Der Link ist ' . $retentionHours . ' Stunden gültig.';
$mail->AltBody = "Nutzen Sie den folgenden Link, um Ihre Daten herunterzuladen:\n" . $downloadUrl . "\n\nDer Link ist " . $retentionHours . ' Stunden gültig.';
if ($smtpPassword === '') {
error_log('SMTP password is not configured.');
return false;
}
if (!$mail->send()) {
error_log('Export email failed: ' . $mail->ErrorInfo);
return false;
}
return true;
}
-3487
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,4 +1,13 @@
<?php
echo "communityCsvData = `". communityCSV($username, $password) ."`;";
?>
$communityCsv = '';
try {
$communityCsv = app_cached_string(
'communities',
app_env_int('REFERENCE_CACHE_SECONDS', 86400, 300, 604800),
static fn(): string => communityCSV((string)$username, (string)$password)
);
} catch (Throwable $exception) {
error_log('Community reference data could not be loaded: ' . $exception->getMessage());
}
echo 'communityCsvData = ' . json_encode($communityCsv, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';';
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+510 -275
View File
@@ -1,322 +1,557 @@
<?php
require_once __DIR__ . '/app.php';
app_send_security_headers();
include "zefixAPI.php";
$turnstileSiteKey = app_turnstile_is_configured() ? app_turnstile_site_key() : '';
$formStartedToken = app_create_form_started_token();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/custom.css?v=1.2">
<link rel="stylesheet" href="css/bootstrap.css">
<title>Silias Zefix Suche</title>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#sitzInput {
box-sizing: border-box;
background-position: 14px 12px;
background-repeat: no-repeat;
font-size: 16px;
padding: 14px 20px 12px 45px;
border: none;
border-bottom: 1px solid #ddd;
}
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=2.2">
#sitzInput {outline: 3px solid #ddd;}
<?php if ($turnstileSiteKey !== ''): ?>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<?php endif; ?>
.button-small {
font-size: 0.7rem;
padding: 0% 1%;
}
<title>Silias Zefix Export</title>
.dropdown a:hover {background-color: #ddd;}
<style>
/* Nice input for the search box */
#sitzInput {
box-sizing: border-box;
font-size: 16px;
border: 1px solid #dee2e6;
border-radius: .5rem;
padding: .6rem .8rem;
width: 100%;
}
.show {display: block;}
.scrollWindow {
max-height: 320px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: .75rem;
background: #fff;
padding: .75rem;
}
.scrollWindow {
max-height: 300px; /* Adjust the value as needed */
overflow-y: auto; /* This will add a scrollbar when content exceeds max-height */
border: 1px solid #ccc; /* Optional: Add a border for visual clarity */
padding: 10px; /* Optional: Add padding for better spacing */
}
</style>
.button-small {
font-size: 0.8rem;
padding: .35rem .6rem;
border-radius: .5rem;
}
.gemeindeeintrag { margin-bottom: .25rem; }
.bot-trap {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
}
</style>
</head>
<body>
<body class="bg-light">
<div class="wrapper">
<section>
<nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container">
<a class="brand-lockup" href="index.php" aria-label="Silias Zefix Export Startseite">
<span class="brand-logo-crop" aria-hidden="true">
<img src="img/silias-logo.png" alt="">
</span>
<span class="brand-divider" aria-hidden="true"></span>
<span class="brand-product">Zefix Export<small>Ein kostenloses Silias-Tool</small></span>
</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-lg-10 col-xl-9">
<div class="container">
<h1>Silias Zefix Suche</h1>
<div class="mb-4 mb-md-5">
<div class="hero-eyebrow mb-2">Zentraler Firmenindex</div>
<h1 class="display-6 fw-bold hero-title mb-3">Firmendaten gezielt auswählen und als CSV exportieren</h1>
<p class="lead hero-copy mb-0">
Filtern Sie Unternehmen nach Firmenname, Ort und Rechtsform. Sobald der Export bereit ist,
erhalten Sie einen sicheren Download-Link per E-Mail.
</p>
</div>
<div class="card export-shell shadow-sm">
<div class="card-body p-4 p-md-5">
<p>Diese Webseite wird gerade neu entwickelt und steht demnächst zur Verfügung.</p>
<form action="submit.php" method="post" class="needs-validation" novalidate>
<form action="submit.php" method="post">
<label for="firma">Firmenname:</label>
<p>* kann als Platzhalter verwendet werden</p>
<input type="text" id="firma" name="firma"><br><br>
<label for="kanton">Kanton:</label>
<div id="kantonauswahl" class="scrollWindow" onclick="filterFunction()">
</div><br><br>
<label for="sitz">Sitz (Ort):</label><br>
<div class="dropdown">
<div id="gemeindeDropdown" class="dropdown-content">
<table style="width: 100%">
<td>
<input type="text" placeholder="suchen (Ort)" id="sitzInput" onkeyup="filterFunction()" onchange="filterFunction()" name="sitzfilter" autocomplete="off">
</td>
<td>
<button type="button" class="button-small" onclick="gefundeneGemeindenAuswählen()">Alle Ergebnise auswählen</button>
<button type="button" class="button-small" onclick="ausgewählteGemeindenLöschen()">Auswahl löschen</button>
<button type="button" class="button-small" onclick="alleGemeindenAnzeigen()">Alle anzeigen</button>
<button type="button" class="button-small" onclick="nurAusgewählteGemeindenAnzeigen()">Nur ausgewählte anzeigen</button>
</td>
</table>
<script>
function alleGemeindenAnzeigen() {
sitzInput.value = ""
filterFunction()
}
function nurAusgewählteGemeindenAnzeigen() {
sitzInput.value = "(nur ausgewählte)"
filterFunction()
}
function ausgewählteGemeindenLöschen() {
sitzauswahl.querySelectorAll('input[type="checkbox"]').forEach(function(checkbox) {
checkbox.checked = false;
})
filterFunction()
}
function gefundeneGemeindenAuswählen() { // TODO diese Funktion aufrufen wenn keine Gemeinde ausgewählt und gesucht wird.
sitzauswahl.querySelectorAll('input[type="checkbox"]').forEach(function(checkbox) {
if(checkbox.parentNode.parentNode.style.display !== "none")
checkbox.checked = true;
})
filterFunction()
}
</script>
<div id="sitzauswahl" class="scrollWindow">
</div>
</div>
</div><br><br><br><br>
<label for="rechtsform">Rechtsform:</label><br>
<div id = "rechtsformenauswahl" class="scrollWindow">
<input type="hidden" name="form_started" value="<?php echo htmlspecialchars($formStartedToken, ENT_QUOTES); ?>">
<div class="bot-trap" aria-hidden="true" hidden>
<label for="website">Website</label>
<input type="text" id="website" name="website" value="" tabindex="-1" autocomplete="off">
</div>
<div class="form-section mb-3">
<div class="section-heading">
<span class="section-number" aria-hidden="true">1</span>
<div>
<h2>Firmenname</h2>
<p>Optional: Schränken Sie den Export auf passende Firmennamen ein.</p>
</div>
<br><br>
</div>
<label for="firma" class="form-label">Firmenname oder Suchmuster</label>
<input type="text" id="firma" name="firma" class="form-control" placeholder="Zum Beispiel *solar*" maxlength="200">
<div class="form-text mt-2">Das Sternchen (*) kann als Platzhalter verwendet werden.</div>
</div>
<input type="checkbox" id="exakteSuche" name="exakteSuche" disabled>
<label for="exakteSuche">Exakte Suche (noch in Entwicklung)</label><br>
<div class="form-section mb-3">
<div class="section-heading">
<span class="section-number" aria-hidden="true">2</span>
<div>
<h2>Ort auswählen</h2>
<p>Filtern Sie zuerst nach Kanton oder Ortsname und übernehmen Sie danach die gewünschten Orte.</p>
</div>
</div>
<input type="checkbox" id="geloeschteRechtseinheiten" name="geloeschteRechtseinheiten">
<label for="geloeschteRechtseinheiten">Gelöschte Rechtseinheiten suchen</label><br>
<div class="alert alert-primary border-0 py-2 mb-3" role="note">
<strong>Hinweis:</strong> Die Kantone filtern nur die Ortsliste. Klicken Sie danach auf
<strong>«Gefilterte Orte auswählen»</strong>, damit die Orte in den Export übernommen werden.
</div>
<input type="checkbox" id="inBisherigenFirmen" name="inBisherigenFirmen" disabled>
<label for="inBisherigenFirmen">In bisherigen Firmen/Namen suchen (noch in Entwicklung)</label><br>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">Kanton</label>
<span class="selection-meta" id="cantonCount">0 ausgewählt</span>
</div>
<div id="kantonauswahl" class="scrollWindow" onclick="filterFunction()"></div>
</div>
<input type="checkbox" id="phonetischeSuche" name="phonetischeSuche" disabled>
<label for="phonetischeSuche">Phonetische Suche (noch in Entwicklung)</label><br><br>
<div>
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="sitzInput" class="form-label mb-0">Sitz (Ort)</label>
<span class="selection-meta" id="seatCount">0 ausgewählt</span>
</div>
<input type="submit" value="Suchergebnisse exportieren">
</form>
<div class="card border-0 bg-white">
<div class="card-body p-0">
<div class="d-flex flex-column flex-lg-row gap-2 mb-2">
<div class="flex-grow-1">
<input type="text"
placeholder="Ort suchen"
id="sitzInput"
onkeyup="filterFunction()"
onchange="filterFunction()"
name="sitzfilter"
autocomplete="off">
</div>
<script>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-primary button-small" onclick="gefundeneGemeindenAuswählen()">
Gefilterte Orte auswählen
</button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="ausgewählteGemeindenLöschen()">
Auswahl löschen
</button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="alleGemeindenAnzeigen()">
Alle anzeigen
</button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="nurAusgewählteGemeindenAnzeigen()">
Nur ausgewählte anzeigen
</button>
</div>
</div>
<div id="sitzauswahl" class="scrollWindow"></div>
</div>
</div>
</div>
</div>
// Parse CSV-Daten und fülle das Dropdown-Feld
<div class="form-section mb-3">
<div class="section-heading">
<span class="section-number" aria-hidden="true">3</span>
<div>
<h2>Rechtsform auswählen</h2>
<p>Wählen Sie mindestens eine Rechtsform für den Export.</p>
</div>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<div>
<button type="button" class="btn btn-outline-primary button-small" onclick="alleRechtsformenAuswählen()">
Alle auswählen
</button>
<button type="button" class="btn btn-outline-secondary button-small" onclick="rechtsformenLöschen()">
Auswahl löschen
</button>
</div>
<span class="selection-meta" id="legalFormCount">0 ausgewählt</span>
</div>
let communityCsvData;
let legalFormsCsvData;
<?php
include 'api/env_vars.php';
include 'api/zefixAPI.php';
include 'gemeinden.php';
include 'legalForms.php';
?>
<div id="rechtsformenauswahl" class="scrollWindow"></div>
</div>
<div class="form-section mb-3">
<div class="section-heading">
<span class="section-number" aria-hidden="true">4</span>
<div>
<h2>Export anfordern</h2>
<p>Geben Sie die Zieladresse an und starten Sie den geschützten Export.</p>
</div>
</div>
const legalFormslines = legalFormsCsvData.trim().split('\n');
const legalFormsheaders = legalFormslines[0].split(',');
const legalformIdIndex = legalFormsheaders.indexOf('id');
const legalformNameIndex = legalFormsheaders.indexOf('name');
for (let i = 1; i < legalFormslines.length; i++) {
const cells = legalFormslines[i].split(',');
const id = cells[legalformIdIndex].trim();
const name = cells[legalformNameIndex].trim();
const label = document.createElement('label');
const rechtsformenauswahl = document.getElementById("rechtsformenauswahl")
rechtsformenauswahl.appendChild(label);
const input = document.createElement('input');
input.type = "checkbox";
input.name = "rechtsformen[]";
input.value = id;
label.textContent = " " + name;
label.style.marginBottom = '0';
label.prepend(input);
rechtsformenauswahl.appendChild(document.createElement("br"))
<div class="mb-4">
<div class="form-label mb-2">Weitere Optionen</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="exakteSuche" name="exakteSuche" disabled>
<label class="form-check-label" for="exakteSuche">Exakte Suche (noch in Entwicklung)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="geloeschteRechtseinheiten" name="geloeschteRechtseinheiten">
<label class="form-check-label" for="geloeschteRechtseinheiten">Gelöschte Rechtseinheiten suchen</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="inBisherigenFirmen" name="inBisherigenFirmen" disabled>
<label class="form-check-label" for="inBisherigenFirmen">In bisherigen Firmen/Namen suchen (noch in Entwicklung)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="phonetischeSuche" name="phonetischeSuche" disabled>
<label class="form-check-label" for="phonetischeSuche">Phonetische Suche (noch in Entwicklung)</label>
</div>
</div>
<div class="mb-4">
<label for="email" class="form-label">Ihre E-Mail-Adresse</label>
<div class="form-text mb-2">
Die Aufbereitung kann einige Zeit dauern. Sie erhalten den Download-Link per E-Mail, sobald die Daten bereit sind.
</div>
<input type="email" id="email" name="email" class="form-control" required placeholder="name@firma.ch">
<div class="invalid-feedback">Bitte geben Sie eine gültige E-Mail-Adresse ein.</div>
</div>
<?php if ($turnstileSiteKey !== ''): ?>
<div class="privacy-note mb-3">
<div class="cf-turnstile"
data-sitekey="<?php echo htmlspecialchars($turnstileSiteKey, ENT_QUOTES); ?>"
data-action="turnstile-spin-v2"
data-theme="auto"
data-language="de"
data-feedback-enabled="false"></div>
<div class="form-text mt-2">
Der Missbrauchsschutz verarbeitet technisch notwendige Browser- und Verbindungsdaten.
Details finden Sie in der <a href="datenschutz.php">Datenschutzerklärung</a>.
</div>
</div>
<?php else: ?>
<div class="alert alert-warning" role="alert">
Der Export ist vorübergehend deaktiviert, da der Missbrauchsschutz noch nicht konfiguriert ist.
</div>
<?php endif; ?>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg"<?php echo $turnstileSiteKey === '' ? ' disabled' : ''; ?>>Export starten</button>
</div>
<div id="selectionValidationMessage" class="text-danger mt-2" style="display:none;">
Wählen Sie mindestens einen Ort und eine Rechtsform aus.
</div>
</div>
</form>
<script>
// Bootstrap validation (optional)
(function () {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.prototype.slice.call(forms).forEach(function (form) {
form.addEventListener('submit', function (event) {
const hasSelectedSitz = document.querySelector('input[name="sitze[]"]:checked') !== null
const hasSelectedRechtsform = document.querySelector('input[name="rechtsformen[]"]:checked') !== null
const selectionValidationMessage = document.getElementById('selectionValidationMessage')
if (selectionValidationMessage) {
selectionValidationMessage.style.display = hasSelectedSitz && hasSelectedRechtsform ? 'none' : 'block'
}
if (!form.checkValidity() || !hasSelectedSitz || !hasSelectedRechtsform) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})();
</script>
<script>
// CSV data injected by PHP
let communityCsvData;
let legalFormsCsvData;
<?php
include 'gemeinden.php';
include 'legalForms.php';
?>
// --- Helpers to create nicer checkbox rows (Bootstrap form-check) ---
function addCheckboxRow(container, checkboxName, checkboxValue, labelText) {
const wrapper = document.createElement('div');
wrapper.className = "form-check";
const input = document.createElement('input');
input.className = "form-check-input";
input.type = "checkbox";
input.name = checkboxName;
input.value = checkboxValue;
input.id = checkboxName.replace(/[^a-z0-9]/gi, '-') + '-' + String(checkboxValue).replace(/[^a-z0-9]/gi, '-');
const label = document.createElement('label');
label.className = "form-check-label";
label.htmlFor = input.id;
label.textContent = labelText;
wrapper.appendChild(input);
wrapper.appendChild(label);
container.appendChild(wrapper);
return input;
}
// --- Legal forms ---
const legalFormslines = legalFormsCsvData.trim().split('\n');
const legalFormsheaders = legalFormslines[0].split(',');
const legalformIdIndex = legalFormsheaders.indexOf('id');
const legalformNameIndex = legalFormsheaders.indexOf('name');
const rechtsformenauswahl = document.getElementById("rechtsformenauswahl");
for (let i = 1; i < legalFormslines.length; i++) {
const cells = legalFormslines[i].split(',');
const id = (cells[legalformIdIndex] || '').trim();
const name = (cells[legalformNameIndex] || '').trim();
if (!id || !name) continue;
addCheckboxRow(rechtsformenauswahl, "rechtsformen[]", id, " " + name);
}
// --- Communities + Cantons ---
const lines = communityCsvData.trim().split('\n');
const headers = lines[0].split(',');
const gemeindeNameIndex = headers.indexOf('Gemeindename');
const bfsIdIndex = headers.indexOf('id');
const kantonIndex = headers.indexOf('Kanton');
const registryOfCommerceIdIndex = headers.indexOf('registryOfCommerceId');
const sitze = [];
const kantone = [];
const kantonauswahl = document.getElementById("kantonauswahl");
const sitzauswahl = document.getElementById("sitzauswahl");
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',');
if (!cells || cells.length < 4) continue;
const bfsId = (cells[bfsIdIndex] || '').trim();
const gemeindeName = (cells[gemeindeNameIndex] || '').trim();
const kanton = (cells[kantonIndex] || '').trim();
const registryOfCommerceId = (cells[registryOfCommerceIdIndex] || '').trim();
if (!bfsId || !gemeindeName) continue;
// Add canton if new
if (kanton && !kantone.includes(kanton)) {
kantone.push(kanton);
addCheckboxRow(kantonauswahl, "kantone[]", kanton, " " + kanton);
}
// Store row for filtering logic
sitze.push(cells);
// Create nicer entry
const gemeindeEintrag = document.createElement("div");
gemeindeEintrag.classList.add("gemeindeeintrag");
const wrapper = document.createElement('div');
wrapper.className = "form-check";
const input = document.createElement('input');
input.className = "form-check-input";
input.type = "checkbox";
input.name = "sitze[]";
input.value = bfsId;
input.id = "sitz-" + bfsId;
const label = document.createElement('label');
label.className = "form-check-label";
label.htmlFor = input.id;
label.textContent = " " + gemeindeName;
wrapper.appendChild(input);
wrapper.appendChild(label);
gemeindeEintrag.appendChild(wrapper);
sitzauswahl.appendChild(gemeindeEintrag);
}
const sitzInput = document.getElementById('sitzInput');
function updateSelectionCounts() {
const cantonTotal = document.querySelectorAll('input[name="kantone[]"]:checked').length;
const seatTotal = document.querySelectorAll('input[name="sitze[]"]:checked').length;
const legalFormTotal = document.querySelectorAll('input[name="rechtsformen[]"]:checked').length;
document.getElementById('cantonCount').textContent = cantonTotal + ' ausgewählt';
document.getElementById('seatCount').textContent = seatTotal + ' ausgewählt';
document.getElementById('legalFormCount').textContent = legalFormTotal + ' ausgewählt';
}
document.addEventListener('change', function(event) {
if (event.target && event.target.matches('input[type="checkbox"]')) {
updateSelectionCounts();
}
});
function kantoneAusgewählt() {
const checkboxes = document.querySelectorAll('input[name="kantone[]"]');
let countChecked = 0;
for (let i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) countChecked++;
}
return countChecked;
}
function filterFunction() {
const numKantoneAusgewählt = kantoneAusgewählt();
const filter = (sitzInput.value || "").toUpperCase();
const entries = sitzauswahl.getElementsByClassName("gemeindeeintrag");
for (let i = 0; i < entries.length; i++) {
const txtValue = entries[i].textContent || entries[i].innerText;
const selectedCheckbox = entries[i].querySelector('input[type="checkbox"]');
const isSelected = selectedCheckbox ? selectedCheckbox.checked : false;
// map entry->sitz row index: still aligned because we push sitze once per entry
const row = sitze[i];
const rowKanton = row ? row[kantonIndex] : "";
let cantonOk = true;
if (numKantoneAusgewählt > 0 && rowKanton) {
const cantonBox = document.querySelector('input[name="kantone[]"][value="' + rowKanton + '"]');
cantonOk = cantonBox ? cantonBox.checked : false;
}
const matchesText = txtValue.toUpperCase().indexOf(filter) > -1;
const lines = communityCsvData.trim().split('\n');
const headers = lines[0].split(',');
const gemeindeNameIndex = headers.indexOf('Gemeindename');
const bfsIdIndex = headers.indexOf('bfsId');
const kantonIndex = headers.indexOf('Kanton');
const registryOfCommerceIdIndex = headers.indexOf('registryOfCommerceId');
const sitze = [];
const kantone = []
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',');
const bfsId = cells[bfsIdIndex].trim();
const gemeindeName = cells[gemeindeNameIndex].trim();
const kanton = cells[kantonIndex].trim();
const registryOfCommerceId = cells[registryOfCommerceIdIndex].trim();
// Falls Kanton noch nicht vorhanden -> hinzufügen
if (!kantone.includes(kanton)) {
kantone.push(kanton)
const label = document.createElement('label');
const kantonsauswahl = document.getElementById("kantonauswahl")
kantonsauswahl.appendChild(label);
const input = document.createElement('input');
input.type = "checkbox";
input.name = "kantone[]";
input.value = kanton;
label.textContent = " " + kanton;
label.style.marginBottom = '0';
label.prepend(input);
kantonsauswahl.appendChild(document.createElement("br"))
}
// Sitz hinzufügen
sitze.push(cells)
const sitzString = gemeindeName
const gemeindeEintrag = document.createElement("div");
gemeindeEintrag.classList.add("gemeindeeintrag");
const label = document.createElement('label');
const sitzauswahl = document.getElementById("sitzauswahl")
sitzauswahl.appendChild(gemeindeEintrag);
gemeindeEintrag.appendChild(label);
const input = document.createElement('input');
input.type = "checkbox";
input.name = "sitze[]";
input.value = bfsId;
label.textContent = " " + sitzString;
label.style.marginBottom = '0';
label.prepend(input);
gemeindeEintrag.appendChild(document.createElement("br"))
if ((matchesText && (cantonOk || numKantoneAusgewählt === 0)) || isSelected) {
entries[i].style.display = "";
} else {
entries[i].style.display = "none";
}
}
}
function filterFunction() {
const numKantoneAusgewählt = kantoneAusgewählt()
var input, filter, ul, li, a, i;
input = document.getElementById("sitzInput");
filter = input.value.toUpperCase();
div = document.getElementById("gemeindeDropdown");
a = div.getElementsByClassName("gemeindeeintrag");
for (i = 0; i < a.length; i++) {
txtValue = a[i].textContent || a[i].innerText;
if ((txtValue.toUpperCase().indexOf(filter) > -1 && (document.querySelector('input[name="kantone[]"][value=' + sitze[i][kantonIndex] + ']').checked || numKantoneAusgewählt === 0) ) || a[i].firstElementChild.firstElementChild.checked) {
a[i].style.display = "";
} else {
a[i].style.display = "none";
}
}
}
function alleGemeindenAnzeigen() {
sitzInput.value = "";
filterFunction();
}
function kantoneAusgewählt() {
const checkboxes = document.querySelectorAll('input[name="kantone[]"]');
let countChecked = 0;
function nurAusgewählteGemeindenAnzeigen() {
sitzInput.value = "(nur ausgewählte)";
filterFunction();
}
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
countChecked = countChecked + 1;
}
}
return countChecked;
}
function ausgewählteGemeindenLöschen() {
sitzauswahl.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
cb.checked = false;
});
filterFunction();
updateSelectionCounts();
}
function gefundeneGemeindenAuswählen() {
sitzauswahl.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
// show/hide is on the gemeindeeintrag wrapper
const entry = cb.closest('.gemeindeeintrag');
if (entry && entry.style.display !== "none") cb.checked = true;
});
filterFunction();
updateSelectionCounts();
}
function alleRechtsformenAuswählen() {
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
cb.checked = true;
});
updateSelectionCounts();
}
const sitzInput = document.getElementById('sitzInput');
const sitzauswahl = document.getElementById('sitzauswahl');
const gemeindedropdown = document.getElementById('gemeindeDropdown');
sitzauswahl.style.display = 'block';
function rechtsformenLöschen() {
document.querySelectorAll('input[name="rechtsformen[]"]').forEach(function(cb) {
cb.checked = false;
});
updateSelectionCounts();
}
gemeindedropdown.addEventListener('focusin', () => {
console.log("focusin")
sitzauswahl.style.display = 'block';
});
</script>
updateSelectionCounts();
</script>
</div>
</div>
</section>
<div class="push"></div>
</div>
<footer class="text-center">
<div class="container footer">
<div class="row">
<table>
<tr>
<td class="footer">
<div class="col-12">
<p><span><b>Impressum</b></span><br>
Silias KLG<br>
Toggenburgstrasse 31<br>
8245 Feuerthalen<br>
<a href="https://www.silias.ch" target="_blank">www.silias.ch</a><br>
<a href="mailto:info@silias.ch">info@silias.ch</a><br>
<a href="https://gitea.silias.ch/Silias-Public/Zefix_search">Projekt Repository</a>
</div>
</td>
<td class="footer">
<div class="col-12">
<p><span><b>Datenquelle</b></span><br>
Zentraler Firmenindex<br>
Eidgenössisches Justiz- und Polizeidepartement (EJPD)<br>
Bundesamt für Justiz (BJ)<br>
Eidgenössisches Amt für das Handelsregister (EHRA)<br>
Bundesrain 20<br>
3003 Bern<br>
<a href="https://www.zefix.admin.ch" target="_blank">www.zefix.admin.ch</a><br>
<a href="tel:+41584624197">+41 (0) 58 462 41 97</a><br>
<a href="zefix@bj.admin.ch">zefix@bj.admin.ch</a>
</p>
</div>
</td>
</tr>
</table>
</div>
</div>
</div>
</main>
<footer class="site-footer border-top">
<div class="container py-4">
<div class="row g-4">
<div class="col-md-4">
<h2 class="h6 mb-2">Impressum</h2>
<div class="text-muted" style="font-size:.95rem;">
Silias KLG<br>
Toggenburgstrasse 31<br>
8245 Feuerthalen<br>
<a href="https://www.silias.ch" target="_blank" rel="noopener">www.silias.ch</a><br>
<a href="mailto:info@silias.ch">info@silias.ch</a><br>
<a href="https://gitea.silias.ch/Silias-Public/Zefix_search">Projekt-Repository</a>
</div>
</div>
<div class="col-md-4">
<h2 class="h6 mb-2">Datenquelle</h2>
<div class="text-muted" style="font-size:.95rem;">
Zentraler Firmenindex<br>
Eidgenössisches Justiz- und Polizeidepartement (EJPD)<br>
Bundesamt für Justiz (BJ)<br>
Eidgenössisches Amt für das Handelsregister (EHRA)<br>
Bundesrain 20, 3003 Bern<br>
<a href="https://www.zefix.admin.ch" target="_blank" rel="noopener">www.zefix.admin.ch</a><br>
<a href="tel:+41584624197">+41 (0) 58 462 41 97</a><br>
<a href="mailto:zefix@bj.admin.ch">zefix@bj.admin.ch</a>
</div>
</div>
<div class="col-md-4">
<h2 class="h6 mb-2">Datenschutz</h2>
<div class="text-muted" style="font-size:.95rem;">
Wir verwenden Ihre E-Mail-Adresse ausschliesslich zur Bereitstellung des angeforderten Exports.
<br><a href="datenschutz.php">Datenschutzerklärung</a>
</div>
</div>
</div>
<div class="text-center text-muted mt-4" style="font-size:.9rem;">
© <?php echo date('Y'); ?> Silias KLG
</div>
</div>
</footer>
</body>
+11 -2
View File
@@ -1,4 +1,13 @@
<?php
echo "legalFormsCsvData = `". legalFormCSV($username, $password) ."`;";
?>
$legalFormsCsv = '';
try {
$legalFormsCsv = app_cached_string(
'legal-forms',
app_env_int('REFERENCE_CACHE_SECONDS', 86400, 300, 604800),
static fn(): string => legalFormCSV((string)$username, (string)$password)
);
} catch (Throwable $exception) {
error_log('Legal-form reference data could not be loaded: ' . $exception->getMessage());
}
echo 'legalFormsCsvData = ' . json_encode($legalFormsCsv, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';';
+29
View File
@@ -0,0 +1,29 @@
<?php
require 'SimpleXLSX.php';
require 'SimpleXLSXGen.php';
function read_xls_file($filename){
$fileContent = [];
if ($xlsx = SimpleXLSX::parse($filename)) {
$fileContent = ($xlsx->rows());
} else {
echo SimpleXLSX::parseError();
}
return $fileContent;
}
function write_xlsxFile($filename, $data) {
$xlsx = SimpleXLSXGen::fromArray($data);
$xlsx->saveAs($filename);
}
function download_xlsxFile($data) {
$xlsxArray = read_xls_file($_SERVER['DOCUMENT_ROOT'] ."/data/data.xlsx");
foreach ($data as $row) {
array_push($xlsxArray, $row);
}
$xlsx = SimpleXLSXGen::fromArray($xlsxArray);
$xlsx->downloadAs("data.xlsx");
}
+344
View File
@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
app_send_security_headers();
header('Cache-Control: no-store');
function renderPage(string $title, string $messageHtml, bool $isError = false, int $httpStatus = 200): void
{
http_response_code($httpStatus);
$logoPath = "img/silias-logo.png";
$statusClass = $isError
? "alert-danger border-0 shadow-sm"
: "alert-success border-0 shadow-sm";
$statusTitle = $isError ? "Fehler" : "Auftrag eingegangen";
echo '<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/bootstrap.css">
<link rel="stylesheet" href="css/custom.css?v=2.2">
<title>' . htmlspecialchars($title) . '</title>
<style>
.brand-card { border-radius: 1rem; }
.brand-box {
border: none;
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 10px 25px rgba(0,0,0,.04);
}
.brand-eyebrow {
font-size: .8rem;
letter-spacing: .04em;
text-transform: uppercase;
color: #6c757d;
margin-bottom: .25rem;
max-width: 420px;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-light site-header border-bottom py-2">
<div class="container">
<a class="brand-lockup" href="index.php" aria-label="Silias Zefix Export Startseite">
<span class="brand-logo-crop" aria-hidden="true"><img src="img/silias-logo.png" alt=""></span>
<span class="brand-divider" aria-hidden="true"></span>
<span class="brand-product">Zefix Export<small>Ein kostenloses Silias-Tool</small></span>
</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<div class="card shadow-sm brand-card">
<div class="card-body p-4 p-md-5">
<h1 class="h4 mb-3 d-flex align-items-center gap-2">
' . ($isError ? '<span class="text-danger">✖</span>' : '<span class="text-success">✔</span>') . '
' . htmlspecialchars($statusTitle) . '
</h1>
<div class="alert ' . $statusClass . ' mb-4">
' . $messageHtml . '
</div>
<div class="d-grid gap-2 d-sm-flex align-items-center">
<a class="btn btn-primary px-4" href="index.php">Zurück zur Startseite</a>
<a class="btn btn-link" href="https://www.silias.ch" target="_blank">Mehr über Silias →</a>
</div>
<hr class="my-4">
<div class="brand-box p-4">
<div class="d-flex align-items-start mb-3">
<div class="flex-shrink-0">
<span class="brand-logo-crop d-block">
<img src="' . htmlspecialchars($logoPath) . '" alt="Silias">
</span>
</div>
<div class="ms-4">
<div class="brand-eyebrow">
Ihr Partner für moderne IT-Infrastruktur & Automatisierung
</div>
<div class="fw-semibold fs-5">Silias KLG</div>
<div class="text-muted small">
IT, Automatisierung & individuelle Web-Lösungen
</div>
</div>
</div>
<div class="text-muted" style="font-size:.95rem;">
Wir bauen schlanke Tools wie dieses, automatisieren Prozesse und integrieren Systeme sauber in bestehende Umgebungen
(Hosting, Mail, ERP/CRM, Schnittstellen).<br><br>
Als Managed Service Provider betreuen wir KMU ganzheitlich von der Infrastruktur über Cloud,
Netzwerke und Telefonie bis zum laufenden Betrieb, Monitoring und persönlichen Support.
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<a class="btn btn-primary btn-sm" href="https://www.silias.ch" target="_blank">Website besuchen</a>
<a class="btn btn-outline-primary btn-sm" href="mailto:info@silias.ch">Kontakt</a>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="border-top bg-white">
<div class="container py-3 text-center text-muted small">
© ' . date('Y') . ' Silias KLG · <a href="datenschutz.php">Datenschutzerklärung</a>
</div>
</footer>
</body>
</html>';
}
/* ---------------- REQUEST AND ABUSE PROTECTION ---------------- */
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
renderPage('Methode nicht erlaubt', 'Bitte starten Sie den Export über das Formular auf der Startseite.', true, 405);
exit;
}
// Bots commonly fill fields that are deliberately hidden from real visitors.
$honeypot = isset($_POST['website']) && is_scalar($_POST['website']) ? trim((string)$_POST['website']) : '';
if ($honeypot !== '') {
renderPage(
'Auftrag eingegangen',
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind.',
false
);
exit;
}
if (!app_turnstile_is_configured()) {
renderPage('Export nicht verfügbar', 'Der Missbrauchsschutz ist noch nicht vollständig konfiguriert.', true, 503);
exit;
}
$clientIp = app_client_ip();
try {
$ipLimit = app_rate_limit(
'submit-ip',
$clientIp,
app_env_int('RATE_LIMIT_IP_MAX', 3, 1, 100),
app_env_int('RATE_LIMIT_IP_WINDOW_SECONDS', 900, 60, 86400)
);
} catch (Throwable $exception) {
error_log('Rate limit failure: ' . $exception->getMessage());
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
if (!$ipLimit['allowed']) {
header('Retry-After: ' . $ipLimit['retry_after']);
renderPage('Zu viele Anfragen', 'Von dieser Verbindung wurden zu viele Aufträge gesendet. Bitte versuchen Sie es später erneut.', true, 429);
exit;
}
$turnstileToken = isset($_POST['cf-turnstile-response']) && is_scalar($_POST['cf-turnstile-response'])
? (string)$_POST['cf-turnstile-response']
: '';
$turnstileResult = app_verify_turnstile($turnstileToken, $clientIp);
if (!$turnstileResult['success']) {
renderPage('Bot-Prüfung fehlgeschlagen', 'Die Sicherheitsprüfung konnte nicht bestätigt werden. Bitte laden Sie die Startseite neu und versuchen Sie es erneut.', true, 403);
exit;
}
$formStarted = isset($_POST['form_started']) && is_scalar($_POST['form_started']) ? (string)$_POST['form_started'] : '';
if (!app_validate_form_started_token($formStarted)) {
renderPage('Überprüfung fehlgeschlagen', 'Das Formular ist abgelaufen oder wurde zu schnell übermittelt. Bitte laden Sie die Startseite neu.', true, 400);
exit;
}
/* ---------------- INPUT VALIDATION ---------------- */
$emailValue = $_POST['email'] ?? '';
$email = is_scalar($emailValue) ? strtolower(trim((string)$emailValue)) : '';
if ($email === '' || strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
renderPage('Ungültige E-Mail', 'Bitte geben Sie eine gültige E-Mail-Adresse an.', true, 400);
exit;
}
$companyValue = $_POST['firma'] ?? '';
$companyName = is_scalar($companyValue) ? trim((string)$companyValue) : '';
$companyNameLength = function_exists('mb_strlen') ? mb_strlen($companyName) : strlen($companyName);
if ($companyNameLength > 200 || preg_match('//u', $companyName) !== 1 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', $companyName)) {
renderPage('Ungültiger Firmenname', 'Der Firmenname enthält ungültige Zeichen oder ist zu lang.', true, 400);
exit;
}
$maximumSeats = app_env_int('MAX_SEATS_PER_JOB', 500, 1, 5000);
$maximumLegalForms = app_env_int('MAX_LEGAL_FORMS_PER_JOB', 50, 1, 200);
$seats = app_normalize_positive_id_list($_POST['sitze'] ?? null, $maximumSeats);
$legalForms = app_normalize_positive_id_list($_POST['rechtsformen'] ?? null, $maximumLegalForms);
if ($seats === null || $legalForms === null) {
renderPage('Ungültige Auswahl', 'Bitte wählen Sie eine zulässige Anzahl Orte und Rechtsformen aus.', true, 400);
exit;
}
sort($seats, SORT_NUMERIC);
sort($legalForms, SORT_NUMERIC);
$requestCount = count($seats) * count($legalForms);
$maximumRequests = app_env_int('MAX_REQUESTS_PER_JOB', 5000, 1, 50000);
if ($requestCount > $maximumRequests) {
renderPage(
'Auswahl zu gross',
'Diese Auswahl würde ' . number_format($requestCount, 0, ',', "'") . ' einzelne Abfragen erzeugen. Erlaubt sind maximal ' . number_format($maximumRequests, 0, ',', "'") . '. Bitte schränken Sie Orte oder Rechtsformen ein.',
true,
400
);
exit;
}
try {
$emailLimit = app_rate_limit(
'submit-email',
$email,
app_env_int('RATE_LIMIT_EMAIL_MAX', 5, 1, 100),
app_env_int('RATE_LIMIT_EMAIL_WINDOW_SECONDS', 86400, 300, 604800)
);
} catch (Throwable $exception) {
error_log('Rate limit failure: ' . $exception->getMessage());
renderPage('Export nicht verfügbar', 'Der Export kann momentan nicht sicher verarbeitet werden. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
if (!$emailLimit['allowed']) {
header('Retry-After: ' . $emailLimit['retry_after']);
renderPage('Zu viele Anfragen', 'Für diese E-Mail-Adresse wurden heute bereits mehrere Exporte angefordert.', true, 429);
exit;
}
/* ---------------- TASK BUILD AND ATOMIC QUEUE WRITE ---------------- */
$baseRequest = [
'maxEntries' => 50,
'offset' => 0,
'languageKey' => 'de',
'deletedFirms' => isset($_POST['geloeschteRechtseinheiten']),
'searchType' => 'exact',
];
if ($companyName !== '') {
$baseRequest['name'] = $companyName;
}
$requestsToDo = [];
foreach ($legalForms as $legalForm) {
foreach ($seats as $seat) {
$request = $baseRequest;
$request['legalForms'] = [$legalForm];
$request['legalSeats'] = [$seat];
$requestsToDo[] = $request;
}
}
$fingerprint = hash('sha256', json_encode([
'email' => $email,
'company' => $companyName,
'deleted' => $baseRequest['deletedFirms'],
'seats' => $seats,
'legalForms' => $legalForms,
], JSON_THROW_ON_ERROR));
try {
$taskDirectory = app_task_dir();
$queueLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'queue.lock', 'c+');
if ($queueLock === false || !flock($queueLock, LOCK_EX)) {
throw new RuntimeException('Queue lock could not be acquired.');
}
try {
$taskFiles = glob($taskDirectory . DIRECTORY_SEPARATOR . '*.json') ?: [];
$maximumQueueSize = app_env_int('MAX_QUEUE_SIZE', 20, 1, 1000);
if (count($taskFiles) >= $maximumQueueSize) {
renderPage('Warteschlange voll', 'Momentan werden bereits viele Exporte verarbeitet. Bitte versuchen Sie es später erneut.', true, 503);
exit;
}
foreach ($taskFiles as $existingTaskFile) {
$existing = json_decode((string)file_get_contents($existingTaskFile), true);
if (is_array($existing) && hash_equals((string)($existing['fingerprint'] ?? ''), $fingerprint)) {
renderPage(
'Auftrag bereits vorhanden',
'Ein identischer Auftrag wartet bereits auf die Verarbeitung. Sie erhalten den Download-Link per E-Mail.',
false
);
exit;
}
}
$taskData = [
'version' => 2,
'createdAt' => time(),
'requests' => $requestsToDo,
'email' => $email,
'fingerprint' => $fingerprint,
'downloadToken' => bin2hex(random_bytes(16)),
];
$taskFilename = $taskDirectory . DIRECTORY_SEPARATOR . time() . '-' . bin2hex(random_bytes(16)) . '.json';
$temporaryFilename = $taskFilename . '.tmp';
$written = file_put_contents($temporaryFilename, json_encode($taskData, JSON_THROW_ON_ERROR), LOCK_EX);
if ($written === false || !rename($temporaryFilename, $taskFilename)) {
@unlink($temporaryFilename);
throw new RuntimeException('Task file could not be written.');
}
@chmod($taskFilename, 0600);
} finally {
flock($queueLock, LOCK_UN);
fclose($queueLock);
}
renderPage(
'Auftrag eingegangen',
'Wir haben Ihren Auftrag erhalten. Sie erhalten von uns eine E-Mail, sobald die Daten zum Download bereit sind. Dies kann je nach Datenmenge länger dauern.',
false
);
} catch (Throwable $exception) {
error_log('Task creation failed: ' . $exception->getMessage());
renderPage('Fehler', 'Der Auftrag konnte momentan nicht gespeichert werden. Bitte versuchen Sie es später erneut.', true, 503);
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require_once __DIR__ . '/app.php';
require_once __DIR__ . '/zefixAPI.php';
require_once __DIR__ . '/emailSender.php';
$maxExecutionTime = app_env_int('TASK_EXECUTOR_MAX_SECONDS', 120, 30, 900);
$minimumTaskAge = app_env_int('TASK_MIN_AGE_SECONDS', 10, 0, 300);
$latestEndTime = time() + $maxExecutionTime;
$smtpPassword = app_env('smtppassword');
app_migrate_legacy_tasks();
$downloadRetention = app_env_int('DOWNLOAD_RETENTION_HOURS', 48, 1, 720) * 3600;
$staleTaskRetention = app_env_int('STALE_TASK_RETENTION_HOURS', 168, 24, 2160) * 3600;
app_cleanup_directory(app_download_dir(), $downloadRetention, 'csv');
app_cleanup_directory(app_task_dir(), $staleTaskRetention, 'json');
app_cleanup_directory(__DIR__ . DIRECTORY_SEPARATOR . 'download', $downloadRetention, 'csv');
$executorLock = fopen(app_private_dir() . DIRECTORY_SEPARATOR . 'executor.lock', 'c+');
if ($executorLock === false || !flock($executorLock, LOCK_EX | LOCK_NB)) {
fwrite(STDOUT, "Another executor is already running.\n");
exit(0);
}
/**
* @return int|false|null Next offset, false when complete, null on a temporary error.
*/
function doRequest(array $data, string $filename, string $username, string $password): int|false|null
{
$response = sendAPICompanySearchRequest($username, $password, $data);
if (!is_string($response) || $response === '') {
return null;
}
$responseObject = json_decode($response, true);
if (!is_array($responseObject) || array_key_exists('error', $responseObject) || !is_array($responseObject['list'] ?? null)) {
error_log('ZEFIX search returned an invalid or error response.');
return null;
}
$companyData = [];
foreach ($responseObject['list'] as $company) {
if (!is_array($company) || !isset($company['uid'])) {
continue;
}
$detailsResponse = sendAPICompanyInfoRequest($username, $password, (string)$company['uid']);
$details = is_string($detailsResponse) ? json_decode($detailsResponse, true) : null;
$companyFullData = is_array($details) && is_array($details[0] ?? null) ? $details[0] : null;
if ($companyFullData === null) {
continue;
}
$companyData[] = [
$companyFullData['name'] ?? '',
$companyFullData['address']['careOf'] ?? '',
$companyFullData['address']['street'] ?? '',
$companyFullData['address']['houseNumber'] ?? '',
$companyFullData['address']['swissZipCode'] ?? '',
$companyFullData['address']['city'] ?? '',
$companyFullData['uid'] ?? '',
$companyFullData['legalSeat'] ?? '',
$companyFullData['legalForm']['name']['de'] ?? '',
$companyFullData['status'] ?? '',
$companyFullData['sogcDate'] ?? '',
$companyFullData['deletionDate'] ?? '',
];
}
$isNewFile = !is_file($filename);
$file = fopen($filename, $isNewFile ? 'x' : 'a');
if ($file === false) {
error_log('Export file could not be opened.');
return null;
}
try {
if ($isNewFile) {
fputcsv($file, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', '\\');
@chmod($filename, 0600);
}
foreach ($companyData as $row) {
fputcsv($file, $row, ',', '"', '\\');
}
} finally {
fclose($file);
}
return ($responseObject['hasMoreResults'] ?? false)
? (int)($responseObject['maxOffset'] ?? 0)
: false;
}
function saveTask(string $filename, array $task): bool
{
$temporary = $filename . '.tmp';
$written = file_put_contents($temporary, json_encode($task, JSON_THROW_ON_ERROR), LOCK_EX);
if ($written === false || !rename($temporary, $filename)) {
@unlink($temporary);
return false;
}
@chmod($filename, 0600);
return true;
}
try {
while ($latestEndTime - time() > 30) {
$taskFiles = glob(app_task_dir() . DIRECTORY_SEPARATOR . '*.json') ?: [];
sort($taskFiles, SORT_STRING);
if ($taskFiles === []) {
fwrite(STDOUT, "Nothing to do.\n");
break;
}
$taskFilename = $taskFiles[0];
$modified = filemtime($taskFilename);
if ($modified !== false && $modified + $minimumTaskAge > time()) {
sleep(min(5, max(1, ($modified + $minimumTaskAge) - time())));
continue;
}
$task = json_decode((string)file_get_contents($taskFilename), true);
if (!is_array($task) || !is_array($task['requests'] ?? null) || !filter_var($task['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
error_log('Invalid task quarantined: ' . basename($taskFilename));
@rename($taskFilename, $taskFilename . '.invalid');
continue;
}
if (!preg_match('/^[a-f0-9]{32}$/', (string)($task['downloadToken'] ?? ''))) {
$task['downloadToken'] = bin2hex(random_bytes(16));
// Preserve partially generated exports from the previous public-download layout.
$legacyCsv = __DIR__ . DIRECTORY_SEPARATOR . 'download' . DIRECTORY_SEPARATOR . pathinfo($taskFilename, PATHINFO_FILENAME) . '.csv';
if (is_file($legacyCsv)) {
@rename($legacyCsv, app_download_path($task['downloadToken']));
}
if (!saveTask($taskFilename, $task)) {
error_log('Could not migrate legacy task metadata.');
break;
}
}
$csvFile = app_download_path((string)$task['downloadToken']);
if ($task['requests'] !== []) {
$nextOffset = doRequest($task['requests'][0], $csvFile, (string)$username, (string)$password);
if ($nextOffset === null) {
fwrite(STDOUT, "Temporary API error; task retained for retry.\n");
break;
}
if ($nextOffset !== false) {
$task['requests'][0]['offset'] = $nextOffset;
} else {
array_shift($task['requests']);
}
if (!saveTask($taskFilename, $task)) {
error_log('Could not save task progress.');
break;
}
continue;
}
if (!is_file($csvFile)) {
$emptyFile = fopen($csvFile, 'x');
if ($emptyFile !== false) {
fputcsv($emptyFile, ['name', 'careOf', 'street', 'houseNumber', 'swissZipCode', 'city', 'uid', 'legalSeat', 'legalForm', 'status', 'sogcDate', 'deletionDate'], ',', '"', '\\');
fclose($emptyFile);
@chmod($csvFile, 0600);
}
}
if (sendEmail((string)$task['email'], (string)$task['downloadToken'], $smtpPassword)) {
unlink($taskFilename);
fwrite(STDOUT, 'Completed ' . basename($taskFilename) . ".\n");
} else {
fwrite(STDOUT, "Email delivery failed; task retained for retry.\n");
break;
}
}
} finally {
flock($executorLock, LOCK_UN);
fclose($executorLock);
}
+221
View File
@@ -0,0 +1,221 @@
<?php
require_once __DIR__ . '/app.php';
$username = app_env('username');
$password = app_env('password');
function sendAPICompanyInfoRequest(string $username, string $password, string $uid): string|bool {
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixPublicREST/api/v1/company/uid/'.$uid;
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
error_log('ZEFIX company info request failed: ' . curl_error($ch));
}
// Close cURL session
curl_close($ch);
return $response;
}
/**
* @param string $
* @param string $
* @param array $data
* @return bool|string
*/
function sendAPICompanySearchRequest(string $username, string $password, array $data): string|bool {
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixREST/api/v1/firm/search.json';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
error_log('ZEFIX company search request failed: ' . curl_error($ch));
}
// Close cURL session
curl_close($ch);
return $response;
}
function sendAPICommunityRequest(string $username, string $password): string|bool
{
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixREST/api/v1/community.json';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
error_log('ZEFIX community request failed: ' . curl_error($ch));
}
// Close cURL session
curl_close($ch);
return $response;
}
function communityCSV(string $username, string $password): string {
if ($username === '' || $password === '') {
error_log('ZEFIX credentials are not configured.');
return '';
}
$response = sendAPICommunityRequest($username, $password);
if (!is_string($response)) {
return '';
}
$communityArray = json_decode($response, true);
if (!is_array($communityArray)) {
return '';
}
$csvOutput = "id,bfsId,Kanton,Gemeindename,registryOfCommerceId,replacedById\n";
foreach ($communityArray as $item) {
if (!is_array($item)) {
return '';
}
$row = [
$item['id'] ?? '',
$item['bfsId'] ?? '',
$item['canton'] ?? '',
$item['name'] ?? '',
$item['registryOfficeId'] ?? '',
$item['replacedById'] ?? ''
];
$csvOutput .= implode(',', $row) . "\n";
}
return $csvOutput;
}
function sendAPILegalFormRequest(string $username, string $password): string|bool
{
// API endpoint
$apiUrl = 'https://www.zefix.admin.ch/ZefixREST/api/v1/legalForm.json';
// Headers
$headers = array(
"accept: application/json",
"Content-Type: application/json"
);
// Initialize cURL session
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($ch, CURLOPT_URL, $apiUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
// Execute cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if (curl_errno($ch)) {
error_log('ZEFIX legal form request failed: ' . curl_error($ch));
}
// Close cURL session
curl_close($ch);
return $response;
}
function legalFormCSV(string $username, string $password): string {
if ($username === '' || $password === '') {
error_log('ZEFIX credentials are not configured.');
return '';
}
$response = sendAPILegalFormRequest($username, $password);
if (!is_string($response)) {
return '';
}
$legalformArray = json_decode($response, true);
if (!is_array($legalformArray)) {
return '';
}
$csvOutput = "id,name\n";
// Create CSV rows
foreach ($legalformArray as $item) {
if (!is_array($item) || !isset($item['name']) || !is_array($item['name'])) {
return '';
}
$csvOutput .= $item["id"].",".$item["name"]["de"] . "\n";
}
return $csvOutput;
}
?>