generated from jric11/baseProject
Codex's updates...
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/xml/settings.local.xml
|
||||||
|
/.env
|
||||||
|
/.tools/
|
||||||
|
/tests/.local/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
Options -Indexes
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports)(?:/|$) - [F,L,NC]
|
||||||
|
RewriteRule (^|/)\. - [F,L]
|
||||||
|
</IfModule>
|
||||||
|
<FilesMatch "^(composer\.(json|lock)|README\.md)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
DirectoryIndex main.php
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Authentication changes and deployment
|
||||||
|
|
||||||
|
Login, signup, password changes, and password reset now use `classes/AuthService.php`.
|
||||||
|
The existing PHP/XSL presentation structure is retained. Signup creates an active
|
||||||
|
`User` account; it does not grant administrator access or club membership.
|
||||||
|
|
||||||
|
## Before using this version against your database
|
||||||
|
|
||||||
|
1. Back up the database and use a maintenance window for the schema update.
|
||||||
|
2. Use 64-bit PHP 8.3 or newer. Run `composer install` and
|
||||||
|
`composer check-platform-reqs` with the same PHP configuration used by the web server.
|
||||||
|
3. Configure database and SMTP settings as described below.
|
||||||
|
4. From the project directory, run `php bin/migrate-auth.php` once. It is safe to
|
||||||
|
rerun. It adds the session version, password reset storage, rate limit storage,
|
||||||
|
and refreshes `view_users`. It preserves accounts and password hashes. MySQL
|
||||||
|
schema changes may briefly lock tables; this is not a web-request migration.
|
||||||
|
5. Serve the site over HTTPS and verify the private-directory blocking rules.
|
||||||
|
6. Test delivery of a reset email to an account you control.
|
||||||
|
|
||||||
|
The migration expects the schema in `tables/users.php`, including
|
||||||
|
`user_password_hash`, `user_status`, and `user_last_login`. It refuses to continue
|
||||||
|
if those fields are missing. Do not rebuild the database to fix an older schema;
|
||||||
|
map and migrate its existing account fields while preserving the data.
|
||||||
|
|
||||||
|
On October 18, 2026, the migration was applied to the configured development
|
||||||
|
database, `192.168.1.190/huntingclubroster`, after a complete backup was restored
|
||||||
|
and checked locally. Existing account data and original table row counts were
|
||||||
|
preserved. Signup, login, logout and password reset were verified using the local
|
||||||
|
application against that database; the SMTP provider accepted a real test reset
|
||||||
|
email over TLS. The temporary account and restored database copy were removed.
|
||||||
|
The public hostname did not resolve from the verification machine, so these
|
||||||
|
results do not confirm deployment or operation of the public site. See
|
||||||
|
[database verification](DATABASE-VERIFICATION.md) for the backup and results.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Your existing settings were preserved in ignored `xml/settings.local.xml`.
|
||||||
|
The tracked `xml/settings.xml` now contains no credentials. Settings are loaded in
|
||||||
|
this order: `HCR_SETTINGS_FILE`, local settings if present, then tracked defaults.
|
||||||
|
Environment variables override individual fields:
|
||||||
|
|
||||||
|
| Variable | XML field |
|
||||||
|
| --- | --- |
|
||||||
|
| `HCR_ENVIRONMENT` | `Environment` |
|
||||||
|
| `HCR_DB_HOST` | `DatabaseServer` |
|
||||||
|
| `HCR_DB_PORT` | `DatabasePort` (default 3306) |
|
||||||
|
| `HCR_DB_NAME` | `DatabaseName` |
|
||||||
|
| `HCR_DB_USER` | `DatabaseUser` |
|
||||||
|
| `HCR_DB_PASSWORD` | `DatabasePassword` |
|
||||||
|
| `HCR_SMTP_HOST` | `SmtpServer` |
|
||||||
|
| `HCR_SMTP_PORT` | `SmtpPort` |
|
||||||
|
| `HCR_SMTP_USER` | `SmtpUserName` |
|
||||||
|
| `HCR_SMTP_PASSWORD` | `SmtpPassword` |
|
||||||
|
| `HCR_SMTP_FROM` | `SmtpFromEmail` (defaults to SMTP username) |
|
||||||
|
| `HCR_APPLICATION_URL` | `ApplicationUrl` |
|
||||||
|
|
||||||
|
The configured application URL is **https://app.myhuntingclubroster.com**. It must
|
||||||
|
be the trusted HTTPS application directory, without `/main.php`, a query string,
|
||||||
|
or a fragment. Request `Host` headers are never used to construct email links.
|
||||||
|
SMTP uses implicit TLS on port 465 and required STARTTLS on other ports,
|
||||||
|
including 587. Certificate verification remains enabled. The sender must be
|
||||||
|
authorized by your SMTP provider. For production, use an external settings file
|
||||||
|
outside the document root or environment variables and set `Environment` to
|
||||||
|
`Production`. Production requests require the web server to set `HTTPS=on`.
|
||||||
|
If TLS terminates at a reverse proxy, configure that mapping in the web server;
|
||||||
|
the application does not trust arbitrary forwarded headers.
|
||||||
|
|
||||||
|
Moving credentials out of tracked files does not remove them from Git history.
|
||||||
|
Rotate the previously committed database/SMTP credentials, webhook, and any
|
||||||
|
account still using the former hardcoded initial administrator password.
|
||||||
|
|
||||||
|
## Behavior and protections
|
||||||
|
|
||||||
|
- Login accepts the email shown by the form, with username compatibility.
|
||||||
|
SQL uses bound parameters. Passwords use PHP's password hashing API.
|
||||||
|
- Signup validates names, email, username, duplicate accounts, password length,
|
||||||
|
and confirmation on the server. Privilege fields submitted by the browser are
|
||||||
|
ignored. New passwords must be 12–72 bytes to avoid bcrypt truncation.
|
||||||
|
- Password reset emails contain 256-bit random links. Only token hashes are
|
||||||
|
stored; links expire after 30 minutes. Completing a reset consumes every
|
||||||
|
outstanding token for that account inside a transaction. Requesting a reset
|
||||||
|
does not change the password. Failed email delivery removes the unsent token.
|
||||||
|
- Reset requests return the same public message for unknown, inactive,
|
||||||
|
throttled, and undeliverable accounts. Delivery errors go to the server log.
|
||||||
|
- Password changes require the current password and matching confirmation.
|
||||||
|
Successful changes and resets revoke all old sessions. Administrator role and
|
||||||
|
status changes also revoke sessions. Account status and privileges are checked
|
||||||
|
against the database on each authenticated request.
|
||||||
|
- Sessions rotate on login/logout, expire after 30 idle minutes, and use
|
||||||
|
HttpOnly/SameSite cookies plus Secure cookies on HTTPS.
|
||||||
|
- CSRF checks cover public authentication forms and authenticated POST actions.
|
||||||
|
Existing application forms and same-origin AJAX requests receive the token.
|
||||||
|
- Database-backed limits apply across new cookies: 10 login attempts per account
|
||||||
|
and 50 per IP in 15 minutes; 3 reset emails per account and 10 per IP per hour;
|
||||||
|
5 signup attempts per IP per hour. Reset submissions and password changes are
|
||||||
|
limited separately.
|
||||||
|
- Routing uses an explicit allowlist. Administrative screens, account editing,
|
||||||
|
resets for other users, and destructive maintenance actions require an
|
||||||
|
administrator role on the server. General helper methods are not endpoints.
|
||||||
|
System administrator names in the old settings file no longer override the
|
||||||
|
role stored in the database.
|
||||||
|
- Errors are logged without recursively opening a database connection or
|
||||||
|
emailing from the error handler. Visitors receive a generic failure message.
|
||||||
|
|
||||||
|
Run `php bin/cleanup-auth.php` daily using your normal server scheduler to remove
|
||||||
|
expired reset tokens and old rate-limit rows. No scheduler was installed here.
|
||||||
|
Exclude query strings containing reset tokens from retained access/analytics
|
||||||
|
logs where your hosting supports it.
|
||||||
|
|
||||||
|
## Hosting and setup scripts
|
||||||
|
|
||||||
|
Apache `.htaccess` and IIS `web.config` block private application directories.
|
||||||
|
Apache needs the rewrite module and permission to apply these rules. On Nginx,
|
||||||
|
configure equivalent denies for `xml`, `classes`, `vendor`, `tables`, `views`,
|
||||||
|
`bin`, `migrations`, `tests`, `reports`, `nbproject`, and hidden files. Verify that
|
||||||
|
`/xml/settings.local.xml` and setup scripts cannot be downloaded or executed.
|
||||||
|
The production web server configuration has not been exercised by local tests.
|
||||||
|
|
||||||
|
Setup scripts also refuse HTTP execution independently. The system rebuild is
|
||||||
|
disabled unless `HCR_ALLOW_REBUILD=yes`; it requires a unique
|
||||||
|
`HCR_INITIAL_ADMIN_PASSWORD` of 12–72 bytes before doing any rebuilding. It is
|
||||||
|
destructive and is not the authentication migration.
|
||||||
|
|
||||||
|
For local development only: `php -S 127.0.0.1:8080 bin/dev-router.php`. This router
|
||||||
|
serves the entry point and static assets while denying private files. Set the
|
||||||
|
environment to `Development` for plain HTTP development. Never use PHP's built-in
|
||||||
|
server for production.
|
||||||
|
|
||||||
|
## Dependencies and verification
|
||||||
|
|
||||||
|
Composer requirements now explicitly declare the PHP version and authentication
|
||||||
|
extensions. PhpSpreadsheet was upgraded from 5.2.0 to 5.10.0 after the dependency
|
||||||
|
audit found advisories. Compatible ZipStream and Composer PCRE dependencies were
|
||||||
|
updated, and the lock file and installed autoloader were synchronized. Existing
|
||||||
|
auxiliary TCPDF library files were preserved. Composer reported no known
|
||||||
|
dependency security advisories after the update.
|
||||||
|
|
||||||
|
`tests/auth_integration.php` creates and drops a randomly named database on the
|
||||||
|
server explicitly supplied by `HCR_TEST_DSN`, `HCR_TEST_USER`, and
|
||||||
|
`HCR_TEST_PASSWORD`. It never reads the application's database credentials.
|
||||||
|
Run `php tests/auth_integration.php` (or `composer test:auth`). The test server
|
||||||
|
account needs permission to create and drop databases. Use an isolated server.
|
||||||
|
|
||||||
|
`tests/auth_http.py` runs the real forms against a temporary PHP development
|
||||||
|
server and disposable database. Supply the same test variables plus
|
||||||
|
`HCR_DB_HOST`, `HCR_DB_PORT`, `HCR_DB_USER`, and `HCR_DB_PASSWORD` for that isolated
|
||||||
|
server. `PHP_TEST_COMMAND` can hold a JSON argument list for a PHP executable and
|
||||||
|
extension options; it defaults to `["php"]`. Run `python tests/auth_http.py`.
|
||||||
|
Email delivery is disabled in HTTP tests; the service tests capture reset emails
|
||||||
|
and simulate transport failure. Tests cover schema migration, signup/login,
|
||||||
|
validation, SQL injection input, CSRF, authorization, token expiry/reuse,
|
||||||
|
throttling, session revocation, logout, and rendering.
|
||||||
|
|
||||||
|
A wider review of unrelated roster/reporting logic and deployment penetration
|
||||||
|
testing are outside this authentication change.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Database backup, migration and verification
|
||||||
|
|
||||||
|
Completed October 18, 2026 against the configured **development** database:
|
||||||
|
`192.168.1.190/huntingclubroster` (MariaDB 10.5.29).
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
The complete SQL backup includes tables, views, triggers, routines and events:
|
||||||
|
|
||||||
|
`.tools/backups/20261018-190815/huntingclubroster.sql`
|
||||||
|
|
||||||
|
It is 34,198 bytes. SHA-256:
|
||||||
|
|
||||||
|
`0cf92f6c5e05433a50bf7f8a8288e39e68893ad82dff679f2d57ea1317d358d9`
|
||||||
|
|
||||||
|
The backup completion marker and checksum were verified. It was restored into
|
||||||
|
an isolated local MariaDB server, and row counts matched for all 10 original
|
||||||
|
tables. The authentication migration passed against the restored copy before
|
||||||
|
being applied to the configured database. The restore copy was then removed
|
||||||
|
and the temporary database server shut down. The original backup is retained.
|
||||||
|
|
||||||
|
The server does not support TLS. The user explicitly approved an unencrypted
|
||||||
|
database connection for the backup, migration and verification. SMTP transport
|
||||||
|
remained encrypted with certificate verification enabled.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
The migration added `users.user_auth_version`, `password_resets`, and
|
||||||
|
`auth_attempts`, ensured transactional user storage, and refreshed `view_users`.
|
||||||
|
Existing user data (including the password hash) and all original table row
|
||||||
|
counts were confirmed unchanged immediately after migration.
|
||||||
|
|
||||||
|
## Verification against the migrated database
|
||||||
|
|
||||||
|
Eleven checks passed using temporary account data:
|
||||||
|
|
||||||
|
1. Signup page loaded.
|
||||||
|
2. Signup created a regular user account.
|
||||||
|
3. An incorrect password was rejected.
|
||||||
|
4. The new account could log in.
|
||||||
|
5. Logout succeeded.
|
||||||
|
6. The configured SMTP provider accepted the authorized reset email over TLS.
|
||||||
|
7. The generated reset token opened the password form through the local server.
|
||||||
|
8. Resetting the temporary account password succeeded.
|
||||||
|
9. Reusing the consumed token failed.
|
||||||
|
10. The previous password failed after reset.
|
||||||
|
11. The new password could log in.
|
||||||
|
|
||||||
|
The reset request was generated by the shared authentication service and sent
|
||||||
|
through the real configured SMTP transport. Signup, login, logout, reset-token
|
||||||
|
validation, and password replacement used the actual HTTP handlers. No existing
|
||||||
|
account was used for a password reset. The temporary account, its reset tokens,
|
||||||
|
and test rate-limit records were removed; the existing account snapshot matched
|
||||||
|
after cleanup. A skipped auto-increment value from the temporary account is
|
||||||
|
expected and was not reset.
|
||||||
|
|
||||||
|
Private machine-readable manifests and results are stored beside the backup.
|
||||||
|
The backup folder and temporary verification helpers are ignored by Git.
|
||||||
|
|
||||||
|
## Public-site limitation
|
||||||
|
|
||||||
|
`https://app.myhuntingclubroster.com` did not resolve from this machine during
|
||||||
|
verification. The checks used the updated local application connected to the
|
||||||
|
configured development database. They do not prove that the public server has
|
||||||
|
the new code or that its database is this same database. DNS, deployment and
|
||||||
|
public HTTPS operation still need verification before declaring the live site
|
||||||
|
ready. SMTP acceptance was verified; receipt in the recipient's inbox was not
|
||||||
|
independently confirmed.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Hunting Club Roster
|
||||||
|
|
||||||
|
See [authentication setup and changes](AUTHENTICATION.md) before deploying the
|
||||||
|
updated login, signup, and password reset flows. The authentication migration
|
||||||
|
must be applied to the existing database before using this version.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
require_once __DIR__ . '/../classes/Base.php';
|
||||||
|
$db = (new Base())->connect();
|
||||||
|
$db->exec('DELETE FROM password_resets WHERE expires_at < UTC_TIMESTAMP()');
|
||||||
|
$db->exec('DELETE FROM auth_attempts WHERE window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)');
|
||||||
|
echo "Expired authentication records removed.\n";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
/** Router for the local PHP development server; not used by Apache/IIS. */
|
||||||
|
if (PHP_SAPI !== 'cli-server') { http_response_code(404); exit; }
|
||||||
|
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/');
|
||||||
|
if ($path === '/' || $path === '/main.php') {
|
||||||
|
require __DIR__ . '/../main.php';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('~\A/(css|js|images)/[A-Za-z0-9_./-]+\.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|map)\z~i', $path)) {
|
||||||
|
$file = realpath(__DIR__ . '/..' . $path);
|
||||||
|
$root = realpath(__DIR__ . '/..');
|
||||||
|
if ($file && str_starts_with($file, $root . DIRECTORY_SEPARATOR) && !str_contains($path, '..')) return false;
|
||||||
|
}
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Page not found.');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Explicit CLI migration: never connect or alter tables during normal web requests.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/../classes/Base.php';
|
||||||
|
$db = (new Base())->connect();
|
||||||
|
$columns = $db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach (['user_password_hash', 'user_first_name', 'user_last_name', 'user_status', 'user_last_login'] as $column) {
|
||||||
|
if (!in_array($column, $columns, true))
|
||||||
|
throw new RuntimeException("Missing users.{$column}. Align the database with tables/users.php before migrating; existing account data must be preserved.");
|
||||||
|
}
|
||||||
|
$sql = file_get_contents(__DIR__ . '/../migrations/001_auth.sql');
|
||||||
|
if (in_array('user_auth_version', $columns, true)) {
|
||||||
|
$sql = str_replace('ALTER TABLE users ADD COLUMN user_auth_version INT UNSIGNED NOT NULL DEFAULT 0;', '', $sql);
|
||||||
|
}
|
||||||
|
foreach (explode(';', $sql) as $statement) {
|
||||||
|
if (trim($statement))
|
||||||
|
$db->exec($statement);
|
||||||
|
}
|
||||||
|
// Refresh the view so users.* includes the new session version column.
|
||||||
|
require __DIR__ . '/../views/view_users.php';
|
||||||
|
$db->exec(str_replace('create view', 'CREATE OR REPLACE VIEW', $SQL));
|
||||||
|
echo "Authentication schema is ready.\n";
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/** Shared account storage and security rules; no HTML or request dispatch here. */
|
||||||
|
class AuthService extends Base {
|
||||||
|
|
||||||
|
public const IDLE_TIMEOUT = 1800;
|
||||||
|
|
||||||
|
public function findUser(string $identifier): ?array {
|
||||||
|
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1');
|
||||||
|
$stmt->execute([':email' => trim($identifier), ':name' => trim($identifier)]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function passwordError(string $password, string $confirmation): string {
|
||||||
|
if ($password !== $confirmation)
|
||||||
|
return 'Passwords must match.';
|
||||||
|
// PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation.
|
||||||
|
if (strlen($password) < 12 || strlen($password) > 72 || str_contains($password, "\0")) {
|
||||||
|
return 'Use a password between 12 and 72 bytes long.';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared database counters survive session/cookie changes. Requires the auth migration. */
|
||||||
|
public function allowAttempt(string $scope, string $identifier, int $limit, int $seconds): bool {
|
||||||
|
$db = $this->connect();
|
||||||
|
$key = hash('sha256', $scope . ':' . strtolower(trim($identifier)));
|
||||||
|
$stmt = $db->prepare('INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP())
|
||||||
|
ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1),
|
||||||
|
window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)');
|
||||||
|
$stmt->execute([':key' => $key, ':seconds' => $seconds, ':window' => $seconds]);
|
||||||
|
$stmt = $db->prepare('SELECT attempts FROM auth_attempts WHERE attempt_key = :key');
|
||||||
|
$stmt->execute([':key' => $key]);
|
||||||
|
return (int) $stmt->fetchColumn() <= $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $identifier, string $password): bool {
|
||||||
|
$ipAllowed = $this->allowAttempt('login-ip', $_SERVER['REMOTE_ADDR'] ?? '', 50, 900);
|
||||||
|
$accountAllowed = $this->allowAttempt('login-account', $identifier, 10, 900);
|
||||||
|
if (!$ipAllowed || !$accountAllowed)
|
||||||
|
return false;
|
||||||
|
$user = $this->findUser($identifier);
|
||||||
|
// A real hash avoids an immediate return for unknown accounts.
|
||||||
|
$hash = $user['user_password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.';
|
||||||
|
$verified = password_verify($password, $hash);
|
||||||
|
if (!$user || !$verified || $user['user_status'] !== 'active')
|
||||||
|
return false;
|
||||||
|
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
|
||||||
|
$stmt = $this->connect()->prepare('UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash');
|
||||||
|
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]);
|
||||||
|
}
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION[self::USER_AUTHENTICATED] = true;
|
||||||
|
$_SESSION[self::USER_SERIAL] = (int) $user['user_serial'];
|
||||||
|
$_SESSION[self::USER_NAME] = $user['user_name'];
|
||||||
|
$_SESSION[self::USER_FULL_NAME] = $user['user_full_name'];
|
||||||
|
$_SESSION[self::USER_EMAIL] = $user['user_email'];
|
||||||
|
$_SESSION[self::USER_ROLE] = $user['user_role'];
|
||||||
|
$_SESSION['auth_version'] = (int) $user['user_auth_version'];
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
$stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id');
|
||||||
|
$stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUser(): ?array {
|
||||||
|
if (empty($_SESSION[self::USER_AUTHENTICATED]))
|
||||||
|
return null;
|
||||||
|
if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT)
|
||||||
|
return null;
|
||||||
|
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id');
|
||||||
|
$stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
if (!$user || $user['user_status'] !== 'active' || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1))
|
||||||
|
return null;
|
||||||
|
$_SESSION['last_activity'] = time();
|
||||||
|
$_SESSION[self::USER_ROLE] = $user['user_role'];
|
||||||
|
$_SESSION[self::USER_NAME] = $user['user_name'];
|
||||||
|
$_SESSION[self::USER_FULL_NAME] = $user['user_full_name'];
|
||||||
|
$_SESSION[self::USER_EMAIL] = $user['user_email'];
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(array $input): void {
|
||||||
|
$first = trim($input['user_first_name'] ?? '');
|
||||||
|
$last = trim($input['user_last_name'] ?? '');
|
||||||
|
$email = trim($input['user_email'] ?? '');
|
||||||
|
$name = trim($input['user_name'] ?? '');
|
||||||
|
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128)
|
||||||
|
throw new InvalidArgumentException('Enter your first and last name (up to 128 bytes each).');
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100)
|
||||||
|
throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).');
|
||||||
|
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name))
|
||||||
|
throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.');
|
||||||
|
$error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? '');
|
||||||
|
if ($error)
|
||||||
|
throw new InvalidArgumentException($error);
|
||||||
|
$db = $this->connect();
|
||||||
|
// Cross-field uniqueness keeps email-or-username login unambiguous.
|
||||||
|
$stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1');
|
||||||
|
$stmt->execute([':name' => $name, ':email' => $email, ':email2' => $email, ':name2' => $name]);
|
||||||
|
if ($stmt->fetch())
|
||||||
|
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
|
||||||
|
$stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')");
|
||||||
|
try {
|
||||||
|
$stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() === '23000')
|
||||||
|
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never modify the password when somebody merely requests an email. */
|
||||||
|
public function requestReset(string $identifier): void {
|
||||||
|
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
|
||||||
|
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
|
||||||
|
if (!$ipAllowed || !$accountAllowed)
|
||||||
|
return;
|
||||||
|
$user = $this->findUser($identifier);
|
||||||
|
if (!$user || $user['user_status'] !== 'active')
|
||||||
|
return;
|
||||||
|
$baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
|
||||||
|
if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https'
|
||||||
|
|| parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null
|
||||||
|
|| parse_url($baseUrl, PHP_URL_USER) !== null || parse_url($baseUrl, PHP_URL_PASS) !== null) {
|
||||||
|
throw new RuntimeException('Configure an HTTPS ApplicationUrl before sending reset emails.');
|
||||||
|
}
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$db = $this->connect();
|
||||||
|
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
|
||||||
|
$hash = hash('sha256', $token);
|
||||||
|
$stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]);
|
||||||
|
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token);
|
||||||
|
if (!$this->deliverResetEmail($user, $url)) {
|
||||||
|
$stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash');
|
||||||
|
$stmt->execute([':hash' => $hash]);
|
||||||
|
throw new RuntimeException('Password reset email delivery failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deliverResetEmail(array $user, string $url): bool {
|
||||||
|
return (new Email())->sendPasswordReset($user, $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $token, string $password, string $confirmation): bool {
|
||||||
|
$error = self::passwordError($password, $confirmation);
|
||||||
|
if ($error)
|
||||||
|
throw new InvalidArgumentException($error);
|
||||||
|
if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
|
||||||
|
return false;
|
||||||
|
$db = $this->connect();
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Serialize resets and password changes on the user row.
|
||||||
|
$stmt = $db->prepare('SELECT user_serial FROM password_resets WHERE token_hash = :hash');
|
||||||
|
$stmt->execute([':hash' => hash('sha256', $token)]);
|
||||||
|
$id = $stmt->fetchColumn();
|
||||||
|
if (!$id) {
|
||||||
|
$db->rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare("SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE");
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
$db->rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare('SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE');
|
||||||
|
$stmt->execute([':hash' => hash('sha256', $token)]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
$db->rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->storePassword($db, (int) $id, $password);
|
||||||
|
$db->commit();
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction())
|
||||||
|
$db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changePassword(int $id, string $current, string $password, string $confirmation): bool {
|
||||||
|
$error = self::passwordError($password, $confirmation);
|
||||||
|
if ($error)
|
||||||
|
throw new InvalidArgumentException($error);
|
||||||
|
$db = $this->connect();
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE");
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
if (!$user || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1) || !password_verify($current, $user['user_password_hash'])) {
|
||||||
|
$db->rollBack();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->storePassword($db, $id, $password);
|
||||||
|
$db->commit();
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($db->inTransaction())
|
||||||
|
$db->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storePassword(PDO $db, int $id, string $password): void {
|
||||||
|
$stmt = $db->prepare('UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id');
|
||||||
|
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $id]);
|
||||||
|
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-381
@@ -1,394 +1,49 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// =====================================================================
|
/** Reset page controller; account storage and email are shared with the other flows. */
|
||||||
// Title: Authentication Class
|
class Authentication extends Base {
|
||||||
// Purpose: This class stands outside the Base Class for password resets
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
require_once "vendor/autoload.php";
|
|
||||||
|
|
||||||
use PHPMailer\PHPMailer\PHPMailer;
|
|
||||||
use PHPMailer\PHPMailer\Exception;
|
|
||||||
|
|
||||||
class Authentication {
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
private $settings = "";
|
|
||||||
private $ghrconnection = "";
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// Class Constructor
|
|
||||||
// =================
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
|
|
||||||
// Get Settings
|
|
||||||
|
|
||||||
$this->settings = new SimpleXMLElement((file_exists("xml/settings.xml") ? file_get_contents("xml/settings.xml") : "<settings/>"));
|
|
||||||
|
|
||||||
if ($this->settings->count() === 0) {
|
|
||||||
|
|
||||||
error_log("GHR Authentication Class : Unable to initialize settings");
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open Database Connections
|
|
||||||
$this->ghrconnection = $this->connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================================
|
|
||||||
// Return a GHR MySQL Database Connection
|
|
||||||
// ======================================
|
|
||||||
|
|
||||||
public function connect() {
|
|
||||||
|
|
||||||
if ($this->settings->Environment == 'Production') {
|
|
||||||
$host = 'localhost';
|
|
||||||
} else {
|
|
||||||
$host = '192.168.1.190';
|
|
||||||
$host = '100.67.215.67';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
$database = (string) ($this->settings->DatabaseName ?? "");
|
|
||||||
$user = (string) ($this->settings->DatabaseUser ?? "");
|
|
||||||
$password = (string) ($this->settings->DatabasePassword ?? "");
|
|
||||||
|
|
||||||
$connection = new PDO("mysql:host={$host};dbname={$database};charset=utf8", $user, $password);
|
|
||||||
|
|
||||||
$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
|
||||||
} catch (PDOException | Exception $e) {
|
|
||||||
|
|
||||||
$this->debug($e->getMessage());
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// Generate Random Password
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
private function generateRandomPassword($length = 12) {
|
|
||||||
|
|
||||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_+=';
|
|
||||||
$password = '';
|
|
||||||
|
|
||||||
for ($i = 0; $i < $length; $i++) {
|
|
||||||
$index = rand(0, strlen($characters) - 1);
|
|
||||||
$password .= $characters[$index];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $password;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===============
|
|
||||||
// Debug an Object
|
|
||||||
// ===============
|
|
||||||
|
|
||||||
private function debug($object = "") {
|
|
||||||
|
|
||||||
echo "<pre>";
|
|
||||||
print_r($object);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// Reset Password
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
public function resetPassword() {
|
public function resetPassword() {
|
||||||
|
$token = $_POST['token'] ?? $_GET['token'] ?? '';
|
||||||
$username = filter_input(INPUT_POST, "user_name") ?: "";
|
$params = ['TOKEN' => $token, 'MESSAGE' => '', 'ERROR' => '', 'COMPLETE' => 'FALSE'];
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
if (empty($username)) {
|
$this->requireCsrf();
|
||||||
return false;
|
if (($_POST['step'] ?? '') === 'reset') {
|
||||||
}
|
|
||||||
|
|
||||||
$exists = $this->userExists($username);
|
|
||||||
|
|
||||||
if (!$exists) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->getUser($username);
|
|
||||||
|
|
||||||
$password = $this->resetUsersPassword($user->record->user_serial);
|
|
||||||
|
|
||||||
$this->emailUserPasswordResetLink($user, $password);
|
|
||||||
$this->journalEntry("{$username} reset their password.");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========
|
|
||||||
// User Exists
|
|
||||||
// ===========
|
|
||||||
|
|
||||||
private function userExists($username) {
|
|
||||||
|
|
||||||
$SQL = 'select user_name from view_users where user_name = :username';
|
|
||||||
|
|
||||||
$statement = $this->ghrconnection->prepare($SQL);
|
|
||||||
$statement->bindValue(':username', $username, PDO::PARAM_STR);
|
|
||||||
$statement->execute();
|
|
||||||
|
|
||||||
$user = $statement->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
return ($user !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// Get a User Object
|
|
||||||
// =================
|
|
||||||
|
|
||||||
private function getUser($username) {
|
|
||||||
|
|
||||||
$SQL = 'select user_serial, user_name, user_email from view_users where user_name = :username';
|
|
||||||
|
|
||||||
$statement = $this->ghrconnection->prepare($SQL);
|
|
||||||
$statement->bindValue(':username', $username, PDO::PARAM_STR);
|
|
||||||
$statement->execute();
|
|
||||||
|
|
||||||
$user = $statement->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
$xml = new SimpleXMLElement('<users/>');
|
|
||||||
$record = $xml->addChild('record');
|
|
||||||
|
|
||||||
$this->arrayToXml($user, $record);
|
|
||||||
|
|
||||||
return $xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function arrayToXml(array $data, SimpleXMLElement $xml): void {
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
$key = is_numeric($key) ? 'item' : $key;
|
|
||||||
|
|
||||||
if (is_array($value)) {
|
|
||||||
$child = $xml->addChild($key);
|
|
||||||
$this->arrayToXml($value, $child);
|
|
||||||
} else {
|
|
||||||
$xml->addChild($key, htmlspecialchars((string) $value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// Reset the Users Password
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
private function resetUsersPassword($user_serial) {
|
|
||||||
|
|
||||||
$user_random_password = $this->generateRandomPassword();
|
|
||||||
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
|
|
||||||
|
|
||||||
$SQL = "update users
|
|
||||||
set user_password = :user_password,
|
|
||||||
user_change_password = :user_change_password
|
|
||||||
where user_serial = :user_serial";
|
|
||||||
|
|
||||||
$statement = $this->ghrconnection->prepare($SQL);
|
|
||||||
|
|
||||||
$statement->bindValue(":user_serial", $user_serial);
|
|
||||||
$statement->bindValue(":user_password", $user_temp_password);
|
|
||||||
$statement->bindValue(":user_change_password", 1);
|
|
||||||
|
|
||||||
$statement->execute();
|
|
||||||
|
|
||||||
return $user_random_password;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================
|
|
||||||
// Email a users Password Reset Link
|
|
||||||
// =================================
|
|
||||||
|
|
||||||
public function emailUserPasswordResetLink($user, $password) {
|
|
||||||
|
|
||||||
if (!$user instanceof SimpleXMLElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the email.
|
|
||||||
|
|
||||||
$XSLParms['USER-TEMP-PASSWORD'] = $password;
|
|
||||||
|
|
||||||
$to = $user->record->user_email;
|
|
||||||
$subject = "DEC International DMS - Password Reset Request";
|
|
||||||
$message = $this->applyXSL($user, $this->getXSL("emailPasswordResetLink"), $XSLParms);
|
|
||||||
|
|
||||||
// Send the Email
|
|
||||||
$this->sendEmail($to, $subject, $message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =======================================
|
|
||||||
// Send an email with optional attachments
|
|
||||||
// =======================================
|
|
||||||
|
|
||||||
public function sendEmail($to = "", $subject = "", $message = "", $attachments = "", $attachment_names = "unknown") {
|
|
||||||
|
|
||||||
if (empty($to) or empty($subject) or empty($message)) {
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$SmtpHost = $this->settings->SmtpServer;
|
|
||||||
$SmtpUsername = $this->settings->SmtpUserName;
|
|
||||||
$SmtpPassword = $this->settings->SmtpPassword;
|
|
||||||
$SmtpPort = $this->settings->SmtpPort;
|
|
||||||
|
|
||||||
$email = new PHPMailer(true);
|
|
||||||
|
|
||||||
// Only for development
|
|
||||||
|
|
||||||
$message = "<span style='font-family: Calibri, Arial, sans-serif;'>" . $message . "</span>";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$auth = new AuthService();
|
||||||
//Server settings
|
if (!$auth->allowAttempt('reset-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900)) {
|
||||||
$email->isSMTP();
|
throw new InvalidArgumentException('Too many attempts. Please try again later.');
|
||||||
$email->Host = $SmtpHost;
|
|
||||||
$email->SMTPAuth = true;
|
|
||||||
$email->Username = $SmtpUsername; // Your Gmail address
|
|
||||||
$email->Password = $SmtpPassword; // App password from Google
|
|
||||||
// $email->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // or 'tls'
|
|
||||||
$email->Port = $SmtpPort;
|
|
||||||
|
|
||||||
$email->isHTML(true);
|
|
||||||
$email->SetFrom("info@dec-international.com", "DEC-International");
|
|
||||||
|
|
||||||
if (is_array($to)) {
|
|
||||||
foreach ($to as $address) {
|
|
||||||
$address = trim(str_replace(" ", "", $address));
|
|
||||||
if (empty($address)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
$email->addAddress($address);
|
if (!$auth->resetPassword($token, $_POST['user_password'] ?? '', $_POST['user_password_confirm'] ?? '')) {
|
||||||
|
throw new InvalidArgumentException('This reset link is invalid or expired. Request a new link.');
|
||||||
|
}
|
||||||
|
$this->logoutSession();
|
||||||
|
$_SESSION['password_reset_success'] = true;
|
||||||
|
header('Location: main.php?Xaction=resetpassword', true, 303);
|
||||||
|
exit;
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$params['ERROR'] = $e->getMessage();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$email->addAddress($to);
|
try {
|
||||||
|
(new AuthService())->requestReset(trim($_POST['user_email'] ?? ''));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('Password reset request failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
$_SESSION['password_reset_requested'] = true;
|
||||||
$email->Subject = $subject;
|
header('Location: main.php?Xaction=resetpassword', true, 303);
|
||||||
$email->Body = $message;
|
exit;
|
||||||
|
|
||||||
if (is_array($attachments)) {
|
|
||||||
foreach ($attachments as $index => $item) {
|
|
||||||
if (!empty($item)) {
|
|
||||||
$email->addStringAttachment($item, ($attachment_names[$index] ?? "unknown"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if (!empty($_SESSION['password_reset_success'])) {
|
||||||
if (!empty($attachments)) {
|
$params['MESSAGE'] = 'Your password has been changed. Please sign in with your new password.';
|
||||||
$email->addStringAttachment($attachments, $attachment_names);
|
$params['COMPLETE'] = 'TRUE';
|
||||||
}
|
unset($_SESSION['password_reset_success']);
|
||||||
}
|
} elseif (!empty($_SESSION['password_reset_requested'])) {
|
||||||
|
$params['MESSAGE'] = 'If an active account matches, you will receive a password reset link. The link expires in 30 minutes.';
|
||||||
$success = $email->Send();
|
unset($_SESSION['password_reset_requested']);
|
||||||
} catch (phpMailerException $e) {
|
}
|
||||||
|
echo $this->applyXSL('<XML/>', $this->getXSL('resetPassword'), $params);
|
||||||
$this->journalEntry($e->errorMessage());
|
exit;
|
||||||
$success = false;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
|
|
||||||
$this->journalEntry($e->getMessage());
|
|
||||||
$success = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $success;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================
|
|
||||||
// Create a Journal Entry
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
public function journalEntry($journal_entry = "") {
|
|
||||||
|
|
||||||
if (empty($journal_entry)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$SQL = "insert into journal
|
|
||||||
|
|
||||||
( journal_origin,
|
|
||||||
journal_ip,
|
|
||||||
journal_entry )
|
|
||||||
|
|
||||||
VALUES ( :journal_origin,
|
|
||||||
:journal_ip,
|
|
||||||
:journal_entry )";
|
|
||||||
|
|
||||||
$statement = $this->ghrconnection->prepare($SQL);
|
|
||||||
|
|
||||||
$statement->bindValue(":journal_origin", "Authentication Class");
|
|
||||||
$statement->bindValue(":journal_ip", $_SERVER["REMOTE_ADDR"] ?? "");
|
|
||||||
$statement->bindValue(":journal_entry", $journal_entry);
|
|
||||||
|
|
||||||
$statement->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Apply an XSL Stylesheet to an XML Document
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
public function applyXSL($XMLString = "", $XSLString = "", $XSLParms = "") {
|
|
||||||
|
|
||||||
if ($XMLString instanceof SimpleXMLElement) {
|
|
||||||
$XMLString = $XMLString->asXML();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($XSLString)) {
|
|
||||||
trigger_error("No XSL Stylesheet provided.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_array($XSLParms)) {
|
|
||||||
$XSLParms = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
$XSLParms["RANDOM_NUMBER"] = rand();
|
|
||||||
$XSLParms["TODAY_YYYYMMDD"] = date("Y-m-d");
|
|
||||||
$XSLParms["TODAY_MMDDYYYY"] = date("m/d/Y");
|
|
||||||
$XSLParms["TODAY_HHMM"] = date("Hi");
|
|
||||||
$XSLParms["COPYRIGHT"] = date("Y");
|
|
||||||
|
|
||||||
$XML = new DOMDocument();
|
|
||||||
$XSL = new DOMDocument();
|
|
||||||
$XSLT = new XSLTProcessor();
|
|
||||||
|
|
||||||
if ($XMLString) {
|
|
||||||
$XML->loadXML($XMLString);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($XSLString) {
|
|
||||||
$XSL->loadXML($XSLString);
|
|
||||||
}
|
|
||||||
|
|
||||||
$XSLT->importStylesheet($XSL);
|
|
||||||
|
|
||||||
if ($XSLParms) {
|
|
||||||
$XSLT->setParameter("", $XSLParms);
|
|
||||||
}
|
|
||||||
|
|
||||||
$document = $XSLT->transformToXml($XML);
|
|
||||||
|
|
||||||
return $document;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// Return an XSL Stylesheet
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
public function getXSL($document = "") {
|
|
||||||
|
|
||||||
if (trim($document) == "") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
$document = "xsl/" . trim($document) . ".xsl";
|
|
||||||
$XSL = (file_exists($document) ? file_get_contents($document) : "");
|
|
||||||
|
|
||||||
return $XSL;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ class Base_Autoloader {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/', $pClassName)) return false;
|
||||||
|
|
||||||
// Try for a "classes" Class first, then a "reports" Class.
|
// Try for a "classes" Class first, then a "reports" Class.
|
||||||
|
|
||||||
$pClassFilePath = "classes/{$pClassName}.php";
|
$pClassFilePath = __DIR__ . "/{$pClassName}.php";
|
||||||
|
|
||||||
if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
|
if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
|
||||||
|
|
||||||
$pClassFilePath = "reports/{$pClassName}.php";
|
$pClassFilePath = __DIR__ . "/../reports/{$pClassName}.php";
|
||||||
|
|
||||||
if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
|
if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+155
-104
@@ -4,8 +4,8 @@
|
|||||||
// Base Class
|
// Base Class
|
||||||
// ==========
|
// ==========
|
||||||
|
|
||||||
require_once "vendor/autoload.php";
|
require_once __DIR__ . "/../vendor/autoload.php";
|
||||||
require_once "classes/Autoloader.php";
|
require_once __DIR__ . "/Autoloader.php";
|
||||||
|
|
||||||
class Base {
|
class Base {
|
||||||
|
|
||||||
@@ -17,7 +17,6 @@ class Base {
|
|||||||
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
|
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
|
||||||
const VERSION = "1.00";
|
const VERSION = "1.00";
|
||||||
const TIMEZONE = "America/New_York";
|
const TIMEZONE = "America/New_York";
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// Session Variables
|
// Session Variables
|
||||||
// =================
|
// =================
|
||||||
@@ -80,12 +79,13 @@ class Base {
|
|||||||
$XSLParms = array();
|
$XSLParms = array();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$XSLParms["CSRF_TOKEN"] = $this->csrfToken();
|
||||||
$XSLParms["APPLICATION_NAME"] = self::APPLICATION_NAME;
|
$XSLParms["APPLICATION_NAME"] = self::APPLICATION_NAME;
|
||||||
$XSLParms["APPLICATION_MNEMONIC"] = self::APPLICATION_MNEMONIC;
|
$XSLParms["APPLICATION_MNEMONIC"] = self::APPLICATION_MNEMONIC;
|
||||||
$XSLParms["VERSION"] = self::VERSION;
|
$XSLParms["VERSION"] = self::VERSION;
|
||||||
$XSLParms["THEME"] = $_SESSION[self::THEME] ?? self::THEME;
|
$XSLParms["THEME"] = $_SESSION[self::THEME] ?? self::THEME;
|
||||||
$XSLParms["ENVIRONMENT"] = $_SESSION[self::ENVIRONMENT] ?? "Production";
|
$XSLParms["ENVIRONMENT"] = $_SESSION[self::ENVIRONMENT] ?? "Production";
|
||||||
$XSLParms["USER_NAME"] = $_SESSION[self::USER_NAME] ?? "";
|
$XSLParms["USER_NAME"] = $XSLParms["USER_NAME"] ?? $_SESSION[self::USER_NAME] ?? "";
|
||||||
$XSLParms["USER_FULL_NAME"] = $_SESSION[self::USER_FULL_NAME] ?? "";
|
$XSLParms["USER_FULL_NAME"] = $_SESSION[self::USER_FULL_NAME] ?? "";
|
||||||
$XSLParms["USER_ROLE"] = $_SESSION[self::USER_ROLE] ?? "Guest";
|
$XSLParms["USER_ROLE"] = $_SESSION[self::USER_ROLE] ?? "Guest";
|
||||||
$XSLParms["RANDOM_NUMBER"] = rand();
|
$XSLParms["RANDOM_NUMBER"] = rand();
|
||||||
@@ -99,11 +99,11 @@ class Base {
|
|||||||
$XSLT = new XSLTProcessor();
|
$XSLT = new XSLTProcessor();
|
||||||
|
|
||||||
if ($XMLString) {
|
if ($XMLString) {
|
||||||
$XML->loadXML($XMLString);
|
$XML->loadXML($XMLString, LIBXML_NONET);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($XSLString) {
|
if ($XSLString) {
|
||||||
$XSL->loadXML($XSLString);
|
$XSL->loadXML($XSLString, LIBXML_NONET);
|
||||||
}
|
}
|
||||||
|
|
||||||
$XSLT->importStylesheet($XSL);
|
$XSLT->importStylesheet($XSL);
|
||||||
@@ -112,6 +112,8 @@ class Base {
|
|||||||
$XSLT->setParameter("", $XSLParms);
|
$XSLT->setParameter("", $XSLParms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$XML->documentElement)
|
||||||
|
$XML->loadXML('<XML/>');
|
||||||
$document = $XSLT->transformToXml($XML);
|
$document = $XSLT->transformToXml($XML);
|
||||||
|
|
||||||
return $document;
|
return $document;
|
||||||
@@ -125,24 +127,23 @@ class Base {
|
|||||||
|
|
||||||
$settings = $this->getSettings();
|
$settings = $this->getSettings();
|
||||||
|
|
||||||
$host = ((string) $settings->Environment === 'Production') ? 'localhost' : '192.168.1.190';
|
$host = (string) $settings->DatabaseServer;
|
||||||
$database = (string) $settings->DatabaseName;
|
$database = (string) $settings->DatabaseName;
|
||||||
$username = (string) $settings->DatabaseUser;
|
$username = (string) $settings->DatabaseUser;
|
||||||
$password = (string) $settings->DatabasePassword;
|
$password = (string) $settings->DatabasePassword;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $database);
|
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, (int) ($settings->DatabasePort ?? 3306), $database);
|
||||||
|
|
||||||
return new PDO($dsn, $username, $password,
|
return new PDO($dsn, $username, $password,
|
||||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
PDO::ATTR_PERSISTENT => false,]);
|
PDO::ATTR_PERSISTENT => false,]);
|
||||||
|
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
|
|
||||||
$this->logError("Database Connection Failed: " . $e->getMessage());
|
error_log("Database Connection Failed: " . $e->getMessage());
|
||||||
|
|
||||||
throw new Exception("Unable to connect to the database.", 0, $e);
|
throw new Exception("Unable to connect to the database.", 0, $e);
|
||||||
}
|
}
|
||||||
@@ -182,32 +183,17 @@ class Base {
|
|||||||
// Custom error handler to display informative error page
|
// Custom error handler to display informative error page
|
||||||
// ======================================================
|
// ======================================================
|
||||||
|
|
||||||
public function displayError($error_number = "", $error_message = "", $error_file = "", $error_line = "") {
|
public function displayError($error_number = 0, $error_message = '', $error_file = '', $error_line = 0) {
|
||||||
|
if (!(error_reporting() & $error_number)) {
|
||||||
if (error_reporting() == 0) {
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// Never log application failures through the failing database or SMTP service.
|
||||||
|
error_log("PHP {$error_number}: {$error_message} in {$error_file}:{$error_line}");
|
||||||
|
|
||||||
$error_file = explode("/", $error_file);
|
if (in_array($error_number, [E_DEPRECATED, E_USER_DEPRECATED], true)) {
|
||||||
$error_file = end($error_file);
|
return true;
|
||||||
$error_message = str_replace("'", '"', $error_message);
|
|
||||||
|
|
||||||
(new Journal())->journalEntry("{$error_file} | {$error_line} | {$error_message}");
|
|
||||||
|
|
||||||
if (($_SESSION[self::ENVIRONMENT] ?? "Production") == "Production") {
|
|
||||||
(new Email())->sendErrorEmail($error_message, $error_file, $error_line);
|
|
||||||
}
|
}
|
||||||
|
throw new ErrorException($error_message, 0, $error_number, $error_file, $error_line);
|
||||||
$XSLParms["FILE_NAME"] = $error_file;
|
|
||||||
$XSLParms["LINE_NUMBER"] = $error_line;
|
|
||||||
$XSLParms["ERROR_MESSAGE"] = $error_message;
|
|
||||||
$XSLParms["ERROR_NUMBER"] = $error_number;
|
|
||||||
|
|
||||||
$content = $this->applyXSL("", $this->getXSL("error"), $XSLParms);
|
|
||||||
|
|
||||||
$this->displayContent($content);
|
|
||||||
|
|
||||||
exit(); // Exit so that only one error is presented.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -241,71 +227,139 @@ class Base {
|
|||||||
|
|
||||||
public function do() {
|
public function do() {
|
||||||
|
|
||||||
$_SESSION[self::TODAY] = date("Y-m-d");
|
if (empty($_SESSION[self::ACTIVE_SESSION])) {
|
||||||
|
|
||||||
// Get the requested Action
|
|
||||||
|
|
||||||
$action = filter_input(INPUT_POST, "action") ?: "";
|
|
||||||
$Xaction = filter_input(INPUT_GET, "Xaction") ?: "";
|
|
||||||
|
|
||||||
$action = (empty($Xaction) ? $action : $Xaction);
|
|
||||||
|
|
||||||
if ($Xaction == 'resetpassword') {
|
|
||||||
$this->resetpassword();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login
|
|
||||||
|
|
||||||
if (($_SESSION[self::USER_AUTHENTICATED] ?? false) === false) {
|
|
||||||
|
|
||||||
(new SignIn())->login();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if user needs a password change
|
|
||||||
if ($this->userChangePassword() === true) {
|
|
||||||
(new Users())->changePassword();
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is an AJAX request.
|
|
||||||
|
|
||||||
$_SESSION[self::AJAX] = (isset($_SERVER["HTTP_X_REQUESTED_WITH"]) and strtolower($_SERVER["HTTP_X_REQUESTED_WITH"]) === "xmlhttprequest");
|
|
||||||
|
|
||||||
// Get the requested Action
|
|
||||||
|
|
||||||
$action = filter_input(INPUT_POST, "action") ?: "";
|
|
||||||
$Xaction = filter_input(INPUT_GET, "Xaction") ?: "";
|
|
||||||
|
|
||||||
$action = (empty($Xaction) ? $action : $Xaction);
|
|
||||||
|
|
||||||
// Sign Out if requested.
|
|
||||||
|
|
||||||
if (strtolower(trim($action)) === "signout") {
|
|
||||||
$this->initializeSession();
|
$this->initializeSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the requested Action.
|
$_SESSION[self::AJAX] = strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest';
|
||||||
|
|
||||||
list($class, $method) = array_pad(explode('.', $action), 2, "");
|
$_SESSION[self::TODAY] = date('Y-m-d');
|
||||||
|
|
||||||
if (empty($method)) {
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
$method = $class;
|
if (!in_array($method, ['GET', 'POST'], true)) {
|
||||||
|
http_response_code(405);
|
||||||
|
exit('Method not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
if (method_exists(__CLASS__, $method)) {
|
// Reject arrays and oversized values before passing input to string operations.
|
||||||
$this->$method();
|
foreach (array_merge($_GET, $_POST) as $value) {
|
||||||
|
if (!is_string($value) || strlen($value) > 8192) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Invalid request.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
|
||||||
|
if ($action === 'signout') {
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
exit('Use POST to sign out.');
|
||||||
|
}
|
||||||
|
$this->requireCsrf();
|
||||||
|
$ajax = $_SESSION[self::AJAX];
|
||||||
|
$this->logoutSession();
|
||||||
|
if ($ajax) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(true);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header('Location: main.php', true, 303);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (in_array($action, ['resetpassword', 'authentication.resetpassword'], true)) {
|
||||||
|
(new Authentication())->resetPassword();
|
||||||
|
}
|
||||||
|
if (in_array($action, ['signup', 'signup.createaccount'], true)) {
|
||||||
|
(new SignUp())->createAccount();
|
||||||
|
}
|
||||||
|
if (in_array($action, ['signin', 'signin.login', 'users.login'], true)) {
|
||||||
|
(new SignIn())->login();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = (new AuthService())->currentUser();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$ajax = $_SESSION[self::AJAX];
|
||||||
|
if (!empty($_SESSION[self::USER_AUTHENTICATED])) {
|
||||||
|
$this->logoutSession();
|
||||||
|
}
|
||||||
|
if ($ajax) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit('Please sign in again.');
|
||||||
|
}
|
||||||
|
(new SignIn())->login();
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$this->requireCsrf();
|
||||||
|
}
|
||||||
|
if ((bool) $user['user_change_password']) {
|
||||||
|
(new Users())->changePassword();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
// Only named, reviewed entry points are routable. Helpers are never endpoints.
|
||||||
|
$routes = [
|
||||||
|
'' => [Base::class, 'displayHome', false],
|
||||||
|
'./' => [Base::class, 'displayHome', false],
|
||||||
|
'displayhome' => [Base::class, 'displayHome', false],
|
||||||
|
'toggletheme' => [Base::class, 'toggleTheme', false],
|
||||||
|
'getzipcode' => [Base::class, 'getZipcode', false],
|
||||||
|
'validatedate' => [Base::class, 'validateDate', false],
|
||||||
|
'getage' => [Base::class, 'getAge', false],
|
||||||
|
'users.changepassword' => [Users::class, 'changePassword', false],
|
||||||
|
'print' => [Base::class, 'print', true],
|
||||||
|
'print_all_permitsbybuilder.print' => ['Print_All_PermitsByBuilder', 'print', true],
|
||||||
|
'export_allpermitsbybuildercsv.export' => ['Export_AllPermitsByBuilderCSV', 'export', true],
|
||||||
|
'export_allpermitsbybuilderexcel.export' => ['Export_AllPermitsByBuilderExcel', 'export', true],
|
||||||
|
];
|
||||||
|
$adminRoutes = [
|
||||||
|
'Users' => ['setupUsers', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
|
||||||
|
'Alerts' => ['setupAlerts', 'addAlert', 'editAlert', 'deleteAlert'],
|
||||||
|
'Dropdowns' => ['setupDropdowns', 'setupDropdowntypes', 'addDropdown', 'editDropdown', 'deleteDropdown', 'addDropdowntype', 'editDropdowntype', 'deleteDropdowntype', 'dropdownExists', 'dropdowntypeExists', 'dropdowntypetitleExists', 'dropdownActive', 'dropdowntypeActive', 'updateDropdownActive', 'updateDropdownDefault'],
|
||||||
|
'Popovers' => ['setupPopovers', 'addPopover', 'editPopover', 'deletePopover', 'popoverExists'],
|
||||||
|
'Reports' => ['setupReports', 'addReport', 'editReport', 'deleteReport', 'selectReport', 'reportExists'],
|
||||||
|
'Statuses' => ['setupStatuses', 'addStatus', 'editStatus', 'deleteStatus', 'statusExists', 'statusActive'],
|
||||||
|
'Journal' => ['viewJournal', 'clearJournal'],
|
||||||
|
'Notes' => ['addNote', 'editNote', 'deleteNote', 'addNoteQuick'],
|
||||||
|
];
|
||||||
|
foreach ($adminRoutes as $class => $methods) {
|
||||||
|
foreach ($methods as $entry)
|
||||||
|
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
|
||||||
|
}
|
||||||
|
if (!isset($routes[$action])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Page not found.');
|
||||||
|
}
|
||||||
|
[$class, $entry, $adminOnly] = $routes[$action];
|
||||||
|
if ($adminOnly && !in_array($user['user_role'], ['Administrator', 'System Administrator'], true)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('You do not have permission to perform this action.');
|
||||||
|
}
|
||||||
|
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword'], true)) {
|
||||||
|
http_response_code(405);
|
||||||
|
exit('Use POST for this action.');
|
||||||
|
}
|
||||||
|
(new $class())->$entry();
|
||||||
|
}
|
||||||
|
|
||||||
if (method_exists($class, $method)) {
|
public function csrfToken(): string {
|
||||||
(new $class())->$method();
|
if (empty($_SESSION['csrf_token']))
|
||||||
return;
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireCsrf(): void {
|
||||||
|
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!is_string($token) || !hash_equals($this->csrfToken(), $token)) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Your form expired. Reload the page and try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to the Home Page.
|
public function logoutSession(): void {
|
||||||
$this->displayHome();
|
$_SESSION = [];
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$this->initializeSession();
|
||||||
|
$this->csrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
@@ -366,7 +420,15 @@ class Base {
|
|||||||
|
|
||||||
public function getSettings() {
|
public function getSettings() {
|
||||||
|
|
||||||
$settings = new SimpleXMLElement($this->getXML("settings"));
|
$path = getenv('HCR_SETTINGS_FILE') ?: (is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
|
||||||
|
$settings = simplexml_load_file($path, SimpleXMLElement::class, LIBXML_NONET);
|
||||||
|
if (!$settings)
|
||||||
|
throw new RuntimeException('Unable to load application settings.');
|
||||||
|
foreach (['Environment' => 'HCR_ENVIRONMENT', 'DatabaseServer' => 'HCR_DB_HOST', 'DatabasePort' => 'HCR_DB_PORT', 'DatabaseName' => 'HCR_DB_NAME', 'DatabaseUser' => 'HCR_DB_USER', 'DatabasePassword' => 'HCR_DB_PASSWORD', 'SmtpServer' => 'HCR_SMTP_HOST', 'SmtpUserName' => 'HCR_SMTP_USER', 'SmtpPassword' => 'HCR_SMTP_PASSWORD', 'SmtpPort' => 'HCR_SMTP_PORT', 'SmtpFromEmail' => 'HCR_SMTP_FROM', 'ApplicationUrl' => 'HCR_APPLICATION_URL'] as $name => $variable) {
|
||||||
|
$value = getenv($variable);
|
||||||
|
if ($value !== false)
|
||||||
|
$settings->$name = $value;
|
||||||
|
}
|
||||||
|
|
||||||
return $settings;
|
return $settings;
|
||||||
}
|
}
|
||||||
@@ -529,7 +591,7 @@ class Base {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
$document = "xml/" . trim($document) . ".xml";
|
$document = __DIR__ . "/../xml/" . trim($document) . ".xml";
|
||||||
$XML = (file_exists($document) ? file_get_contents($document) : "");
|
$XML = (file_exists($document) ? file_get_contents($document) : "");
|
||||||
|
|
||||||
return $XML;
|
return $XML;
|
||||||
@@ -545,7 +607,7 @@ class Base {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
$document = "xsl/" . trim($document) . ".xsl";
|
$document = __DIR__ . "/../xsl/" . trim($document) . ".xsl";
|
||||||
$XSL = (file_exists($document) ? file_get_contents($document) : "");
|
$XSL = (file_exists($document) ? file_get_contents($document) : "");
|
||||||
|
|
||||||
return $XSL;
|
return $XSL;
|
||||||
@@ -670,6 +732,10 @@ class Base {
|
|||||||
|
|
||||||
$report = filter_input(INPUT_POST, "report") ?: "";
|
$report = filter_input(INPUT_POST, "report") ?: "";
|
||||||
|
|
||||||
|
if ($report !== 'Print_All_PermitsByBuilder') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Report not found.');
|
||||||
|
}
|
||||||
(new $report())->print();
|
(new $report())->print();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +785,7 @@ class Base {
|
|||||||
$password = '';
|
$password = '';
|
||||||
|
|
||||||
for ($i = 0; $i < $length; $i++) {
|
for ($i = 0; $i < $length; $i++) {
|
||||||
$index = rand(0, strlen($characters) - 1);
|
$index = random_int(0, strlen($characters) - 1);
|
||||||
$password .= $characters[$index];
|
$password .= $characters[$index];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,19 +839,4 @@ class Base {
|
|||||||
// =====================
|
// =====================
|
||||||
// Forgot/Reset Password
|
// Forgot/Reset Password
|
||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
public function resetPassword() {
|
|
||||||
|
|
||||||
require_once 'classes/Authentication.php';
|
|
||||||
|
|
||||||
$XSLParms = [
|
|
||||||
"BTN_SUBMIT" => "main.php?action=Users.signIn&step=authenticate",
|
|
||||||
"RESET_PASSWORD_SUCCESSFUL" => (new Authentication())->resetPassword() ? "TRUE" : "FALSE"
|
|
||||||
];
|
|
||||||
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
|
|||||||
+23
-48
@@ -9,6 +9,13 @@ use PHPMailer\PHPMailer\Exception;
|
|||||||
|
|
||||||
class Email extends Base {
|
class Email extends Base {
|
||||||
|
|
||||||
|
public function sendPasswordReset(array $user, string $url): bool {
|
||||||
|
$xml = new SimpleXMLElement('<users><record/></users>');
|
||||||
|
$xml->record->user_name = $user['user_first_name'] ?: $user['user_name'];
|
||||||
|
$body = $this->applyXSL($xml, $this->getXSL('emailPasswordResetLink'), ['RESET_URL' => $url]);
|
||||||
|
return $this->sendEmail((string) $user['user_email'], self::APPLICATION_NAME . ' - Password reset', $body);
|
||||||
|
}
|
||||||
|
|
||||||
// Variables.
|
// Variables.
|
||||||
|
|
||||||
private $current_user_name = "";
|
private $current_user_name = "";
|
||||||
@@ -32,45 +39,14 @@ class Email extends Base {
|
|||||||
// Email a new users credentials
|
// Email a new users credentials
|
||||||
// =============================
|
// =============================
|
||||||
|
|
||||||
public function emailNewUserCredentials($user, $password) {
|
public function emailNewUserCredentials($user, $password = null) {
|
||||||
|
$this->emailUserPasswordResetLink($user);
|
||||||
if (!$user instanceof SimpleXMLElement) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the email
|
public function emailUserPasswordResetLink($user, $password = null) {
|
||||||
|
if ($user instanceof SimpleXMLElement) {
|
||||||
$to = (string) $user->record->user_email;
|
(new AuthService())->requestReset((string) $user->record->user_email);
|
||||||
|
|
||||||
$XSLParms['USER-TEMP-PASSWORD'] = $password;
|
|
||||||
|
|
||||||
$subject = "New User Credentials";
|
|
||||||
$message = $this->applyXSL($user, $this->getXSL("emailNewUserCredentials"), $XSLParms);
|
|
||||||
|
|
||||||
// Send the Email
|
|
||||||
$this->sendEmail($to, $subject, $message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================
|
|
||||||
// Email a users Password Reset Link
|
|
||||||
// =================================
|
|
||||||
|
|
||||||
public function emailUserPasswordResetLink($user, $password) {
|
|
||||||
|
|
||||||
if (!$user instanceof SimpleXMLElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the email.
|
|
||||||
|
|
||||||
$XSLParms['USER-TEMP-PASSWORD'] = $password;
|
|
||||||
|
|
||||||
$to = (string) $user->record->user_email;
|
|
||||||
$subject = "Password Reset Request";
|
|
||||||
$message = $this->applyXSL($user, $this->getXSL("emailPasswordResetLink"), $XSLParms);
|
|
||||||
|
|
||||||
// Send the Email
|
|
||||||
$this->sendEmail($to, $subject, $message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =======================================
|
// =======================================
|
||||||
@@ -101,15 +77,17 @@ class Email extends Base {
|
|||||||
|
|
||||||
//Server settings
|
//Server settings
|
||||||
$email->isSMTP();
|
$email->isSMTP();
|
||||||
$email->Host = $SmtpHost;
|
$email->Host = (string) $SmtpHost;
|
||||||
$email->SMTPAuth = true;
|
$email->SMTPAuth = true;
|
||||||
$email->Username = $SmtpUsername; // Your Gmail address
|
$email->Username = (string) $SmtpUsername;
|
||||||
$email->Password = $SmtpPassword; // App password from Google
|
$email->Password = (string) $SmtpPassword;
|
||||||
// $email->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // or 'tls'
|
$email->Port = (int) $SmtpPort;
|
||||||
$email->Port = $SmtpPort;
|
$email->SMTPSecure = $email->Port === 465 ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
|
||||||
|
$email->Timeout = 15;
|
||||||
|
$email->CharSet = 'UTF-8';
|
||||||
|
|
||||||
$email->isHTML(true);
|
$email->isHTML(true);
|
||||||
$email->SetFrom("info@onesourceits.com", "OneSource IT Solutions LLC");
|
$email->setFrom((string) ($settings->SmtpFromEmail ?? $settings->SmtpUserName), self::APPLICATION_NAME);
|
||||||
|
|
||||||
if (is_array($to)) {
|
if (is_array($to)) {
|
||||||
foreach ($to as $address) {
|
foreach ($to as $address) {
|
||||||
@@ -125,6 +103,7 @@ class Email extends Base {
|
|||||||
|
|
||||||
$email->Subject = $subject;
|
$email->Subject = $subject;
|
||||||
$email->Body = $message;
|
$email->Body = $message;
|
||||||
|
$email->AltBody = html_entity_decode(strip_tags(preg_replace('/<a\s+href="([^"]*)"[^>]*>(.*?)<\/a>/is', '$2 ($1)', $message)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
if (is_array($attachments)) {
|
if (is_array($attachments)) {
|
||||||
foreach ($attachments as $index => $item) {
|
foreach ($attachments as $index => $item) {
|
||||||
@@ -139,13 +118,9 @@ class Email extends Base {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$success = $email->Send();
|
$success = $email->Send();
|
||||||
} catch (phpMailerException $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
(new Journal())->journalEntry($e->errorMessage());
|
error_log('Email delivery failed: ' . $e->getMessage());
|
||||||
$success = false;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
|
|
||||||
(new Journal())->journalEntry($e->getMessage());
|
|
||||||
$success = false;
|
$success = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-111
@@ -1,118 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// =============
|
|
||||||
// Sign In Class
|
|
||||||
// =============
|
|
||||||
|
|
||||||
class SignIn extends Base {
|
class SignIn extends Base {
|
||||||
|
|
||||||
// Variables
|
|
||||||
|
|
||||||
private $current_user_serial = "";
|
|
||||||
private $current_user_name = "";
|
|
||||||
private $current_user_full_name = "";
|
|
||||||
private $current_user_email = "";
|
|
||||||
|
|
||||||
// ===============================
|
|
||||||
// __construct : Class Constructor
|
|
||||||
// ===============================
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
|
|
||||||
set_error_handler(array($this, "displayError"));
|
|
||||||
|
|
||||||
date_default_timezone_set(self::TIMEZONE);
|
|
||||||
|
|
||||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
|
||||||
$this->current_user_full_name = $_SESSION[self::USER_FULL_NAME] ?? "";
|
|
||||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// Login to the application
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
public function login() {
|
public function login() {
|
||||||
|
$params = ['BTN_SUBMIT' => 'main.php', 'INVALID_SIGNIN' => 'FALSE'];
|
||||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
|
$this->requireCsrf();
|
||||||
switch ($step) {
|
if ((new AuthService())->login(trim($_POST['user_email'] ?? $_POST['user_name'] ?? ''), $_POST['user_password'] ?? '')) {
|
||||||
|
header('Location: main.php', true, 303);
|
||||||
case "prompt":
|
exit;
|
||||||
|
|
||||||
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
|
|
||||||
$XSLParms["INVALID_SIGNIN"] = "FALSE";
|
|
||||||
|
|
||||||
echo $this->applyXSL("", $this->getXSL("login"), $XSLParms);
|
|
||||||
exit();
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "authenticate":
|
|
||||||
|
|
||||||
$user_name = trim(filter_input(INPUT_POST, "user_name") ?: "");
|
|
||||||
$user_password = filter_input(INPUT_POST, "user_password") ?: "";
|
|
||||||
|
|
||||||
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
|
|
||||||
$XSLParms["INVALID_SIGNIN"] = "TRUE";
|
|
||||||
|
|
||||||
$user = (new Users())->getUserByUserName($user_name);
|
|
||||||
|
|
||||||
if ($user->count() == 0) {
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
die();
|
|
||||||
}
|
}
|
||||||
|
$params['INVALID_SIGNIN'] = 'TRUE';
|
||||||
if ((string) $user->record->user_status !== 'active') {
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
die();
|
|
||||||
}
|
}
|
||||||
|
echo $this->applyXSL('<XML/>', $this->getXSL('login'), $params);
|
||||||
$stored_hashed_password = $user->record->user_password_hash;
|
exit;
|
||||||
|
|
||||||
if (!password_verify($user_password, $stored_hashed_password)) {
|
|
||||||
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
die();
|
|
||||||
}
|
|
||||||
|
|
||||||
session_regenerate_id(true);
|
|
||||||
|
|
||||||
$_SESSION[self::USER_AUTHENTICATED] = true;
|
|
||||||
$_SESSION[self::USER_SERIAL] = (int) $user->record->user_serial;
|
|
||||||
$_SESSION[self::USER_NAME] = (string) $user->record->user_name;
|
|
||||||
$_SESSION[self::USER_FULL_NAME] = (string) $user->record->user_full_name;
|
|
||||||
$_SESSION[self::USER_EMAIL] = (string) $user->record->user_email;
|
|
||||||
$_SESSION[self::USER_ROLE] = (string) $user->record->user_role;
|
|
||||||
|
|
||||||
$this->current_user_serial = $_SESSION[self::USER_SERIAL] ?? 0;
|
|
||||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
|
||||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
|
||||||
|
|
||||||
$system_admins = $this->getSettings()->xpath("//SystemAdministrator");
|
|
||||||
|
|
||||||
foreach ($system_admins as $admin) {
|
|
||||||
if ((string) $admin === $user_name) {
|
|
||||||
$_SESSION[self::USER_ROLE] = "System Administrator";
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} logged in ({$_SESSION[self::USER_ROLE]}).");
|
|
||||||
|
|
||||||
(new Users())->updateLogin();
|
|
||||||
|
|
||||||
header("Location: ./");
|
|
||||||
exit();
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// User NOT Validated.
|
|
||||||
header("Location: ./");
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
|
|||||||
+24
-95
@@ -1,103 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// =============
|
class SignUp extends Base {
|
||||||
// Sign In Class
|
|
||||||
// =============
|
|
||||||
|
|
||||||
class SignUp {
|
|
||||||
|
|
||||||
// ===============================
|
|
||||||
// __construct : Class Constructor
|
|
||||||
// ===============================
|
|
||||||
|
|
||||||
public function __construct() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// Create Account
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
public function createAccount() {
|
public function createAccount() {
|
||||||
|
$params = ['BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
|
||||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||||
|
$this->requireCsrf();
|
||||||
switch ($step) {
|
try {
|
||||||
|
$auth = new AuthService();
|
||||||
case "prompt":
|
if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) {
|
||||||
|
throw new InvalidArgumentException('Too many account requests. Please try again later.');
|
||||||
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignUp.createAccount&step=create";
|
|
||||||
|
|
||||||
echo $this->applyXSL("", $this->getXSL("createAccount"), $XSLParms);
|
|
||||||
exit();
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "create":
|
|
||||||
|
|
||||||
$user_name = trim(filter_input(INPUT_POST, "user_name") ?: "");
|
|
||||||
$user_password = filter_input(INPUT_POST, "user_password") ?: "";
|
|
||||||
|
|
||||||
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
|
|
||||||
$XSLParms["INVALID_SIGNIN"] = "TRUE";
|
|
||||||
|
|
||||||
$user = (new Users())->getUserByUserName($user_name);
|
|
||||||
|
|
||||||
if ($user->count() == 0) {
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
die();
|
|
||||||
}
|
}
|
||||||
|
$auth->register($_POST);
|
||||||
if ((string) $user->record->user_status !== 'active') {
|
$_SESSION['signup_success'] = true;
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
header('Location: main.php?Xaction=signup', true, 303);
|
||||||
die();
|
exit;
|
||||||
}
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$params['SIGNUP_ERROR'] = $e->getMessage();
|
||||||
$stored_hashed_password = $user->record->user_password_hash;
|
foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name'] as $field) {
|
||||||
|
$params[strtoupper($field)] = $_POST[$field] ?? '';
|
||||||
if (!password_verify($user_password, $stored_hashed_password)) {
|
|
||||||
|
|
||||||
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
|
|
||||||
die();
|
|
||||||
}
|
|
||||||
|
|
||||||
session_regenerate_id(true);
|
|
||||||
|
|
||||||
$_SESSION[self::USER_AUTHENTICATED] = true;
|
|
||||||
$_SESSION[self::USER_SERIAL] = (int) $user->record->user_serial;
|
|
||||||
$_SESSION[self::USER_NAME] = (string) $user->record->user_name;
|
|
||||||
$_SESSION[self::USER_FULL_NAME] = (string) $user->record->user_full_name;
|
|
||||||
$_SESSION[self::USER_EMAIL] = (string) $user->record->user_email;
|
|
||||||
$_SESSION[self::USER_ROLE] = (string) $user->record->user_role;
|
|
||||||
|
|
||||||
$this->current_user_serial = $_SESSION[self::USER_SERIAL] ?? 0;
|
|
||||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
|
||||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
|
||||||
|
|
||||||
$system_admins = $this->getSettings()->xpath("//SystemAdministrator");
|
|
||||||
|
|
||||||
foreach ($system_admins as $admin) {
|
|
||||||
if ((string) $admin === $user_name) {
|
|
||||||
$_SESSION[self::USER_ROLE] = "System Administrator";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} logged in ({$_SESSION[self::USER_ROLE]}).");
|
|
||||||
|
|
||||||
(new Users())->updateLogin();
|
|
||||||
|
|
||||||
header("Location: ./");
|
|
||||||
exit();
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// User NOT Validated.
|
|
||||||
header("Location: ./");
|
|
||||||
exit();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!empty($_SESSION['signup_success'])) {
|
||||||
?>
|
$params['SIGNUP_SUCCESSFUL'] = 'TRUE';
|
||||||
|
unset($_SESSION['signup_success']);
|
||||||
|
}
|
||||||
|
echo $this->applyXSL('<XML/>', $this->getXSL('createAccount'), $params);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+62
-131
@@ -25,6 +25,7 @@ class Users extends Base {
|
|||||||
|
|
||||||
date_default_timezone_set(self::TIMEZONE);
|
date_default_timezone_set(self::TIMEZONE);
|
||||||
|
|
||||||
|
$this->current_user_serial = (int) ($_SESSION[self::USER_SERIAL] ?? 0);
|
||||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||||
@@ -100,9 +101,13 @@ class Users extends Base {
|
|||||||
case "add":
|
case "add":
|
||||||
|
|
||||||
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
||||||
$user_client_name = filter_input(INPUT_POST, "user_client_name") ?: "";
|
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
|
||||||
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
||||||
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
||||||
|
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
|
||||||
|
http_response_code(422); exit('Enter a valid username, email address and role.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$user_name = strtoupper(preg_replace("/\s+/", " ", $user_name));
|
$user_name = strtoupper(preg_replace("/\s+/", " ", $user_name));
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ class Users extends Base {
|
|||||||
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
|
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
$user_change_password = (int) true;
|
$user_change_password = (int) true;
|
||||||
$user_active = (int) true;
|
$user_active = 'active';
|
||||||
|
|
||||||
// Add the User
|
// Add the User
|
||||||
|
|
||||||
@@ -120,21 +125,21 @@ class Users extends Base {
|
|||||||
|
|
||||||
( user_name,
|
( user_name,
|
||||||
user_email,
|
user_email,
|
||||||
user_client_name,
|
user_first_name,
|
||||||
user_password,
|
user_password_hash,
|
||||||
user_role,
|
user_role,
|
||||||
user_change_password,
|
user_change_password,
|
||||||
user_active,
|
user_status,
|
||||||
user_creator,
|
user_creator,
|
||||||
user_created )
|
user_created )
|
||||||
|
|
||||||
values ( :user_name,
|
values ( :user_name,
|
||||||
:user_email,
|
:user_email,
|
||||||
:user_client_name,
|
:user_first_name,
|
||||||
:user_password,
|
:user_password_hash,
|
||||||
:user_role,
|
:user_role,
|
||||||
:user_change_password,
|
:user_change_password,
|
||||||
:user_active,
|
:user_status,
|
||||||
:user_creator,
|
:user_creator,
|
||||||
:user_created )";
|
:user_created )";
|
||||||
|
|
||||||
@@ -142,11 +147,11 @@ class Users extends Base {
|
|||||||
|
|
||||||
$statement->bindValue(":user_name", $user_name);
|
$statement->bindValue(":user_name", $user_name);
|
||||||
$statement->bindValue(":user_email", $user_email);
|
$statement->bindValue(":user_email", $user_email);
|
||||||
$statement->bindValue(":user_client_name", $user_client_name);
|
$statement->bindValue(":user_first_name", $user_first_name);
|
||||||
$statement->bindValue(":user_password", $user_temp_password);
|
$statement->bindValue(":user_password_hash", $user_temp_password);
|
||||||
$statement->bindValue(":user_role", $user_role);
|
$statement->bindValue(":user_role", $user_role);
|
||||||
$statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
|
$statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
|
||||||
$statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
|
$statement->bindValue(":user_status", $user_active);
|
||||||
$statement->bindValue(":user_creator", $this->current_user_name);
|
$statement->bindValue(":user_creator", $this->current_user_name);
|
||||||
$statement->bindValue(":user_created", $this->getTimestamp());
|
$statement->bindValue(":user_created", $this->getTimestamp());
|
||||||
|
|
||||||
@@ -158,7 +163,7 @@ class Users extends Base {
|
|||||||
|
|
||||||
// Send User Credentials
|
// Send User Credentials
|
||||||
|
|
||||||
(new Email())->emailNewUserCredentials($user, $user_random_password);
|
(new AuthService())->requestReset($user_email);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -226,9 +231,13 @@ class Users extends Base {
|
|||||||
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||||
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
||||||
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
||||||
$user_client_name = filter_input(INPUT_POST, "user_client_name") ?: "";
|
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
|
||||||
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
||||||
$user_active = filter_input(INPUT_POST, "user_active", FILTER_VALIDATE_BOOLEAN) ?: false;
|
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
|
||||||
|
http_response_code(422); exit('Enter a valid username, email address and role.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
|
||||||
|
|
||||||
$users = $this->getUser($user_serial);
|
$users = $this->getUser($user_serial);
|
||||||
|
|
||||||
@@ -241,9 +250,10 @@ class Users extends Base {
|
|||||||
$SQL = "update users
|
$SQL = "update users
|
||||||
set user_name = :user_name,
|
set user_name = :user_name,
|
||||||
user_email = :user_email,
|
user_email = :user_email,
|
||||||
user_client_name = :user_client_name,
|
user_first_name = :user_first_name,
|
||||||
user_role = :user_role,
|
user_role = :user_role,
|
||||||
user_active = :user_active,
|
user_auth_version = user_auth_version + 1,
|
||||||
|
user_status = :user_status,
|
||||||
user_changer = :user_changer,
|
user_changer = :user_changer,
|
||||||
user_changed = :user_changed
|
user_changed = :user_changed
|
||||||
where user_serial = :user_serial";
|
where user_serial = :user_serial";
|
||||||
@@ -253,9 +263,9 @@ class Users extends Base {
|
|||||||
$statement->bindValue(":user_serial", $user_serial);
|
$statement->bindValue(":user_serial", $user_serial);
|
||||||
$statement->bindValue(":user_name", $user_name);
|
$statement->bindValue(":user_name", $user_name);
|
||||||
$statement->bindValue(":user_email", $user_email);
|
$statement->bindValue(":user_email", $user_email);
|
||||||
$statement->bindValue(":user_client_name", $user_client_name);
|
$statement->bindValue(":user_first_name", $user_first_name);
|
||||||
$statement->bindValue(":user_role", $user_role);
|
$statement->bindValue(":user_role", $user_role);
|
||||||
$statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
|
$statement->bindValue(":user_status", $user_active);
|
||||||
$statement->bindValue(":user_changer", $this->current_user_name);
|
$statement->bindValue(":user_changer", $this->current_user_name);
|
||||||
$statement->bindValue(":user_changed", $this->getTimestamp());
|
$statement->bindValue(":user_changed", $this->getTimestamp());
|
||||||
|
|
||||||
@@ -274,6 +284,7 @@ class Users extends Base {
|
|||||||
// ====================
|
// ====================
|
||||||
|
|
||||||
public function getUser($user_serial = 0) {
|
public function getUser($user_serial = 0) {
|
||||||
|
$user_serial = (int) $user_serial;
|
||||||
|
|
||||||
$SQL = "select view_users.*
|
$SQL = "select view_users.*
|
||||||
from view_users
|
from view_users
|
||||||
@@ -287,12 +298,9 @@ class Users extends Base {
|
|||||||
// ====================
|
// ====================
|
||||||
|
|
||||||
public function getUserByUserName($user_name) {
|
public function getUserByUserName($user_name) {
|
||||||
|
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_name = :name');
|
||||||
$SQL = "select view_users.*
|
$stmt->execute([':name' => $user_name]);
|
||||||
from view_users
|
return $this->getTableXML('users', $stmt);
|
||||||
where user_name = '{$user_name}'";
|
|
||||||
|
|
||||||
return $this->getTable("user", $SQL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
@@ -337,14 +345,14 @@ class Users extends Base {
|
|||||||
$SQL = "select count(*) as count
|
$SQL = "select count(*) as count
|
||||||
from view_users
|
from view_users
|
||||||
where user_name regexp :user_name
|
where user_name regexp :user_name
|
||||||
or user_client_name regexp :user_client_name
|
or user_first_name regexp :user_first_name
|
||||||
or user_email regexp :user_email
|
or user_email regexp :user_email
|
||||||
or user_role regexp :user_role";
|
or user_role regexp :user_role";
|
||||||
|
|
||||||
$statement = $connection->prepare($SQL);
|
$statement = $connection->prepare($SQL);
|
||||||
|
|
||||||
$statement->bindValue(":user_name", $find_words);
|
$statement->bindValue(":user_name", $find_words);
|
||||||
$statement->bindValue(":user_client_name", $find_words);
|
$statement->bindValue(":user_first_name", $find_words);
|
||||||
$statement->bindValue(":user_email", $find_words);
|
$statement->bindValue(":user_email", $find_words);
|
||||||
$statement->bindValue(":user_role", $find_words);
|
$statement->bindValue(":user_role", $find_words);
|
||||||
|
|
||||||
@@ -379,7 +387,7 @@ class Users extends Base {
|
|||||||
$SQL = "select view_users.*
|
$SQL = "select view_users.*
|
||||||
from view_users
|
from view_users
|
||||||
where user_name regexp :user_name
|
where user_name regexp :user_name
|
||||||
or user_client_name regexp :user_client_name
|
or user_first_name regexp :user_first_name
|
||||||
or user_email regexp :user_email
|
or user_email regexp :user_email
|
||||||
or user_role regexp :user_role
|
or user_role regexp :user_role
|
||||||
limit {$this->pagination_size}
|
limit {$this->pagination_size}
|
||||||
@@ -388,7 +396,7 @@ class Users extends Base {
|
|||||||
$statement = $connection->prepare($SQL);
|
$statement = $connection->prepare($SQL);
|
||||||
|
|
||||||
$statement->bindValue(":user_name", $find_words);
|
$statement->bindValue(":user_name", $find_words);
|
||||||
$statement->bindValue(":user_client_name", $find_words);
|
$statement->bindValue(":user_first_name", $find_words);
|
||||||
$statement->bindValue(":user_email", $find_words);
|
$statement->bindValue(":user_email", $find_words);
|
||||||
$statement->bindValue(":user_role", $find_words);
|
$statement->bindValue(":user_role", $find_words);
|
||||||
|
|
||||||
@@ -485,111 +493,33 @@ class Users extends Base {
|
|||||||
// ===============
|
// ===============
|
||||||
|
|
||||||
public function changePassword() {
|
public function changePassword() {
|
||||||
|
$params = ['PASSWORD_ERROR' => ''];
|
||||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
if (($_POST['step'] ?? '') === 'change') {
|
||||||
|
$this->requireCsrf();
|
||||||
switch ($step) {
|
try {
|
||||||
|
$auth = new AuthService();
|
||||||
case "prompt":
|
if (!$auth->allowAttempt('change-password', (string) $_SESSION[self::USER_SERIAL], 10, 900)) {
|
||||||
|
throw new InvalidArgumentException('Too many attempts. Please try again later.');
|
||||||
$popovers = (new Popovers())->getPopovers();
|
|
||||||
$XML = $this->mergeXML($popovers, "<XML/>");
|
|
||||||
|
|
||||||
$content = $this->applyXSL($XML, $this->getXSL("changePassword"));
|
|
||||||
$this->displayContent($content);
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "change":
|
|
||||||
|
|
||||||
$user_serial = $_SESSION[self::USER_SERIAL];
|
|
||||||
$user_new_password = filter_input(INPUT_POST, "user_new_password") ?: "";
|
|
||||||
$user_verify_password = filter_input(INPUT_POST, "user_verify_password") ?: "";
|
|
||||||
|
|
||||||
if (!$user_new_password == $user_verify_password) {
|
|
||||||
$this->initializeSession();
|
|
||||||
}
|
}
|
||||||
|
if (!$auth->changePassword((int) $_SESSION[self::USER_SERIAL], $_POST['user_current_password'] ?? '', $_POST['user_new_password'] ?? '', $_POST['user_verify_password'] ?? '')) {
|
||||||
$user_new_hashed_password = password_hash($user_new_password, PASSWORD_DEFAULT);
|
throw new InvalidArgumentException('Your current password is incorrect or your session has expired.');
|
||||||
|
|
||||||
// Update User Password
|
|
||||||
|
|
||||||
$connection = $this->connect();
|
|
||||||
|
|
||||||
$SQL = "update users
|
|
||||||
set user_password = :user_password,
|
|
||||||
user_change_password = :user_change_password,
|
|
||||||
user_changer = :user_changer,
|
|
||||||
user_changed = :user_changed
|
|
||||||
where user_serial = :user_serial";
|
|
||||||
|
|
||||||
$statement = $connection->prepare($SQL);
|
|
||||||
|
|
||||||
$statement->bindValue(":user_serial", $user_serial);
|
|
||||||
$statement->bindValue(":user_password", $user_new_hashed_password);
|
|
||||||
$statement->bindValue(":user_change_password", 0);
|
|
||||||
$statement->bindValue(":user_changer", $this->current_user_name);
|
|
||||||
$statement->bindValue(":user_changed", $this->getTimestamp());
|
|
||||||
|
|
||||||
if (!$statement->execute()) {
|
|
||||||
$this->debug($statement->errorInfo()[2]);
|
|
||||||
}
|
}
|
||||||
|
$this->logoutSession();
|
||||||
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} changed their password.");
|
header('Location: main.php', true, 303);
|
||||||
|
exit;
|
||||||
break;
|
} catch (InvalidArgumentException $e) { $params['PASSWORD_ERROR'] = $e->getMessage(); }
|
||||||
|
|
||||||
default:
|
|
||||||
|
|
||||||
$this->displayHome();
|
|
||||||
}
|
}
|
||||||
|
$this->displayContent($this->applyXSL('<XML/>', $this->getXSL('changePassword'), $params));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============
|
|
||||||
// Reset Password
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
public function resetPassword() {
|
public function resetPassword() {
|
||||||
|
$this->requireCsrf();
|
||||||
$reset = false;
|
$stmt = $this->connect()->prepare('SELECT user_email FROM users WHERE user_serial = :id');
|
||||||
|
$stmt->execute([':id' => (int) ($_POST['user_serial'] ?? 0)]);
|
||||||
$user_serial = filter_input(INPUT_POST, "user_serial") ?: "";
|
$email = $stmt->fetchColumn();
|
||||||
|
if ($email) (new AuthService())->requestReset($email);
|
||||||
$user = $this->getUser($user_serial);
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(true);
|
||||||
$user_name = $user->record->user_name;
|
|
||||||
|
|
||||||
$user_random_password = $this->generateRandomPassword();
|
|
||||||
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
|
|
||||||
|
|
||||||
// Update User Password
|
|
||||||
|
|
||||||
$connection = $this->connect();
|
|
||||||
|
|
||||||
$SQL = "update users
|
|
||||||
set user_password = :user_password,
|
|
||||||
user_change_password = :user_change_password
|
|
||||||
where user_serial = :user_serial";
|
|
||||||
|
|
||||||
$statement = $connection->prepare($SQL);
|
|
||||||
|
|
||||||
$statement->bindValue(":user_serial", $user_serial);
|
|
||||||
$statement->bindValue(":user_password", $user_temp_password);
|
|
||||||
$statement->bindValue(":user_change_password", 1);
|
|
||||||
|
|
||||||
if (!$statement->execute()) {
|
|
||||||
$this->debug($statement->errorInfo()[2]);
|
|
||||||
} else {
|
|
||||||
$reset = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send User Credentials
|
|
||||||
|
|
||||||
(new Email())->emailUserPasswordResetLink($user, $user_random_password);
|
|
||||||
|
|
||||||
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} reset {$user_name}'s password.");
|
|
||||||
|
|
||||||
echo json_encode($reset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================================
|
// ===============================================
|
||||||
@@ -599,7 +529,7 @@ class Users extends Base {
|
|||||||
public function updateLogin() {
|
public function updateLogin() {
|
||||||
|
|
||||||
$SQL = "update users
|
$SQL = "update users
|
||||||
set user_login = :user_login
|
set user_last_login = :user_login
|
||||||
where user_serial = :user_serial";
|
where user_serial = :user_serial";
|
||||||
|
|
||||||
$statement = $this->connect()->prepare($SQL);
|
$statement = $this->connect()->prepare($SQL);
|
||||||
@@ -617,10 +547,11 @@ class Users extends Base {
|
|||||||
public function updateActiveStatus() {
|
public function updateActiveStatus() {
|
||||||
|
|
||||||
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||||
$user_active = filter_input(INPUT_POST, "user_active", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
|
||||||
|
|
||||||
$SQL = "update users
|
$SQL = "update users
|
||||||
set user_active = :user_active,
|
set user_status = :user_status,
|
||||||
|
user_auth_version = user_auth_version + 1,
|
||||||
user_changed = :user_changed,
|
user_changed = :user_changed,
|
||||||
user_changer = :user_changer
|
user_changer = :user_changer
|
||||||
where user_serial = :user_serial";
|
where user_serial = :user_serial";
|
||||||
@@ -628,7 +559,7 @@ class Users extends Base {
|
|||||||
$statement = $this->connect()->prepare($SQL);
|
$statement = $this->connect()->prepare($SQL);
|
||||||
|
|
||||||
$statement->bindValue(":user_serial", $user_serial);
|
$statement->bindValue(":user_serial", $user_serial);
|
||||||
$statement->bindValue(":user_active", $user_active);
|
$statement->bindValue(":user_status", $user_active);
|
||||||
$statement->bindValue(":user_changed", $this->getTimestamp());
|
$statement->bindValue(":user_changed", $this->getTimestamp());
|
||||||
$statement->bindValue(":user_changer", $this->current_user_name);
|
$statement->bindValue(":user_changer", $this->current_user_name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
{
|
{
|
||||||
"require": {
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"php-64bit": "^8.3",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_mysql": "*",
|
||||||
|
"ext-session": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xsl": "*",
|
||||||
"tecnickcom/tcpdf": "^6.10",
|
"tecnickcom/tcpdf": "^6.10",
|
||||||
"phpmailer/phpmailer": "^7.0",
|
"phpmailer/phpmailer": "^7.0",
|
||||||
"phpoffice/phpspreadsheet": "^5.2"
|
"phpoffice/phpspreadsheet": "^5.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:auth": "php tests/auth_integration.php"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+50
-196
@@ -4,32 +4,33 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "6621356a0da1644f04f6f40bd0e0b948",
|
"content-hash": "0389de306dca31ea5873c1d9ed6a4c5d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
"version": "3.3.2",
|
"version": "3.4.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/composer/pcre.git",
|
"url": "https://github.com/composer/pcre.git",
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.4 || ^8.0"
|
"php": "^7.4 || ^8.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"phpstan/phpstan": "<1.11.10"
|
"phpstan/phpstan": "<2.2.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
"phpstan/phpstan": "^2",
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
"phpunit/phpunit": "^8 || ^9"
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/composer/pcre/issues",
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -77,26 +78,22 @@
|
|||||||
{
|
{
|
||||||
"url": "https://github.com/composer",
|
"url": "https://github.com/composer",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-11-12T16:29:46+00:00"
|
"time": "2026-06-07T11:47:49+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "maennchen/zipstream-php",
|
"name": "maennchen/zipstream-php",
|
||||||
"version": "3.2.0",
|
"version": "3.2.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -107,7 +104,7 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"brianium/paratest": "^7.7",
|
"brianium/paratest": "^7.7",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"friendsofphp/php-cs-fixer": "^3.16",
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
"guzzlehttp/guzzle": "^7.5",
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
"mikey179/vfsstream": "^1.6",
|
"mikey179/vfsstream": "^1.6",
|
||||||
"php-coveralls/php-coveralls": "^2.5",
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
@@ -153,7 +150,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -161,7 +158,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-07-17T11:15:13+00:00"
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "markbaker/complex",
|
"name": "markbaker/complex",
|
||||||
@@ -354,16 +351,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoffice/phpspreadsheet",
|
"name": "phpoffice/phpspreadsheet",
|
||||||
"version": "5.2.0",
|
"version": "5.10.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
"reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb"
|
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
|
||||||
"reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
|
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -371,6 +368,7 @@
|
|||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-fileinfo": "*",
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
@@ -384,14 +382,14 @@
|
|||||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
"markbaker/complex": "^3.0",
|
"markbaker/complex": "^3.0",
|
||||||
"markbaker/matrix": "^3.0",
|
"markbaker/matrix": "^3.0",
|
||||||
"php": "^8.1",
|
"php": "^8.2",
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"psr/http-factory": "^1.0",
|
|
||||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
"mitoteam/jpgraph": "^10.5",
|
"mitoteam/jpgraph": "^10.5",
|
||||||
"mpdf/mpdf": "^8.1.1",
|
"mpdf/mpdf": "^8.1.1",
|
||||||
@@ -399,13 +397,14 @@
|
|||||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
"tecnickcom/tcpdf": "^6.5"
|
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
"ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
|
||||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
@@ -438,6 +437,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Adrien Crivelli"
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
@@ -454,169 +456,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.2.0"
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
|
||||||
},
|
},
|
||||||
"time": "2025-10-26T15:54:22+00:00"
|
"time": "2026-09-17T03:54:50+00:00"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "psr/http-client",
|
|
||||||
"version": "1.0.3",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/php-fig/http-client.git",
|
|
||||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
|
||||||
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.0 || ^8.0",
|
|
||||||
"psr/http-message": "^1.0 || ^2.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.0.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Psr\\Http\\Client\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "PHP-FIG",
|
|
||||||
"homepage": "https://www.php-fig.org/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Common interface for HTTP clients",
|
|
||||||
"homepage": "https://github.com/php-fig/http-client",
|
|
||||||
"keywords": [
|
|
||||||
"http",
|
|
||||||
"http-client",
|
|
||||||
"psr",
|
|
||||||
"psr-18"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/php-fig/http-client"
|
|
||||||
},
|
|
||||||
"time": "2023-09-23T14:17:50+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "psr/http-factory",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/php-fig/http-factory.git",
|
|
||||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
|
||||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=7.1",
|
|
||||||
"psr/http-message": "^1.0 || ^2.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.0.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Psr\\Http\\Message\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "PHP-FIG",
|
|
||||||
"homepage": "https://www.php-fig.org/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
|
|
||||||
"keywords": [
|
|
||||||
"factory",
|
|
||||||
"http",
|
|
||||||
"message",
|
|
||||||
"psr",
|
|
||||||
"psr-17",
|
|
||||||
"psr-7",
|
|
||||||
"request",
|
|
||||||
"response"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/php-fig/http-factory"
|
|
||||||
},
|
|
||||||
"time": "2024-04-15T12:06:14+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "psr/http-message",
|
|
||||||
"version": "2.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/php-fig/http-message.git",
|
|
||||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
|
||||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.2 || ^8.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "2.0.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Psr\\Http\\Message\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "PHP-FIG",
|
|
||||||
"homepage": "https://www.php-fig.org/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Common interface for HTTP messages",
|
|
||||||
"homepage": "https://github.com/php-fig/http-message",
|
|
||||||
"keywords": [
|
|
||||||
"http",
|
|
||||||
"http-message",
|
|
||||||
"psr",
|
|
||||||
"psr-7",
|
|
||||||
"request",
|
|
||||||
"response"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/php-fig/http-message/tree/2.0"
|
|
||||||
},
|
|
||||||
"time": "2023-04-04T09:54:51+00:00"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/simple-cache",
|
"name": "psr/simple-cache",
|
||||||
@@ -747,7 +589,19 @@
|
|||||||
"stability-flags": {},
|
"stability-flags": {},
|
||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {},
|
"platform": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"php-64bit": "^8.3",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_mysql": "*",
|
||||||
|
"ext-session": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xsl": "*"
|
||||||
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -83,7 +83,7 @@ $(document).ready(function () {
|
|||||||
$("#addUser").submit(function (e) {
|
$("#addUser").submit(function (e) {
|
||||||
|
|
||||||
var user_name = $("#user_name"),
|
var user_name = $("#user_name"),
|
||||||
user_client_name = $("#user_client_name"),
|
user_first_name = $("#user_first_name"),
|
||||||
user_email = $("#user_email"),
|
user_email = $("#user_email"),
|
||||||
user_role = $("#user_role");
|
user_role = $("#user_role");
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@ $(document).ready(function () {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlank(user_client_name)) {
|
if (isBlank(user_first_name)) {
|
||||||
createInputError(user_client_name, "Client Name is required");
|
createInputError(user_first_name, "Client Name is required");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
@@ -1,3 +1,17 @@
|
|||||||
|
// Attach the session CSRF token to existing forms and same-origin AJAX requests.
|
||||||
|
$.ajaxPrefilter(function (options, original, xhr) {
|
||||||
|
if (!options.crossDomain && !/^(GET|HEAD|OPTIONS)$/i.test(options.type)) {
|
||||||
|
xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content') || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$(function () {
|
||||||
|
$('form[method="post" i]').each(function () {
|
||||||
|
if (!$(this).find('[name="csrf_token"]').length) {
|
||||||
|
$('<input>', {type: 'hidden', name: 'csrf_token', value: $('meta[name="csrf-token"]').attr('content') || ''}).appendTo(this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// ==============
|
// ==============
|
||||||
// Base Functions
|
// Base Functions
|
||||||
|
|||||||
+3
-3
@@ -97,7 +97,7 @@ $(document).ready(function () {
|
|||||||
var user_name = $("#user_name"),
|
var user_name = $("#user_name"),
|
||||||
user_email = $("#user_email"),
|
user_email = $("#user_email"),
|
||||||
user_role = $("#user_role"),
|
user_role = $("#user_role"),
|
||||||
user_client_name = $("#user_client_name");
|
user_first_name = $("#user_first_name");
|
||||||
|
|
||||||
clearInputErrors();
|
clearInputErrors();
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@ $(document).ready(function () {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlank(user_client_name)) {
|
if (isBlank(user_first_name)) {
|
||||||
createInputError(user_client_name, "Client Name is required");
|
createInputError(user_first_name, "Client Name is required");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ $(document).ready(function () {
|
|||||||
// Use our Bootstrap validation messages.
|
// Use our Bootstrap validation messages.
|
||||||
$login.add($signUp).add($resetPassword).attr("novalidate", "novalidate");
|
$login.add($signUp).add($resetPassword).attr("novalidate", "novalidate");
|
||||||
|
|
||||||
$(".btnSignUp").on("click", function (e) {
|
$("#login .btnSignUp").on("click", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "./?Xaction=signup";
|
window.location.href = "./?Xaction=signup";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once "classes/Base.php";
|
declare(strict_types=1);
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Referrer-Policy: no-referrer');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
set_exception_handler(function (Throwable $e): void {
|
||||||
|
error_log((string) $e);
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'The application could not complete this request. Please try again later.';
|
||||||
|
});
|
||||||
|
require_once __DIR__ . '/classes/Base.php';
|
||||||
|
$application = new Base();
|
||||||
|
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||||
|
if ((string) $application->getSettings()->Environment === 'Production' && !$https) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('HTTPS is required. Open the secure application URL.');
|
||||||
|
}
|
||||||
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
ini_set('session.use_only_cookies', '1');
|
||||||
|
session_name('HUNTINGCLUBROSTER');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0, 'path' => '/',
|
||||||
|
'secure' => $https,
|
||||||
|
'httponly' => true, 'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
session_start();
|
session_start();
|
||||||
|
$application->do();
|
||||||
(new Base())->do();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Run once on the supplied users schema. Take a database backup first.
|
||||||
|
ALTER TABLE users ADD COLUMN user_auth_version INT UNSIGNED NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
|
||||||
|
user_serial BIGINT UNSIGNED NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
INDEX (user_serial),
|
||||||
|
INDEX (expires_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_attempts (
|
||||||
|
attempt_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
window_started DATETIME NOT NULL,
|
||||||
|
INDEX (window_started)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once ("tcpdf/tcpdf.php");
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
// Print Selected Work Orders
|
// Print Selected Work Orders
|
||||||
|
|||||||
@@ -1,48 +1,18 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Create System Tables</title>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #333;
|
|
||||||
color: white;
|
|
||||||
font-family: sans-serif;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success {
|
|
||||||
color: #8fd19e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
color: #ffd966;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ff9999;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
define("INITIALIZATION_FILE_NAME", "../xml/settings.xml");
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
define("INITIALIZATION_FILE_NAME", getenv("HCR_SETTINGS_FILE") ?: (__DIR__ . "/../xml/" . (is_file(__DIR__ . "/../xml/settings.local.xml") ? "settings.local.xml" : "settings.xml")));
|
||||||
define("INIT_SCRIPT_USER", "Init Script");
|
define("INIT_SCRIPT_USER", "Init Script");
|
||||||
|
|
||||||
// Safety switch - Change this to true when you intentionally want to rebuild system tables.
|
// Safety switch - Change this to true when you intentionally want to rebuild system tables.
|
||||||
$allow_rebuild = true;
|
$allow_rebuild = getenv('HCR_ALLOW_REBUILD') === 'yes';
|
||||||
|
|
||||||
if (!$allow_rebuild) {
|
if (!$allow_rebuild) {
|
||||||
echoLog("Rebuild is disabled. Set \$allow_rebuild to true to continue.", "warning");
|
echoLog("Rebuild is disabled. Set \$allow_rebuild to true to continue.", "warning");
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (strlen(getenv('HCR_INITIAL_ADMIN_PASSWORD') ?: '') < 12 || strlen(getenv('HCR_INITIAL_ADMIN_PASSWORD') ?: '') > 72) throw new RuntimeException('Set HCR_INITIAL_ADMIN_PASSWORD before rebuilding.');
|
||||||
|
|
||||||
// --------------------------
|
// --------------------------
|
||||||
// Load Application Settings
|
// Load Application Settings
|
||||||
// --------------------------
|
// --------------------------
|
||||||
@@ -247,7 +217,9 @@
|
|||||||
|
|
||||||
function seedInitialAdminUser(PDO $connection) {
|
function seedInitialAdminUser(PDO $connection) {
|
||||||
|
|
||||||
$password = password_hash("349Jamot@", PASSWORD_DEFAULT);
|
$initialPassword = getenv('HCR_INITIAL_ADMIN_PASSWORD');
|
||||||
|
if (!$initialPassword || strlen($initialPassword) < 12 || strlen($initialPassword) > 72) throw new RuntimeException('Set HCR_INITIAL_ADMIN_PASSWORD to a unique 12-72 byte password.');
|
||||||
|
$password = password_hash($initialPassword, PASSWORD_DEFAULT);
|
||||||
|
|
||||||
$SQL = "INSERT INTO users (
|
$SQL = "INSERT INTO users (
|
||||||
user_name,
|
user_name,
|
||||||
|
|||||||
+2
-10
@@ -1,14 +1,6 @@
|
|||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #333333;
|
|
||||||
color: white;
|
|
||||||
font-family: sans-serif;
|
|
||||||
padding: 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
define("INITIALIZATION_FILE_NAME", "../xml/settings.xml");
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
define("INITIALIZATION_FILE_NAME", getenv("HCR_SETTINGS_FILE") ?: (__DIR__ . "/../xml/" . (is_file(__DIR__ . "/../xml/settings.local.xml") ? "settings.local.xml" : "settings.xml")));
|
||||||
|
|
||||||
// Retrieve and validate table name.
|
// Retrieve and validate table name.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ $SQL = "create table users (
|
|||||||
user_password_hash varchar(255) not null,
|
user_password_hash varchar(255) not null,
|
||||||
user_role varchar(255) not null,
|
user_role varchar(255) not null,
|
||||||
user_change_password boolean default false,
|
user_change_password boolean default false,
|
||||||
|
user_auth_version int unsigned not null default 0,
|
||||||
|
|
||||||
user_status varchar(255) not null default 'active',
|
user_status varchar(255) not null default 'active',
|
||||||
user_last_login datetime default null,
|
user_last_login datetime default null,
|
||||||
@@ -26,6 +27,6 @@ $SQL = "create table users (
|
|||||||
unique key (user_email),
|
unique key (user_email),
|
||||||
index (user_name)
|
index (user_name)
|
||||||
|
|
||||||
) default character set = utf8mb4
|
) engine = InnoDB default character set = utf8mb4
|
||||||
collate = utf8mb4_unicode_ci";
|
collate = utf8mb4_unicode_ci";
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""End-to-end tests against a disposable local database and PHP development server.
|
||||||
|
|
||||||
|
Set HCR_TEST_DSN, HCR_TEST_USER, HCR_TEST_PASSWORD, HCR_DB_HOST,
|
||||||
|
HCR_DB_PORT, HCR_DB_USER, HCR_DB_PASSWORD and PHP_TEST_COMMAND (JSON argv).
|
||||||
|
"""
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PHP = json.loads(os.environ.get('PHP_TEST_COMMAND', '["php"]'))
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['HCR_APPLICATION_URL'] = '' # Never send real email from the test suite.
|
||||||
|
env['HCR_ENVIRONMENT'] = 'Development'
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
def fixture(action, database=None):
|
||||||
|
args = PHP + ['tests/http_fixture.php', action] + ([database] if database else [])
|
||||||
|
result = subprocess.run(args, cwd=ROOT, env=env, check=True, text=True, capture_output=True)
|
||||||
|
return json.loads(result.stdout.splitlines()[-1]) if result.stdout.strip() else {}
|
||||||
|
|
||||||
|
def check(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError(message)
|
||||||
|
checks.append(message)
|
||||||
|
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
|
def request(path='/', data=None, headers=None):
|
||||||
|
encoded = urllib.parse.urlencode(data).encode() if data is not None else None
|
||||||
|
req = urllib.request.Request(base + path, data=encoded, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with client.open(req, timeout=20) as response:
|
||||||
|
return response.status, response.read().decode(), response.headers
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
return error.code, error.read().decode(), error.headers
|
||||||
|
|
||||||
|
def csrf(html):
|
||||||
|
match = re.search(r'name="csrf_token"[^>]*value="([a-f0-9]+)"', html)
|
||||||
|
if not match:
|
||||||
|
match = re.search(r'name="csrf-token"[^>]*content="([a-f0-9]+)"', html)
|
||||||
|
if not match:
|
||||||
|
raise AssertionError('Missing CSRF token')
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
database = fixture('create')['database']
|
||||||
|
env['HCR_DB_NAME'] = database
|
||||||
|
with socket.socket() as available:
|
||||||
|
available.bind(('127.0.0.1', 0))
|
||||||
|
port = available.getsockname()[1]
|
||||||
|
base = f'http://127.0.0.1:{port}'
|
||||||
|
server = None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(PHP + ['bin/migrate-auth.php'], cwd=ROOT, env=env, check=True, capture_output=True, text=True)
|
||||||
|
check('schema is ready' in result.stdout, 'Database migration can be rerun safely')
|
||||||
|
with tempfile.TemporaryFile(mode='w+b') as log:
|
||||||
|
server = subprocess.Popen(PHP + ['-S', f'127.0.0.1:{port}', 'bin/dev-router.php'], cwd=ROOT, env=env, stdout=log, stderr=log)
|
||||||
|
for _ in range(50):
|
||||||
|
try:
|
||||||
|
status, html, headers = request()
|
||||||
|
break
|
||||||
|
except urllib.error.URLError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
raise AssertionError('PHP development server did not start')
|
||||||
|
check(status == 200 and 'name="user_email"' in html, 'Login page renders over HTTP')
|
||||||
|
check('no-store' in headers.get('Cache-Control', ''), 'Authentication pages are not cached')
|
||||||
|
check('HttpOnly' in headers.get('Set-Cookie', '') and 'SameSite=Lax' in headers.get('Set-Cookie', ''), 'Session cookies have HttpOnly and SameSite protections')
|
||||||
|
check(request(data={'action': 'SignIn.login', 'user_email': 'http@example.test', 'user_password': 'wrong'})[0] == 403, 'Login rejects missing CSRF token')
|
||||||
|
check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected')
|
||||||
|
status, html, _ = request('/?Xaction=signup')
|
||||||
|
check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login')
|
||||||
|
fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator'}
|
||||||
|
status, html, _ = request(data=fields)
|
||||||
|
check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects')
|
||||||
|
status, html, _ = request()
|
||||||
|
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'wrong'})
|
||||||
|
check(status == 200 and 'Invalid email address or password' in html, 'Invalid login renders the existing login template')
|
||||||
|
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'})
|
||||||
|
check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
|
||||||
|
token = csrf(html)
|
||||||
|
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges')
|
||||||
|
check(request(data={'action': 'print', 'report': 'Email', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
|
||||||
|
check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
|
||||||
|
check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
|
||||||
|
status, html, _ = request(data={'action': 'signout', 'csrf_token': token})
|
||||||
|
check(status == 200 and 'id="login"' in html, 'Logout ends the authenticated session')
|
||||||
|
status, html, _ = request('/?Xaction=resetpassword')
|
||||||
|
check('Send reset link' in html, 'Password reset request page is reachable before login')
|
||||||
|
status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(html), 'user_email': 'unknown@example.test'})
|
||||||
|
check(status == 200 and 'If an active account matches' in html, 'Unknown account reset returns a neutral response')
|
||||||
|
status, form, _ = request('/?Xaction=resetpassword')
|
||||||
|
status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(form), 'user_email': 'http@example.test'})
|
||||||
|
check('If an active account matches' in html, 'Reset delivery/configuration failure returns the same neutral response')
|
||||||
|
reset_token = fixture('token', database)['token']
|
||||||
|
status, html, headers = request('/?Xaction=resetpassword&token=' + reset_token)
|
||||||
|
check(status == 200 and 'Set new password' in html and headers.get('Referrer-Policy') == 'no-referrer', 'Reset link renders a protected new-password form')
|
||||||
|
status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
|
||||||
|
check(status == 200 and 'Your password has been changed' in html, 'Reset form changes the password and redirects')
|
||||||
|
status, html, _ = request('/?Xaction=resetpassword&token=' + reset_token)
|
||||||
|
status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
|
||||||
|
check('invalid or expired' in html, 'Used reset link fails through the real HTTP handler')
|
||||||
|
status, html, _ = request()
|
||||||
|
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP changed password!'})
|
||||||
|
check(status == 200 and 'name="csrf-token"' in html, 'Reset password works through the real login form')
|
||||||
|
fixture('revoke', database)
|
||||||
|
check(request(data={'action': 'toggleTheme', 'csrf_token': csrf(html)}, headers={'X-Requested-With': 'XMLHttpRequest'})[0] == 401, 'Revoked AJAX sessions return 401 instead of a login document')
|
||||||
|
for path in ['/xml/settings.local.xml', '/tables/_createSystemTable.php', '/bin/migrate-auth.php', '/.git/config']:
|
||||||
|
check(request(path)[0] == 404, 'Development router blocks ' + path)
|
||||||
|
for message in checks:
|
||||||
|
print('PASS:', message)
|
||||||
|
print(len(checks), 'HTTP checks passed.')
|
||||||
|
finally:
|
||||||
|
if server:
|
||||||
|
server.terminate()
|
||||||
|
server.wait(timeout=10)
|
||||||
|
fixture('drop', database)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
/** Destructive only inside a new, randomly named test database. Never reads project DB credentials. */
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
require_once __DIR__ . '/../classes/Base.php';
|
||||||
|
if (!getenv('HCR_TEST_DSN')) throw new RuntimeException('Set HCR_TEST_DSN to an isolated MySQL/MariaDB server (without dbname).');
|
||||||
|
$server = new PDO(getenv('HCR_TEST_DSN'), getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$database = 'hcr_auth_test_' . bin2hex(random_bytes(6));
|
||||||
|
$server->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
$server->exec("USE `{$database}`");
|
||||||
|
$server->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$server->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
class TestAuth extends AuthService {
|
||||||
|
public array $mailbox = [];
|
||||||
|
public bool $mailWorks = true;
|
||||||
|
public function __construct(private PDO $db) { parent::__construct(); }
|
||||||
|
public function connect(): PDO { return $this->db; }
|
||||||
|
public function getSettings() { return new SimpleXMLElement('<Settings><ApplicationUrl>https://roster.example.test/club</ApplicationUrl></Settings>'); }
|
||||||
|
protected function deliverResetEmail(array $user, string $url): bool {
|
||||||
|
$this->mailbox[] = ['email' => $user['user_email'], 'url' => $url];
|
||||||
|
return $this->mailWorks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CaptureMailer extends Email {
|
||||||
|
public array $message = [];
|
||||||
|
public function sendEmail($to = '', $subject = '', $message = '', $attachments = '', $attachment_names = 'unknown') {
|
||||||
|
$this->message = ['to' => $to, 'subject' => $subject, 'body' => $message];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$checks = [];
|
||||||
|
function check(bool $condition, string $message): void {
|
||||||
|
global $checks;
|
||||||
|
if (!$condition) throw new RuntimeException('FAILED: ' . $message);
|
||||||
|
$checks[] = $message;
|
||||||
|
}
|
||||||
|
function rejects(callable $operation, string $message): void {
|
||||||
|
try { $operation(); } catch (InvalidArgumentException $e) { check(true, $message); return; }
|
||||||
|
check(false, $message);
|
||||||
|
}
|
||||||
|
function tokenFrom(array $email): string {
|
||||||
|
parse_str(parse_url($email['url'], PHP_URL_QUERY), $query);
|
||||||
|
return $query['token'];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require __DIR__ . '/../tables/users.php';
|
||||||
|
$server->exec($SQL);
|
||||||
|
// Exercise the upgrade path from the supplied pre-change schema too.
|
||||||
|
$server->exec('ALTER TABLE users DROP COLUMN user_auth_version');
|
||||||
|
foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
|
||||||
|
if (trim($sql)) $server->exec($sql);
|
||||||
|
}
|
||||||
|
require __DIR__ . '/../views/view_users.php';
|
||||||
|
$server->exec($SQL);
|
||||||
|
$auth = new TestAuth($server);
|
||||||
|
$auth->initializeSession();
|
||||||
|
$input = ['user_first_name' => 'Jane', 'user_last_name' => 'Hunter', 'user_email' => 'jane@example.test', 'user_name' => 'jane', 'user_password' => 'A long password 123!', 'user_password_confirm' => 'A long password 123!', 'user_role' => 'Administrator'];
|
||||||
|
$auth->register($input);
|
||||||
|
$user = $auth->findUser('jane@example.test');
|
||||||
|
$id = (int) $user['user_serial'];
|
||||||
|
check($user['user_role'] === 'User', 'Signup cannot assign administrator privileges');
|
||||||
|
check(password_verify($input['user_password'], $user['user_password_hash']), 'Signup stores a verifiable password hash');
|
||||||
|
check($user['user_password_hash'] !== $input['user_password'], 'Signup does not store plaintext passwords');
|
||||||
|
rejects(fn() => $auth->register($input), 'Duplicate account details are rejected');
|
||||||
|
rejects(fn() => $auth->register(array_replace($input, ['user_email' => 'invalid'])), 'Invalid email is rejected on the server');
|
||||||
|
rejects(fn() => $auth->register(array_replace($input, ['user_password_confirm' => 'different'])), 'Mismatched passwords are rejected on the server');
|
||||||
|
rejects(fn() => $auth->register(array_replace($input, ['user_password' => 'short', 'user_password_confirm' => 'short'])), 'Short passwords are rejected');
|
||||||
|
rejects(fn() => $auth->register(array_replace($input, ['user_password' => str_repeat('x', 73), 'user_password_confirm' => str_repeat('x', 73)])), 'Passwords above bcrypt byte limit are rejected');
|
||||||
|
check(!$auth->login("' OR 1=1 -- ", 'wrong'), 'SQL injection input cannot log in');
|
||||||
|
check(!$auth->login('jane@example.test', 'wrong'), 'Wrong password cannot log in');
|
||||||
|
$oldSession = session_id();
|
||||||
|
check($auth->login('jane@example.test', $input['user_password']), 'Email login succeeds');
|
||||||
|
check(session_id() !== $oldSession, 'Login rotates session ID');
|
||||||
|
check($auth->currentUser() !== null, 'Authenticated session is valid');
|
||||||
|
check($server->query('SELECT user_last_login FROM users')->fetchColumn() !== null, 'Login timestamp uses the correct column');
|
||||||
|
check($auth->login('jane', $input['user_password']), 'Username login remains supported');
|
||||||
|
$before = $auth->findUser('jane')['user_password_hash'];
|
||||||
|
$auth->requestReset('jane@example.test');
|
||||||
|
check(count($auth->mailbox) === 1, 'Reset request generates an email');
|
||||||
|
check($auth->findUser('jane')['user_password_hash'] === $before, 'Reset request leaves password unchanged');
|
||||||
|
$token = tokenFrom($auth->mailbox[0]);
|
||||||
|
check(strlen($token) === 64, 'Reset token contains 256 bits of randomness');
|
||||||
|
check($server->query('SELECT token_hash FROM password_resets')->fetchColumn() === hash('sha256', $token), 'Database stores only a hash of the reset token');
|
||||||
|
$auth->requestReset('missing@example.test');
|
||||||
|
check(count($auth->mailbox) === 1, 'Unknown accounts do not produce reset email');
|
||||||
|
check(!$auth->resetPassword(str_repeat('0', 64), 'Another long password!', 'Another long password!'), 'Unknown reset token is rejected');
|
||||||
|
$server->exec('UPDATE password_resets SET expires_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 SECOND)');
|
||||||
|
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Expired reset token is rejected');
|
||||||
|
$auth->requestReset('jane@example.test');
|
||||||
|
$token = tokenFrom($auth->mailbox[1]);
|
||||||
|
check($auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Valid reset token changes password');
|
||||||
|
check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Reset token cannot be reused');
|
||||||
|
check($auth->currentUser() === null, 'Password reset revokes an existing session');
|
||||||
|
check(!$auth->login('jane', $input['user_password']), 'Old password fails after reset');
|
||||||
|
check($auth->login('jane', 'Another long password!'), 'New password works after reset');
|
||||||
|
rejects(fn() => $auth->changePassword($id, 'Another long password!', 'New password 123!', 'mismatch'), 'Password change rejects mismatched confirmation');
|
||||||
|
check(!$auth->changePassword($id, 'wrong', 'New password 123!', 'New password 123!'), 'Password change requires the current password');
|
||||||
|
check($auth->changePassword($id, 'Another long password!', 'New password 123!', 'New password 123!'), 'Authenticated password change succeeds');
|
||||||
|
check($auth->currentUser() === null, 'Password change revokes previous sessions');
|
||||||
|
check($auth->login('jane', 'New password 123!'), 'Changed password works');
|
||||||
|
$_SESSION['last_activity'] = time() - 1801;
|
||||||
|
check($auth->currentUser() === null, 'Idle sessions expire on the server');
|
||||||
|
$server->exec("UPDATE users SET user_status = 'inactive'");
|
||||||
|
check(!$auth->login('jane', 'New password 123!'), 'Inactive users cannot log in');
|
||||||
|
$server->exec("UPDATE users SET user_status = 'active'");
|
||||||
|
$auth->mailWorks = false;
|
||||||
|
$beforeFailedEmail = $auth->findUser('jane')['user_password_hash'];
|
||||||
|
try { $auth->requestReset('jane@example.test'); check(false, 'Mail failure must be detected'); }
|
||||||
|
catch (RuntimeException $e) { check($e->getMessage() === 'Password reset email delivery failed.', 'Email failure is detected'); }
|
||||||
|
check((int) $server->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() === 0, 'Failed email delivery removes its token');
|
||||||
|
check($auth->findUser('jane')['user_password_hash'] === $beforeFailedEmail, 'Email failure preserves the existing password');
|
||||||
|
$mailer = new CaptureMailer();
|
||||||
|
$mailer->sendPasswordReset(['user_name' => 'jane', 'user_first_name' => '<b>Jane & club</b>', 'user_email' => 'jane@example.test'], 'https://app.myhuntingclubroster.com/main.php?Xaction=resetpassword&token=example');
|
||||||
|
check($mailer->message['to'] === 'jane@example.test', 'Reset email uses the stored account email');
|
||||||
|
check(str_contains($mailer->message['body'], 'https://app.myhuntingclubroster.com/main.php'), 'Reset email contains the configured application link');
|
||||||
|
check(str_contains($mailer->message['body'], '<b>Jane & club</b>'), 'Reset email escapes account names');
|
||||||
|
check(!str_contains($mailer->message['body'], 'New password 123!'), 'Reset email never includes an account password');
|
||||||
|
for ($i = 0; $i < 3; $i++) $allowed = $auth->allowAttempt('test', 'address', 2, 60);
|
||||||
|
check(!$allowed, 'Rate limits reject excessive attempts');
|
||||||
|
$server->exec('UPDATE auth_attempts SET window_started = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)');
|
||||||
|
check($auth->allowAttempt('test', 'address', 2, 60), 'Rate limits recover after their window');
|
||||||
|
$freshAuth = new TestAuth($server);
|
||||||
|
for ($i = 0; $i < 11; $i++) $allowed = $freshAuth->login('jane', 'wrong');
|
||||||
|
check(!$freshAuth->login('jane', 'New password 123!'), 'Login rate limits survive new service instances');
|
||||||
|
foreach (['login', 'createAccount', 'resetPassword', 'changePassword', 'content'] as $template) {
|
||||||
|
$html = $auth->applyXSL('<XML/>', $auth->getXSL($template));
|
||||||
|
check(is_string($html) && strlen($html) > 100, $template . ' stylesheet renders');
|
||||||
|
}
|
||||||
|
foreach ($checks as $message) echo "PASS: {$message}\n";
|
||||||
|
echo count($checks) . " checks passed.\n";
|
||||||
|
} finally {
|
||||||
|
$server->exec("DROP DATABASE `{$database}`");
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||||
|
require_once __DIR__ . '/../classes/Base.php';
|
||||||
|
$dsn = getenv('HCR_TEST_DSN');
|
||||||
|
if (!$dsn) throw new RuntimeException('Set HCR_TEST_DSN to an isolated test server.');
|
||||||
|
$db = new PDO($dsn, getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$action = $argv[1] ?? '';
|
||||||
|
$name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6)));
|
||||||
|
if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.');
|
||||||
|
if ($action === 'drop') { $db->exec("DROP DATABASE `{$name}`"); exit; }
|
||||||
|
if ($action === 'create') {
|
||||||
|
$db->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||||
|
$db->exec("USE `{$name}`");
|
||||||
|
require __DIR__ . '/../tables/users.php'; $db->exec($SQL);
|
||||||
|
// The CLI migration must also support being run against a fresh users table.
|
||||||
|
putenv('HCR_DB_NAME=' . $name);
|
||||||
|
require __DIR__ . '/../bin/migrate-auth.php';
|
||||||
|
// Idempotent migration is verified separately by the HTTP test runner.
|
||||||
|
echo json_encode(['database' => $name]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$db->exec("USE `{$name}`");
|
||||||
|
if ($action === 'revoke') {
|
||||||
|
$db->exec("UPDATE users SET user_auth_version = user_auth_version + 1 WHERE user_name = 'httpuser'");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($action === 'token') {
|
||||||
|
$id = $db->query("SELECT user_serial FROM users WHERE user_name = 'httpuser'")->fetchColumn();
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
|
||||||
|
$stmt->execute([hash('sha256', $token), $id]);
|
||||||
|
echo json_encode(['token' => $token]);
|
||||||
|
}
|
||||||
+2
-2
@@ -277,7 +277,7 @@ class InstalledVersions
|
|||||||
if (null === self::$installed) {
|
if (null === self::$installed) {
|
||||||
// only require the installed.php file if this file is loaded from its dumped location,
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
self::$installed = include __DIR__ . '/installed.php';
|
self::$installed = include __DIR__ . '/installed.php';
|
||||||
} else {
|
} else {
|
||||||
self::$installed = array();
|
self::$installed = array();
|
||||||
@@ -378,7 +378,7 @@ class InstalledVersions
|
|||||||
if (null === self::$installed) {
|
if (null === self::$installed) {
|
||||||
// only require the installed.php file if this file is loaded from its dumped location,
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||||
$required = require __DIR__ . '/installed.php';
|
$required = require __DIR__ . '/installed.php';
|
||||||
self::$installed = $required;
|
self::$installed = $required;
|
||||||
|
|||||||
Vendored
+2
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
@@ -17,3 +18,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
|||||||
Vendored
-16
@@ -8,25 +8,9 @@ $baseDir = dirname($vendorDir);
|
|||||||
return array(
|
return array(
|
||||||
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
|
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
|
||||||
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
|
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
|
||||||
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
|
|
||||||
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
|
|
||||||
'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
|
'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
|
||||||
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
|
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
|
||||||
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
|
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
|
||||||
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
|
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
|
||||||
'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
|
'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
|
||||||
'Com\\Tecnick\\Unicode\\Data\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode-data/src'),
|
|
||||||
'Com\\Tecnick\\Unicode\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Sign\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-sign/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Parser\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-parser/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Page\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-page/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Image\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-image/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Graph\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-graph/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Font\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-font/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Filter\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-filter/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\Encrypt\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-encrypt/src'),
|
|
||||||
'Com\\Tecnick\\Pdf\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf/src'),
|
|
||||||
'Com\\Tecnick\\File\\' => array($vendorDir . '/tecnickcom/tc-lib-file/src'),
|
|
||||||
'Com\\Tecnick\\Color\\' => array($vendorDir . '/tecnickcom/tc-lib-color/src'),
|
|
||||||
'Com\\Tecnick\\Barcode\\' => array($vendorDir . '/tecnickcom/tc-lib-barcode/src'),
|
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
-81
@@ -14,8 +14,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
|
|||||||
'P' =>
|
'P' =>
|
||||||
array (
|
array (
|
||||||
'Psr\\SimpleCache\\' => 16,
|
'Psr\\SimpleCache\\' => 16,
|
||||||
'Psr\\Http\\Message\\' => 17,
|
|
||||||
'Psr\\Http\\Client\\' => 16,
|
|
||||||
'PhpOffice\\PhpSpreadsheet\\' => 25,
|
'PhpOffice\\PhpSpreadsheet\\' => 25,
|
||||||
'PHPMailer\\PHPMailer\\' => 20,
|
'PHPMailer\\PHPMailer\\' => 20,
|
||||||
),
|
),
|
||||||
@@ -27,20 +25,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
|
|||||||
array (
|
array (
|
||||||
'Composer\\Pcre\\' => 14,
|
'Composer\\Pcre\\' => 14,
|
||||||
'Complex\\' => 8,
|
'Complex\\' => 8,
|
||||||
'Com\\Tecnick\\Unicode\\Data\\' => 25,
|
|
||||||
'Com\\Tecnick\\Unicode\\' => 20,
|
|
||||||
'Com\\Tecnick\\Pdf\\Sign\\' => 21,
|
|
||||||
'Com\\Tecnick\\Pdf\\Parser\\' => 23,
|
|
||||||
'Com\\Tecnick\\Pdf\\Page\\' => 21,
|
|
||||||
'Com\\Tecnick\\Pdf\\Image\\' => 22,
|
|
||||||
'Com\\Tecnick\\Pdf\\Graph\\' => 22,
|
|
||||||
'Com\\Tecnick\\Pdf\\Font\\' => 21,
|
|
||||||
'Com\\Tecnick\\Pdf\\Filter\\' => 23,
|
|
||||||
'Com\\Tecnick\\Pdf\\Encrypt\\' => 24,
|
|
||||||
'Com\\Tecnick\\Pdf\\' => 16,
|
|
||||||
'Com\\Tecnick\\File\\' => 17,
|
|
||||||
'Com\\Tecnick\\Color\\' => 18,
|
|
||||||
'Com\\Tecnick\\Barcode\\' => 20,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,15 +37,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
|
|||||||
array (
|
array (
|
||||||
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
|
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
|
||||||
),
|
),
|
||||||
'Psr\\Http\\Message\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/psr/http-factory/src',
|
|
||||||
1 => __DIR__ . '/..' . '/psr/http-message/src',
|
|
||||||
),
|
|
||||||
'Psr\\Http\\Client\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/psr/http-client/src',
|
|
||||||
),
|
|
||||||
'PhpOffice\\PhpSpreadsheet\\' =>
|
'PhpOffice\\PhpSpreadsheet\\' =>
|
||||||
array (
|
array (
|
||||||
0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
|
0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
|
||||||
@@ -82,62 +57,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
|
|||||||
array (
|
array (
|
||||||
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
|
0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
|
||||||
),
|
),
|
||||||
'Com\\Tecnick\\Unicode\\Data\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode-data/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Unicode\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Sign\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-sign/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Parser\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-parser/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Page\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-page/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Image\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-image/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Graph\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-graph/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Font\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-font/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Filter\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-filter/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\Encrypt\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-encrypt/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Pdf\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\File\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-file/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Color\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-color/src',
|
|
||||||
),
|
|
||||||
'Com\\Tecnick\\Barcode\\' =>
|
|
||||||
array (
|
|
||||||
0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-barcode/src',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public static $classMap = array (
|
public static $classMap = array (
|
||||||
|
|||||||
Vendored
+38
-1204
File diff suppressed because it is too large
Load Diff
Vendored
+15
-168
@@ -1,9 +1,9 @@
|
|||||||
<?php return array(
|
<?php return array(
|
||||||
'root' => array(
|
'root' => array(
|
||||||
'name' => '__root__',
|
'name' => '__root__',
|
||||||
'pretty_version' => '1.0.0+no-version-set',
|
'pretty_version' => 'dev-development',
|
||||||
'version' => '1.0.0.0',
|
'version' => 'dev-development',
|
||||||
'reference' => null,
|
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/../../',
|
'install_path' => __DIR__ . '/../../',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
@@ -11,27 +11,27 @@
|
|||||||
),
|
),
|
||||||
'versions' => array(
|
'versions' => array(
|
||||||
'__root__' => array(
|
'__root__' => array(
|
||||||
'pretty_version' => '1.0.0+no-version-set',
|
'pretty_version' => 'dev-development',
|
||||||
'version' => '1.0.0.0',
|
'version' => 'dev-development',
|
||||||
'reference' => null,
|
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/../../',
|
'install_path' => __DIR__ . '/../../',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'composer/pcre' => array(
|
'composer/pcre' => array(
|
||||||
'pretty_version' => '3.3.2',
|
'pretty_version' => '3.4.0',
|
||||||
'version' => '3.3.2.0',
|
'version' => '3.4.0.0',
|
||||||
'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
|
'reference' => 'd5a341b3fb61f3001970940afb1d332968a183ed',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/./pcre',
|
'install_path' => __DIR__ . '/./pcre',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'maennchen/zipstream-php' => array(
|
'maennchen/zipstream-php' => array(
|
||||||
'pretty_version' => '3.2.0',
|
'pretty_version' => '3.2.2',
|
||||||
'version' => '3.2.0.0',
|
'version' => '3.2.2.0',
|
||||||
'reference' => '9712d8fa4cdf9240380b01eb4be55ad8dcf71416',
|
'reference' => '77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
|
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
@@ -65,41 +65,14 @@
|
|||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'phpoffice/phpspreadsheet' => array(
|
'phpoffice/phpspreadsheet' => array(
|
||||||
'pretty_version' => '5.2.0',
|
'pretty_version' => '5.10.0',
|
||||||
'version' => '5.2.0.0',
|
'version' => '5.10.0.0',
|
||||||
'reference' => '3b8994b3aac4b61018bc04fc8c441f4fd68c18eb',
|
'reference' => 'eb18727acf6b1f4cc67145a52ab04f9fd4c28d53',
|
||||||
'type' => 'library',
|
'type' => 'library',
|
||||||
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
|
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
|
||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'psr/http-client' => array(
|
|
||||||
'pretty_version' => '1.0.3',
|
|
||||||
'version' => '1.0.3.0',
|
|
||||||
'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../psr/http-client',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'psr/http-factory' => array(
|
|
||||||
'pretty_version' => '1.1.0',
|
|
||||||
'version' => '1.1.0.0',
|
|
||||||
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../psr/http-factory',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'psr/http-message' => array(
|
|
||||||
'pretty_version' => '2.0',
|
|
||||||
'version' => '2.0.0.0',
|
|
||||||
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../psr/http-message',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'psr/simple-cache' => array(
|
'psr/simple-cache' => array(
|
||||||
'pretty_version' => '3.0.0',
|
'pretty_version' => '3.0.0',
|
||||||
'version' => '3.0.0.0',
|
'version' => '3.0.0.0',
|
||||||
@@ -109,132 +82,6 @@
|
|||||||
'aliases' => array(),
|
'aliases' => array(),
|
||||||
'dev_requirement' => false,
|
'dev_requirement' => false,
|
||||||
),
|
),
|
||||||
'tecnickcom/tc-lib-barcode' => array(
|
|
||||||
'pretty_version' => '2.13.2',
|
|
||||||
'version' => '2.13.2.0',
|
|
||||||
'reference' => 'c7f3ddbc3df10259c59ddc3016ec421267e25e5d',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-barcode',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-color' => array(
|
|
||||||
'pretty_version' => '2.13.3',
|
|
||||||
'version' => '2.13.3.0',
|
|
||||||
'reference' => '36b0cb2215c446fed65842151d494f2b3081533c',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-color',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-file' => array(
|
|
||||||
'pretty_version' => '3.7.3',
|
|
||||||
'version' => '3.7.3.0',
|
|
||||||
'reference' => 'c582b6d493c26703e95809f6d3e938c79f88885f',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-file',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf' => array(
|
|
||||||
'pretty_version' => '8.68.3',
|
|
||||||
'version' => '8.68.3.0',
|
|
||||||
'reference' => '6b056239c0d9781319a4d009e8ca33d48e94e0ea',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-encrypt' => array(
|
|
||||||
'pretty_version' => '2.9.3',
|
|
||||||
'version' => '2.9.3.0',
|
|
||||||
'reference' => '9d188937a673af8adc6da1f104c44cf434f7b198',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-encrypt',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-filter' => array(
|
|
||||||
'pretty_version' => '2.10.3',
|
|
||||||
'version' => '2.10.3.0',
|
|
||||||
'reference' => '48270d50db69852ef58a22b5b034b8a08e5c8464',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-filter',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-font' => array(
|
|
||||||
'pretty_version' => '3.13.2',
|
|
||||||
'version' => '3.13.2.0',
|
|
||||||
'reference' => '314dfbe240057e1119d4830f35827455f8a76eba',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-font',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-graph' => array(
|
|
||||||
'pretty_version' => '2.15.2',
|
|
||||||
'version' => '2.15.2.0',
|
|
||||||
'reference' => '4a3408690c65e14b9c86abe97a12c3079eb8a0cd',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-graph',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-image' => array(
|
|
||||||
'pretty_version' => '3.12.2',
|
|
||||||
'version' => '3.12.2.0',
|
|
||||||
'reference' => 'd7052a7e9216e842bd77395f1dfb443e9bfbcef8',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-image',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-page' => array(
|
|
||||||
'pretty_version' => '4.14.2',
|
|
||||||
'version' => '4.14.2.0',
|
|
||||||
'reference' => 'ac9adaddf8624b5008da335914a42a318cd7d310',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-page',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-parser' => array(
|
|
||||||
'pretty_version' => '3.14.2',
|
|
||||||
'version' => '3.14.2.0',
|
|
||||||
'reference' => 'db9c32046a3e8d57c8626e8d3ffc94caa10a0675',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-parser',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-pdf-sign' => array(
|
|
||||||
'pretty_version' => '1.1.3',
|
|
||||||
'version' => '1.1.3.0',
|
|
||||||
'reference' => '4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-sign',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-unicode' => array(
|
|
||||||
'pretty_version' => '3.0.2',
|
|
||||||
'version' => '3.0.2.0',
|
|
||||||
'reference' => 'e0549f1e0bee657a7a5c361d823409655a4ea2c3',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tc-lib-unicode-data' => array(
|
|
||||||
'pretty_version' => '3.0.2',
|
|
||||||
'version' => '3.0.2.0',
|
|
||||||
'reference' => '6b090f51613087c2ed6e576e9543c1feddf2ba51',
|
|
||||||
'type' => 'library',
|
|
||||||
'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode-data',
|
|
||||||
'aliases' => array(),
|
|
||||||
'dev_requirement' => false,
|
|
||||||
),
|
|
||||||
'tecnickcom/tcpdf' => array(
|
'tecnickcom/tcpdf' => array(
|
||||||
'pretty_version' => '6.10.0',
|
'pretty_version' => '6.10.0',
|
||||||
'version' => '6.10.0.0',
|
'version' => '6.10.0.0',
|
||||||
|
|||||||
Vendored
+9
-5
@@ -20,12 +20,13 @@
|
|||||||
"php": "^7.4 || ^8.0"
|
"php": "^7.4 || ^8.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^8 || ^9",
|
"phpunit/phpunit": "^9",
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
"phpstan/phpstan": "^2",
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2"
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"phpstan/phpstan": "<1.11.10"
|
"phpstan/phpstan": "<2.2.2"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -48,7 +49,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "@php vendor/bin/phpunit",
|
"test": [
|
||||||
|
"@php vendor/bin/phpunit",
|
||||||
|
"@php vendor/bin/phpunit --testsuite phpstan"
|
||||||
|
],
|
||||||
"phpstan": "@php phpstan analyse"
|
"phpstan": "@php phpstan analyse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,18 +57,38 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
|
|||||||
{
|
{
|
||||||
$args = $node->getArgs();
|
$args = $node->getArgs();
|
||||||
$patternArg = $args[0] ?? null;
|
$patternArg = $args[0] ?? null;
|
||||||
|
$subjectArg = $args[1] ?? null;
|
||||||
$matchesArg = $args[2] ?? null;
|
$matchesArg = $args[2] ?? null;
|
||||||
$flagsArg = $args[3] ?? null;
|
$flagsArg = $args[3] ?? null;
|
||||||
|
|
||||||
|
$subjectTypes = new SpecifiedTypes();
|
||||||
|
if ($patternArg === null) {
|
||||||
|
return $subjectTypes;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$patternArg === null || $matchesArg === null
|
$subjectArg !== null
|
||||||
|
&& $context->true()
|
||||||
|
&& $scope->getType($subjectArg->value)->isString()->yes()
|
||||||
) {
|
) {
|
||||||
return new SpecifiedTypes();
|
$subjectType = $this->regexShapeMatcher->matchSubjectExpr($patternArg->value, $scope);
|
||||||
|
if ($subjectType !== null) {
|
||||||
|
$subjectTypes = $this->typeSpecifier->create(
|
||||||
|
$subjectArg->value,
|
||||||
|
$subjectType,
|
||||||
|
$context,
|
||||||
|
$scope,
|
||||||
|
)->setRootExpr($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matchesArg === null) {
|
||||||
|
return $subjectTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
$flagsType = PregMatchFlags::getType($flagsArg, $scope);
|
||||||
if ($flagsType === null) {
|
if ($flagsType === null) {
|
||||||
return new SpecifiedTypes();
|
return $subjectTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
|
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
|
||||||
@@ -78,7 +98,7 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($matchedType === null) {
|
if ($matchedType === null) {
|
||||||
return new SpecifiedTypes();
|
return $subjectTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -93,27 +113,13 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
|
|||||||
$context = $context->negate();
|
$context = $context->negate();
|
||||||
}
|
}
|
||||||
|
|
||||||
// @phpstan-ignore function.alreadyNarrowedType
|
$specifiedTypes = $this->typeSpecifier->create(
|
||||||
if (method_exists('PHPStan\Analyser\SpecifiedTypes', 'setRootExpr')) {
|
|
||||||
$typeSpecifier = $this->typeSpecifier->create(
|
|
||||||
$matchesArg->value,
|
$matchesArg->value,
|
||||||
$matchedType,
|
$matchedType,
|
||||||
$context,
|
$context,
|
||||||
$scope
|
$scope
|
||||||
)->setRootExpr($node);
|
)->setRootExpr($node);
|
||||||
|
|
||||||
return $overwrite ? $typeSpecifier->setAlwaysOverwriteTypes() : $typeSpecifier;
|
return $subjectTypes->unionWith($overwrite ? $specifiedTypes->setAlwaysOverwriteTypes() : $specifiedTypes);
|
||||||
}
|
|
||||||
|
|
||||||
// @phpstan-ignore arguments.count
|
|
||||||
return $this->typeSpecifier->create(
|
|
||||||
$matchesArg->value,
|
|
||||||
$matchedType,
|
|
||||||
$context,
|
|
||||||
// @phpstan-ignore argument.type
|
|
||||||
$overwrite,
|
|
||||||
$scope,
|
|
||||||
$node
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-2
@@ -395,7 +395,7 @@ class Preg
|
|||||||
* @return array<int|string, string>
|
* @return array<int|string, string>
|
||||||
* @throws UnexpectedNullMatchException
|
* @throws UnexpectedNullMatchException
|
||||||
*/
|
*/
|
||||||
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
|
private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod): array
|
||||||
{
|
{
|
||||||
foreach ($matches as $group => $match) {
|
foreach ($matches as $group => $match) {
|
||||||
if (is_string($match) || (is_array($match) && is_string($match[0]))) {
|
if (is_string($match) || (is_array($match) && is_string($match[0]))) {
|
||||||
@@ -414,7 +414,7 @@ class Preg
|
|||||||
* @return array<int|string, list<string>>
|
* @return array<int|string, list<string>>
|
||||||
* @throws UnexpectedNullMatchException
|
* @throws UnexpectedNullMatchException
|
||||||
*/
|
*/
|
||||||
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
|
private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod): array
|
||||||
{
|
{
|
||||||
foreach ($matches as $group => $groupMatches) {
|
foreach ($matches as $group => $groupMatches) {
|
||||||
foreach ($groupMatches as $match) {
|
foreach ($groupMatches as $match) {
|
||||||
|
|||||||
@@ -71,4 +71,5 @@ return $config->setRules([
|
|||||||
])
|
])
|
||||||
->setFinder($finder)
|
->setFinder($finder)
|
||||||
->setRiskyAllowed(true)
|
->setRiskyAllowed(true)
|
||||||
|
->setUnsupportedPhpVersionAllowed(true)
|
||||||
->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
|
->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
php 8.4.10
|
php 8.5.0
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"mikey179/vfsstream": "^1.6",
|
"mikey179/vfsstream": "^1.6",
|
||||||
"php-coveralls/php-coveralls": "^2.5",
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
"friendsofphp/php-cs-fixer": "^3.16",
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
"vimeo/psalm": "^6.0",
|
"vimeo/psalm": "^6.0",
|
||||||
"brianium/paratest": "^7.7"
|
"brianium/paratest": "^7.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
Compressing large files to S3 compatible storages
|
||||||
|
=============
|
||||||
|
|
||||||
|
S3 compatible storages usually have a limitation of 5 GiB when using single upload. When generating larger zips, the
|
||||||
|
solution is to use a `multi-part upload`_.
|
||||||
|
|
||||||
|
.. _multi-part upload: <https://github.com/awsdocs/aws-doc-sdk-examples/blob/75a8bf8536d436db91ec5de1ba0ed80fd258e904/php/example_code/s3/s3-multipart-upload-using-lowlevel-php-sdk-api.php>`
|
||||||
|
|
||||||
|
We can implement a `PSR-7 stream <https://www.php-fig.org/psr/psr-7/>`_ that buffers ZipStream's output and uploads
|
||||||
|
to S3 in chunks.
|
||||||
|
|
||||||
|
MultipartUploadBufferStream example
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Aws\S3\S3ClientInterface;
|
||||||
|
use Psr\Http\Message\StreamInterface;
|
||||||
|
|
||||||
|
use function strlen;
|
||||||
|
use function substr;
|
||||||
|
|
||||||
|
final class MultipartUploadBufferStream implements StreamInterface
|
||||||
|
{
|
||||||
|
private const int PART_SIZE = 5242880; // 5 MiB in bytes
|
||||||
|
|
||||||
|
private string $buffer = '';
|
||||||
|
private int $bufferSize = 0;
|
||||||
|
private int $partNumber = 1;
|
||||||
|
private array $parts = [];
|
||||||
|
|
||||||
|
private string $uploadId;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $destinationFileName,
|
||||||
|
private readonly string $bucket,
|
||||||
|
private readonly S3ClientInterface $client
|
||||||
|
) {
|
||||||
|
$result = $this->client->createMultipartUpload(
|
||||||
|
[
|
||||||
|
'Bucket' => $bucket,
|
||||||
|
'Key' => $destinationFileName,
|
||||||
|
'StorageClass' => 'REDUCED_REDUNDANCY',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->uploadId = $result['UploadId'];
|
||||||
|
|
||||||
|
$this->parts['Parts'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write($string): int
|
||||||
|
{
|
||||||
|
$chunkSize = strlen($string);
|
||||||
|
|
||||||
|
$this->buffer .= $string;
|
||||||
|
$this->bufferSize += $chunkSize;
|
||||||
|
|
||||||
|
if ($this->bufferSize >= self::PART_SIZE) {
|
||||||
|
$this->uploadPart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void
|
||||||
|
{
|
||||||
|
// Upload remaining closing bytes from zip
|
||||||
|
$this->uploadPart();
|
||||||
|
|
||||||
|
$this->client->completeMultipartUpload([
|
||||||
|
'Bucket' => $this->bucket,
|
||||||
|
'Key' => $this->destinationFileName,
|
||||||
|
'UploadId' => $this->uploadId,
|
||||||
|
'MultipartUpload' => $this->parts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploadPart(): void
|
||||||
|
{
|
||||||
|
$result = $this->client->uploadPart([
|
||||||
|
'Bucket' => $this->bucket,
|
||||||
|
'Key' => $this->destinationFileName,
|
||||||
|
'UploadId' => $this->uploadId,
|
||||||
|
'PartNumber' => $this->partNumber,
|
||||||
|
'Body' => $this->buffer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
|
||||||
|
$this->parts['Parts'][$this->partNumber] = [
|
||||||
|
'PartNumber' => $this->partNumber,
|
||||||
|
'ETag' => $result['ETag'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->partNumber++;
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
gc_collect_cycles(); // To avoid memory leaks. @see github.com/aws/aws-sdk-php/issues/1273
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->getContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContents(): string
|
||||||
|
{
|
||||||
|
$buffer = $this->buffer;
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
|
||||||
|
return $buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(): null
|
||||||
|
{
|
||||||
|
$this->close();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSize(): int
|
||||||
|
{
|
||||||
|
return $this->bufferSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReadable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isWritable(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSeekable(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rewind(): void
|
||||||
|
{
|
||||||
|
$this->seek(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function seek($offset, $whence = SEEK_SET): void
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Cannot seek a BufferStream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eof(): bool
|
||||||
|
{
|
||||||
|
return $this->bufferSize === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tell(): int
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Cannot determine the position of a BufferStream');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read($length): string
|
||||||
|
{
|
||||||
|
$currentLength = $this->bufferSize;
|
||||||
|
|
||||||
|
if ($length >= $currentLength) {
|
||||||
|
// No need to slice the buffer because we don't have enough data.
|
||||||
|
$result = $this->buffer;
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->bufferSize = 0;
|
||||||
|
} else {
|
||||||
|
// Slice up the result to provide a subset of the buffer.
|
||||||
|
$result = substr($this->buffer, 0, $length);
|
||||||
|
$this->buffer = substr($this->buffer, $length);
|
||||||
|
$this->bufferSize -= $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetadata($key = null)
|
||||||
|
{
|
||||||
|
return $key ? null : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Then, we can use it with ZipStream to compress the files and upload the parts to the storage.
|
||||||
|
|
||||||
|
ZipStream usage
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Aws\S3\S3Client;
|
||||||
|
use Aws\Credentials\CredentialProvider;
|
||||||
|
use MultipartUploadBufferStream;
|
||||||
|
use ZipStream\ZipStream;
|
||||||
|
|
||||||
|
$bucket = 'your bucket name';
|
||||||
|
$client = new S3Client([
|
||||||
|
'region' => 'your region',
|
||||||
|
'version' => 'latest',
|
||||||
|
'bucketName' => $bucket,
|
||||||
|
'credentials' => CredentialProvider::defaultProvider(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bufferStream = new MultipartUploadBufferStream(
|
||||||
|
'destination-file.zip',
|
||||||
|
$bucket,
|
||||||
|
$client,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip = new ZipStream(
|
||||||
|
outputStream: $destination,
|
||||||
|
defaultCompressionMethod: CompressionMethod::STORE,
|
||||||
|
defaultEnableZeroHeader: true,
|
||||||
|
sendHttpHeaders: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'big-file-1.txt',
|
||||||
|
data: 'File1 data',
|
||||||
|
);
|
||||||
|
$zip->addFile(
|
||||||
|
fileName: 'big-file-2.txt',
|
||||||
|
data: 'File2 data',
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->finish();
|
||||||
|
$destination->close(); // Needed after $zip->finish() to upload the last remaining bytes to S3
|
||||||
|
|
||||||
|
You can read more about the logic behind this implementation in the `discussion`_.
|
||||||
|
|
||||||
|
.. _discussion: https://github.com/maennchen/ZipStream-PHP/discussions/402
|
||||||
@@ -16,6 +16,7 @@ user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
|
|||||||
Nginx
|
Nginx
|
||||||
Varnish
|
Varnish
|
||||||
ContentLength
|
ContentLength
|
||||||
|
LargeFilesToS3
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
---------------
|
---------------
|
||||||
|
|||||||
+9
-9
@@ -110,8 +110,8 @@ final class File
|
|||||||
$detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
|
$detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$this->isSimulation() &&
|
$this->isSimulation()
|
||||||
$detectedSize !== null
|
&& $detectedSize !== null
|
||||||
) {
|
) {
|
||||||
$this->uncompressedSize = $detectedSize;
|
$this->uncompressedSize = $detectedSize;
|
||||||
$this->compressedSize = $detectedSize;
|
$this->compressedSize = $detectedSize;
|
||||||
@@ -202,10 +202,10 @@ final class File
|
|||||||
compressionMethod: $this->compressionMethod,
|
compressionMethod: $this->compressionMethod,
|
||||||
lastModificationDateTime: $this->lastModificationDateTime,
|
lastModificationDateTime: $this->lastModificationDateTime,
|
||||||
crc32UncompressedData: $this->crc,
|
crc32UncompressedData: $this->crc,
|
||||||
compressedSize: $zip64Enabled
|
compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
|
||||||
? 0xFFFFFFFF
|
? 0xFFFFFFFF
|
||||||
: $this->compressedSize,
|
: $this->compressedSize,
|
||||||
uncompressedSize: $zip64Enabled
|
uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
|
||||||
? 0xFFFFFFFF
|
? 0xFFFFFFFF
|
||||||
: $this->uncompressedSize,
|
: $this->uncompressedSize,
|
||||||
fileName: $this->fileName,
|
fileName: $this->fileName,
|
||||||
@@ -238,8 +238,8 @@ final class File
|
|||||||
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
|
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
|
||||||
// the filename and comment fields for this file
|
// the filename and comment fields for this file
|
||||||
// MUST be encoded using UTF-8. (see APPENDIX D)
|
// MUST be encoded using UTF-8. (see APPENDIX D)
|
||||||
if (mb_check_encoding($this->fileName, 'UTF-8') &&
|
if (mb_check_encoding($this->fileName, 'UTF-8')
|
||||||
mb_check_encoding($this->comment, 'UTF-8')) {
|
&& mb_check_encoding($this->comment, 'UTF-8')) {
|
||||||
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
|
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,9 +321,9 @@ final class File
|
|||||||
$deflate = $this->compressionInit();
|
$deflate = $this->compressionInit();
|
||||||
|
|
||||||
while (
|
while (
|
||||||
!feof($this->unpackStream()) &&
|
!feof($this->unpackStream())
|
||||||
($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
|
&& ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
|
||||||
($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
|
&& ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
|
||||||
) {
|
) {
|
||||||
$readLength = min(
|
$readLength = min(
|
||||||
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
|
||||||
|
|||||||
+6
-6
@@ -29,11 +29,11 @@ abstract class Time
|
|||||||
[$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
|
[$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
|
||||||
|
|
||||||
return
|
return
|
||||||
((int) $year << 25) |
|
((int) $year << 25)
|
||||||
((int) $month << 21) |
|
| ((int) $month << 21)
|
||||||
((int) $day << 16) |
|
| ((int) $day << 16)
|
||||||
((int) $hour << 11) |
|
| ((int) $hour << 11)
|
||||||
((int) $minute << 5) |
|
| ((int) $minute << 5)
|
||||||
((int) $second >> 1);
|
| ((int) $second >> 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ abstract class ExtendedInformationExtraField
|
|||||||
new PackField(format: 'v', value: self::TAG),
|
new PackField(format: 'v', value: self::TAG),
|
||||||
new PackField(
|
new PackField(
|
||||||
format: 'v',
|
format: 'v',
|
||||||
value: ($originalSize === null ? 0 : 8) +
|
value: ($originalSize === null ? 0 : 8)
|
||||||
($compressedSize === null ? 0 : 8) +
|
+ ($compressedSize === null ? 0 : 8)
|
||||||
($relativeHeaderOffset === null ? 0 : 8) +
|
+ ($relativeHeaderOffset === null ? 0 : 8)
|
||||||
($diskStartNumber === null ? 0 : 4)
|
+ ($diskStartNumber === null ? 0 : 4)
|
||||||
),
|
),
|
||||||
...($originalSize === null ? [] : [
|
...($originalSize === null ? [] : [
|
||||||
new PackField(format: 'P', value: $originalSize),
|
new PackField(format: 'P', value: $originalSize),
|
||||||
|
|||||||
+3
-3
@@ -714,9 +714,9 @@ class ZipStream
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add 64bit headers (if applicable)
|
// Add 64bit headers (if applicable)
|
||||||
if (count($this->centralDirectoryRecords) >= 0xFFFF ||
|
if (count($this->centralDirectoryRecords) >= 0xFFFF
|
||||||
$centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
|
|| $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF
|
||||||
$sizeOfCentralDirectory > 0xFFFFFFFF) {
|
|| $sizeOfCentralDirectory > 0xFFFFFFFF) {
|
||||||
if (!$this->enableZip64) {
|
if (!$this->enableZip64) {
|
||||||
throw new OverflowException();
|
throw new OverflowException();
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-6
@@ -24,7 +24,9 @@ final class CallbackOutputTest extends TestCase
|
|||||||
$buf = '';
|
$buf = '';
|
||||||
$zip = new ZipStream(
|
$zip = new ZipStream(
|
||||||
outputStream: CallbackStreamWrapper::open(
|
outputStream: CallbackStreamWrapper::open(
|
||||||
static function (string $chunk) use (&$buf): void { $buf .= $chunk; }
|
static function (string $chunk) use (&$buf): void {
|
||||||
|
$buf .= $chunk;
|
||||||
|
}
|
||||||
),
|
),
|
||||||
sendHttpHeaders: false
|
sendHttpHeaders: false
|
||||||
);
|
);
|
||||||
@@ -53,10 +55,14 @@ final class CallbackOutputTest extends TestCase
|
|||||||
$buf2 = '';
|
$buf2 = '';
|
||||||
|
|
||||||
$stream1 = CallbackStreamWrapper::open(
|
$stream1 = CallbackStreamWrapper::open(
|
||||||
static function (string $chunk) use (&$buf1): void { $buf1 .= $chunk; }
|
static function (string $chunk) use (&$buf1): void {
|
||||||
|
$buf1 .= $chunk;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
$stream2 = CallbackStreamWrapper::open(
|
$stream2 = CallbackStreamWrapper::open(
|
||||||
static function (string $chunk) use (&$buf2): void { $buf2 .= $chunk; }
|
static function (string $chunk) use (&$buf2): void {
|
||||||
|
$buf2 .= $chunk;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertIsResource($stream1);
|
$this->assertIsResource($stream1);
|
||||||
@@ -112,7 +118,8 @@ final class CallbackOutputTest extends TestCase
|
|||||||
public function testStreamPositionTracking(): void
|
public function testStreamPositionTracking(): void
|
||||||
{
|
{
|
||||||
$stream = CallbackStreamWrapper::open(
|
$stream = CallbackStreamWrapper::open(
|
||||||
static function (string $chunk): void { /* no-op */ }
|
static function (string $chunk): void { // no-op
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame(0, ftell($stream));
|
$this->assertSame(0, ftell($stream));
|
||||||
@@ -129,7 +136,8 @@ final class CallbackOutputTest extends TestCase
|
|||||||
public function testInvalidModeRejection(): void
|
public function testInvalidModeRejection(): void
|
||||||
{
|
{
|
||||||
$stream = CallbackStreamWrapper::open(
|
$stream = CallbackStreamWrapper::open(
|
||||||
static function (string $chunk): void { /* no-op */ }
|
static function (string $chunk): void { // no-op
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close the stream first
|
// Close the stream first
|
||||||
@@ -143,7 +151,8 @@ final class CallbackOutputTest extends TestCase
|
|||||||
public function testStreamStatistics(): void
|
public function testStreamStatistics(): void
|
||||||
{
|
{
|
||||||
$stream = CallbackStreamWrapper::open(
|
$stream = CallbackStreamWrapper::open(
|
||||||
static function (string $chunk): void { /* no-op */ }
|
static function (string $chunk): void { // no-op
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
fwrite($stream, 'test data');
|
fwrite($stream, 'test data');
|
||||||
|
|||||||
@@ -35,26 +35,26 @@ class CentralDirectoryFileHeaderTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($header),
|
bin2hex($header),
|
||||||
'504b0102' . // 4 bytes; central file header signature
|
'504b0102' // 4 bytes; central file header signature
|
||||||
'0306' . // 2 bytes; version made by
|
. '0306' // 2 bytes; version made by
|
||||||
'2d00' . // 2 bytes; version needed to extract
|
. '2d00' // 2 bytes; version needed to extract
|
||||||
'2222' . // 2 bytes; general purpose bit flag
|
. '2222' // 2 bytes; general purpose bit flag
|
||||||
'0800' . // 2 bytes; compression method
|
. '0800' // 2 bytes; compression method
|
||||||
'2008' . // 2 bytes; last mod file time
|
. '2008' // 2 bytes; last mod file time
|
||||||
'2154' . // 2 bytes; last mod file date
|
. '2154' // 2 bytes; last mod file date
|
||||||
'11111111' . // 4 bytes; crc-32
|
. '11111111' // 4 bytes; crc-32
|
||||||
'77777777' . // 4 bytes; compressed size
|
. '77777777' // 4 bytes; compressed size
|
||||||
'99999999' . // 4 bytes; uncompressed size
|
. '99999999' // 4 bytes; uncompressed size
|
||||||
'0800' . // 2 bytes; file name length (n)
|
. '0800' // 2 bytes; file name length (n)
|
||||||
'0c00' . // 2 bytes; extra field length (m)
|
. '0c00' // 2 bytes; extra field length (m)
|
||||||
'0c00' . // 2 bytes; file comment length (o)
|
. '0c00' // 2 bytes; file comment length (o)
|
||||||
'0000' . // 2 bytes; disk number start
|
. '0000' // 2 bytes; disk number start
|
||||||
'0000' . // 2 bytes; internal file attributes
|
. '0000' // 2 bytes; internal file attributes
|
||||||
'20000000' . // 4 bytes; external file attributes
|
. '20000000' // 4 bytes; external file attributes
|
||||||
'34120000' . // 4 bytes; relative offset of local header
|
. '34120000' // 4 bytes; relative offset of local header
|
||||||
'746573742e706e67' . // n bytes; file name
|
. '746573742e706e67' // n bytes; file name
|
||||||
'736f6d6520636f6e74656e74' . // m bytes; extra field
|
. '736f6d6520636f6e74656e74' // m bytes; extra field
|
||||||
'736f6d6520636f6d6d656e74' // o bytes; file comment
|
. '736f6d6520636f6d6d656e74' // o bytes; file comment
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class DataDescriptorTest extends TestCase
|
|||||||
compressedSize: 0x77777777,
|
compressedSize: 0x77777777,
|
||||||
uncompressedSize: 0x99999999,
|
uncompressedSize: 0x99999999,
|
||||||
)),
|
)),
|
||||||
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
|
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
|
||||||
'11111111' . // 4 bytes; CRC-32 of uncompressed data
|
. '11111111' // 4 bytes; CRC-32 of uncompressed data
|
||||||
'77777777' . // 4 bytes; Compressed size
|
. '77777777' // 4 bytes; Compressed size
|
||||||
'99999999' // 4 bytes; Uncompressed size
|
. '99999999' // 4 bytes; Uncompressed size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ class EndOfCentralDirectoryTest extends TestCase
|
|||||||
centralDirectoryStartOffsetOnDisk: 0x33,
|
centralDirectoryStartOffsetOnDisk: 0x33,
|
||||||
zipFileComment: 'foo',
|
zipFileComment: 'foo',
|
||||||
)),
|
)),
|
||||||
'504b0506' . // 4 bytes; end of central dir signature 0x06054b50
|
'504b0506' // 4 bytes; end of central dir signature 0x06054b50
|
||||||
'0000' . // 2 bytes; number of this disk
|
. '0000' // 2 bytes; number of this disk
|
||||||
'0000' . // 2 bytes; number of the disk with the start of the central directory
|
. '0000' // 2 bytes; number of the disk with the start of the central directory
|
||||||
'1000' . // 2 bytes; total number of entries in the central directory on this disk
|
. '1000' // 2 bytes; total number of entries in the central directory on this disk
|
||||||
'1000' . // 2 bytes; total number of entries in the central directory
|
. '1000' // 2 bytes; total number of entries in the central directory
|
||||||
'22000000' . // 4 bytes; size of the central directory
|
. '22000000' // 4 bytes; size of the central directory
|
||||||
'33000000' . // 4 bytes; offset of start of central directory with respect to the starting disk number
|
. '33000000' // 4 bytes; offset of start of central directory with respect to the starting disk number
|
||||||
'0300' . // 2 bytes; .ZIP file comment length
|
. '0300' // 2 bytes; .ZIP file comment length
|
||||||
bin2hex('foo')
|
. bin2hex('foo')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class EndlessCycleStream implements StreamInterface
|
|||||||
throw new RuntimeException('Infinite Stream!');
|
throw new RuntimeException('Infinite Stream!');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMetadata(?string $key = null): array|null
|
public function getMetadata(?string $key = null): ?array
|
||||||
{
|
{
|
||||||
return $key !== null ? null : [];
|
return $key !== null ? null : [];
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -29,19 +29,19 @@ class LocalFileHeaderTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex((string) $header),
|
bin2hex((string) $header),
|
||||||
'504b0304' . // 4 bytes; Local file header signature
|
'504b0304' // 4 bytes; Local file header signature
|
||||||
'2d00' . // 2 bytes; Version needed to extract (minimum)
|
. '2d00' // 2 bytes; Version needed to extract (minimum)
|
||||||
'2222' . // 2 bytes; General purpose bit flag
|
. '2222' // 2 bytes; General purpose bit flag
|
||||||
'0800' . // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
|
. '0800' // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
|
||||||
'2008' . // 2 bytes; File last modification time
|
. '2008' // 2 bytes; File last modification time
|
||||||
'2154' . // 2 bytes; File last modification date
|
. '2154' // 2 bytes; File last modification date
|
||||||
'11111111' . // 4 bytes; CRC-32 of uncompressed data
|
. '11111111' // 4 bytes; CRC-32 of uncompressed data
|
||||||
'77777777' . // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
|
. '77777777' // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
|
||||||
'99999999' . // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
|
. '99999999' // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
|
||||||
'0800' . // 2 bytes; File name length (n)
|
. '0800' // 2 bytes; File name length (n)
|
||||||
'0c00' . // 2 bytes; Extra field length (m)
|
. '0c00' // 2 bytes; Extra field length (m)
|
||||||
'746573742e706e67' . // n bytes; File name
|
. '746573742e706e67' // n bytes; File name
|
||||||
'736f6d6520636f6e74656e74' // m bytes; Extra field
|
. '736f6d6520636f6e74656e74' // m bytes; Extra field
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ namespace ZipStream\Test;
|
|||||||
|
|
||||||
trait Tempfile
|
trait Tempfile
|
||||||
{
|
{
|
||||||
protected string|null $tempfile;
|
protected ?string $tempfile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var resource
|
* @var resource
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ class DataDescriptorTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($descriptor),
|
bin2hex($descriptor),
|
||||||
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
|
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
|
||||||
'11111111' . // 4 bytes; CRC-32 of uncompressed data
|
. '11111111' // 4 bytes; CRC-32 of uncompressed data
|
||||||
'6666666677777777' . // 8 bytes; Compressed size
|
. '6666666677777777' // 8 bytes; Compressed size
|
||||||
'8888888899999999' // 8 bytes; Uncompressed size
|
. '8888888899999999' // 8 bytes; Uncompressed size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -19,10 +19,10 @@ class EndOfCentralDirectoryLocatorTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($descriptor),
|
bin2hex($descriptor),
|
||||||
'504b0607' . // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
|
'504b0607' // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
|
||||||
'11111111' . // 4 bytes; number of the disk with the start of the zip64 end of central directory
|
. '11111111' // 4 bytes; number of the disk with the start of the zip64 end of central directory
|
||||||
'3333333322222222' . // 28 bytes; relative offset of the zip64 end of central directory record
|
. '3333333322222222' // 28 bytes; relative offset of the zip64 end of central directory record
|
||||||
'44444444' // 4 bytes;total number of disks
|
. '44444444' // 4 bytes;total number of disks
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ class EndOfCentralDirectoryTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($descriptor),
|
bin2hex($descriptor),
|
||||||
'504b0606' . // 4 bytes;zip64 end of central dir signature - 0x06064b50
|
'504b0606' // 4 bytes;zip64 end of central dir signature - 0x06064b50
|
||||||
'2f00000000000000' . // 8 bytes; size of zip64 end of central directory record
|
. '2f00000000000000' // 8 bytes; size of zip64 end of central directory record
|
||||||
'3333' . // 2 bytes; version made by
|
. '3333' // 2 bytes; version made by
|
||||||
'4444' . // 2 bytes; version needed to extract
|
. '4444' // 2 bytes; version needed to extract
|
||||||
'55555555' . // 4 bytes; number of this disk
|
. '55555555' // 4 bytes; number of this disk
|
||||||
'66666666' . // 4 bytes; number of the disk with the start of the central directory
|
. '66666666' // 4 bytes; number of the disk with the start of the central directory
|
||||||
'8888888877777777' . // 8 bytes; total number of entries in the central directory on this disk
|
. '8888888877777777' // 8 bytes; total number of entries in the central directory on this disk
|
||||||
'aaaaaaaa99999999' . // 8 bytes; total number of entries in the central directory
|
. 'aaaaaaaa99999999' // 8 bytes; total number of entries in the central directory
|
||||||
'ccccccccbbbbbbbb' . // 8 bytes; size of the central directory
|
. 'ccccccccbbbbbbbb' // 8 bytes; size of the central directory
|
||||||
'eeeeeeeedddddddd' . // 8 bytes; offset of start of central directory with respect to the starting disk number
|
. 'eeeeeeeedddddddd' // 8 bytes; offset of start of central directory with respect to the starting disk number
|
||||||
bin2hex('foo')
|
. bin2hex('foo')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -20,12 +20,12 @@ class ExtendedInformationExtraFieldTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($extraField),
|
bin2hex($extraField),
|
||||||
'0100' . // 2 bytes; Tag for this "extra" block type
|
'0100' // 2 bytes; Tag for this "extra" block type
|
||||||
'1c00' . // 2 bytes; Size of this "extra" block
|
. '1c00' // 2 bytes; Size of this "extra" block
|
||||||
'6666666677777777' . // 8 bytes; Original uncompressed file size
|
. '6666666677777777' // 8 bytes; Original uncompressed file size
|
||||||
'8888888899999999' . // 8 bytes; Size of compressed data
|
. '8888888899999999' // 8 bytes; Size of compressed data
|
||||||
'1111111122222222' . // 8 bytes; Offset of local header record
|
. '1111111122222222' // 8 bytes; Offset of local header record
|
||||||
'33333333' // 4 bytes; Number of the disk on which this file starts
|
. '33333333' // 4 bytes; Number of the disk on which this file starts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex($extraField),
|
bin2hex($extraField),
|
||||||
'0100' . // 2 bytes; Tag for this "extra" block type
|
'0100' // 2 bytes; Tag for this "extra" block type
|
||||||
'0000' // 2 bytes; Size of this "extra" block
|
. '0000' // 2 bytes; Size of this "extra" block
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-9
@@ -62,10 +62,10 @@ class ZipStreamTest extends TestCase
|
|||||||
|
|
||||||
$name = 'árvíztűrő tükörfúrógép.txt';
|
$name = 'árvíztűrő tükörfúrógép.txt';
|
||||||
$content = 'Sample String Data';
|
$content = 'Sample String Data';
|
||||||
$comment =
|
$comment
|
||||||
'Filename has every special characters ' .
|
= 'Filename has every special characters '
|
||||||
'from Hungarian language in lowercase. ' .
|
. 'from Hungarian language in lowercase. '
|
||||||
'In uppercase: ÁÍŰŐÜÖÚÓÉ';
|
. 'In uppercase: ÁÍŰŐÜÖÚÓÉ';
|
||||||
|
|
||||||
$zip->addFile(fileName: $name, data: $content, comment: $comment);
|
$zip->addFile(fileName: $name, data: $content, comment: $comment);
|
||||||
$zip->finish();
|
$zip->finish();
|
||||||
@@ -327,11 +327,14 @@ class ZipStreamTest extends TestCase
|
|||||||
defaultEnableZeroHeader: false,
|
defaultEnableZeroHeader: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (file_exists('/dev/null')) {
|
foreach (['cat', 'more'] as $cmd) {
|
||||||
$streamUnseekable = fopen('/dev/null', 'w+');
|
$streamUnseekable = @popen($cmd, 'w');
|
||||||
} elseif (file_exists('NUL')) {
|
if ($streamUnseekable !== false) {
|
||||||
$streamUnseekable = fopen('NUL', 'w+');
|
break;
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($streamUnseekable === false) {
|
||||||
$this->markTestSkipped('Needs file /dev/null');
|
$this->markTestSkipped('Needs file /dev/null');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,12 +549,24 @@ class ZipStreamTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$zip->addDirectory('mydir/');
|
||||||
|
|
||||||
$zip->finish();
|
$zip->finish();
|
||||||
|
|
||||||
$tmpDir = $this->validateAndExtractZip($this->tempfile);
|
$tmpDir = $this->validateAndExtractZip($this->tempfile);
|
||||||
|
|
||||||
$files = $this->getRecursiveFileList($tmpDir);
|
$files = $this->getRecursiveFileList($tmpDir);
|
||||||
$this->assertSame(['sample0', 'sample1', 'sample2', 'sample3'], $files);
|
$this->assertSame(['sample0', 'sample1', 'sample2', 'sample3'], $files);
|
||||||
|
|
||||||
|
$this->assertFileDoesNotContain(
|
||||||
|
$this->tempfile,
|
||||||
|
PackField::pack(
|
||||||
|
new PackField(format: 'V', value: 0x00000000), // CRC-32 = 0 (directory)
|
||||||
|
new PackField(format: 'V', value: 0xFFFFFFFF), // compressedSize — bug sentinel
|
||||||
|
new PackField(format: 'V', value: 0xFFFFFFFF), // uncompressedSize — bug sentinel
|
||||||
|
new PackField(format: 'v', value: 6), // filename length = strlen('mydir/')
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Group('slow')]
|
#[Group('slow')]
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
bin2hex((string) $extraField),
|
bin2hex((string) $extraField),
|
||||||
'5356' . // 2 bytes; Tag for this "extra" block type
|
'5356' // 2 bytes; Tag for this "extra" block type
|
||||||
'0000' // 2 bytes; TODO: Document
|
. '0000' // 2 bytes; TODO: Document
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-218
@@ -3,9 +3,13 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com)
|
The format is based on [Keep a Changelog](https://keepachangelog.com)
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org). Thia is always true of the master branch. Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
|
and this project adheres to [Semantic Versioning](https://semver.org). This is always true of the master branch.
|
||||||
|
Dropping support for EOL versions of PHP will not be considered
|
||||||
|
a breaking change.
|
||||||
|
|
||||||
## TBD - 5.3.0
|
Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
|
||||||
|
|
||||||
|
## TBD - 5.11.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -31,6 +35,210 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
- Nothing yet.
|
- Nothing yet.
|
||||||
|
|
||||||
|
## 2026-09-15 - 5.10.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- XLSX password-to-open encryption and decryption using Office Agile encryption. [Issue #3878](https://github.com/PHPOffice/PhpSpreadsheet/issues/3878) [PR #4975](https://github.com/PHPOffice/PhpSpreadsheet/pull/4975) [PR #4988](https://github.com/PHPOffice/PhpSpreadsheet/pull/4988)
|
||||||
|
- Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941)
|
||||||
|
- Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Performance: avoid `Worksheet::getStyle()` on every `Cell::setValueExplicit()` unless quote-prefix must change. On a dense 40k-cell populate+save microbenchmark this cut wall time by ~5%. [PR #4958](https://github.com/PHPOffice/PhpSpreadsheet/pull/4958)
|
||||||
|
- Infrastructure changes for documentation. [PR #4973](https://github.com/PHPOffice/PhpSpreadsheet/pull/4973)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Security patches.
|
||||||
|
- Xls Writer now creates conformant CFB/DIFAT metadata at FAT-sector boundaries. [Issue #4811](https://github.com/PHPOffice/PhpSpreadsheet/issues/4811) [PR #4983](https://github.com/PHPOffice/PhpSpreadsheet/pull/4983)
|
||||||
|
- Ods Reader/Writer Drawings. [Issue #4809](https://github.com/PHPOffice/PhpSpreadsheet/issues/4809) [PR #4956](https://github.com/PHPOffice/PhpSpreadsheet/pull/4956)
|
||||||
|
- Ods Writer no longer converts cell references or commas inside string literals, so `="THIS IS E1"` is written unchanged instead of as `="THIS IS [.E1]"`. [Issue #4454](https://github.com/PHPOffice/PhpSpreadsheet/issues/4454) [PR #4962](https://github.com/PHPOffice/PhpSpreadsheet/pull/4962)
|
||||||
|
- Ods Reader/Writer slight improvement for Date styles. [PR #4960](https://github.com/PHPOffice/PhpSpreadsheet/pull/4960)
|
||||||
|
- BETAINV/BETA.INV no longer abandons its search when the Beta CDF underflows to zero (wrong results for alpha above about 1080). [PR #4954](https://github.com/PHPOffice/PhpSpreadsheet/pull/4954)
|
||||||
|
- GAMMA.INV, the GAMMA.DIST/CHISQ.DIST/F.DIST densities, and GAMMALN no longer fail or return wrong results for large shape parameters / degrees of freedom. [PR #4953](https://github.com/PHPOffice/PhpSpreadsheet/pull/4953)
|
||||||
|
- GAMMAINV/GAMMA.INV no longer clamps upper-tail quantiles beyond alpha*beta*5. [PR #4946](https://github.com/PHPOffice/PhpSpreadsheet/pull/4946)
|
||||||
|
- Correct incomplete gamma convergence for GAMMA.DIST/CHISQ.DIST family (wrong once the series argument reached ~32). [PR #4945](https://github.com/PHPOffice/PhpSpreadsheet/pull/4945)
|
||||||
|
- Fix problem with VLOOKUP and whole-column ranges. [Issue #4969](https://github.com/PHPOffice/PhpSpreadsheet/issues/4969) [PR #4967](https://github.com/PHPOffice/PhpSpreadsheet/pull/4967)
|
||||||
|
- Problem with Xlsx Writer and header-row table. [PR #4968](https://github.com/PHPOffice/PhpSpreadsheet/pull/4968)
|
||||||
|
- Throw when Ods Reader encounters invalid Xml. [PR #4986](https://github.com/PHPOffice/PhpSpreadsheet/pull/4986)
|
||||||
|
- Update Sheetname in Charts when Sheetname changes. [Issue #744](https://github.com/PHPOffice/PhpSpreadsheet/issues/744) [PR #4984](https://github.com/PHPOffice/PhpSpreadsheet/pull/4984)
|
||||||
|
|
||||||
|
## 2026-07-12 - 5.9.0
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Drop support for PHP 8.1**, according to [our published guidelines](https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support). [PR #4907](https://github.com/PHPOffice/PhpSpreadsheet/pull/4907)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Navigate Cell as Cursor. [Issue #863](https://github.com/PHPOffice/PhpSpreadsheet/issues/863) [PR #4909](https://github.com/PHPOffice/PhpSpreadsheet/pull/4909)
|
||||||
|
- Chart DataTables. [Issue #413](https://github.com/PHPOffice/PhpSpreadsheet/issues/413) [PR #4911](https://github.com/PHPOffice/PhpSpreadsheet/pull/4911)
|
||||||
|
- Permit Ignoring "Misleading Format" Tag. [PR #4914](https://github.com/PHPOffice/PhpSpreadsheet/pull/4914)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restructure parsing logic in Reader/Xlsx. [PR #4830](https://github.com/PHPOffice/PhpSpreadsheet/pull/4830)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Small improvement for Radar Charts. [Issue #661](https://github.com/PHPOffice/PhpSpreadsheet/issues/661) [PR #4908](https://github.com/PHPOffice/PhpSpreadsheet/pull/4908)
|
||||||
|
- Allow Use of Multiple Ranges When Setting Styles. [Issue #411](https://github.com/PHPOffice/PhpSpreadsheet/issues/411) [PR #4910](https://github.com/PHPOffice/PhpSpreadsheet/pull/4910)
|
||||||
|
- Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912)
|
||||||
|
- Avoid hard-coding some chart Xml attributes. [PR #4915](https://github.com/PHPOffice/PhpSpreadsheet/pull/4915)
|
||||||
|
- Readers should directly access readFilter. [PR #4919](https://github.com/PHPOffice/PhpSpreadsheet/pull/4919)
|
||||||
|
- Very minor changes to Worksheet, Reader/Xlsx, and Worksheet/AutoFilter. [Issue #4917](https://github.com/PHPOffice/PhpSpreadsheet/issues/4917) [PR #4926](https://github.com/PHPOffice/PhpSpreadsheet/pull/4926)
|
||||||
|
- Consistent HighestRow/Column after row/columnDelete. [Issue #943](https://github.com/PHPOffice/PhpSpreadsheet/issues/943) [PR #4925](https://github.com/PHPOffice/PhpSpreadsheet/pull/4925)
|
||||||
|
|
||||||
|
## 2026-06-06 - 5.8.0
|
||||||
|
|
||||||
|
### Security Note
|
||||||
|
|
||||||
|
- File::prohibitWrappers and Drawing::setPath now reject phar paths with extra leading slashes (e.g. phar:///…) that escaped the prior parse_url-based filter. No security exploit was possible even with the extra slashes. [PR #4876](https://github.com/PHPOffice/PhpSpreadsheet/pull/4876)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional method to increase Calculation Engine's parsing speed. [PR #4829](https://github.com/PHPOffice/PhpSpreadsheet/pull/4829)
|
||||||
|
- Html Writer/Reader new data attributes. [PR #4858](https://github.com/PHPOffice/PhpSpreadsheet/pull/4858)
|
||||||
|
- IReader2 interface extending IReader with listWorksheetInfo/Names. [Issue #4883](https://github.com/PHPOffice/PhpSpreadsheet/issues/4883) [PR #4886](https://github.com/PHPOffice/PhpSpreadsheet/pull/4886)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Helper/Handler is now internal and final. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Html Writer handle text colors a bit better. [PR #4855](https://github.com/PHPOffice/PhpSpreadsheet/pull/4855)
|
||||||
|
- Html Writer apply rotation to images. [Issue #4875](https://github.com/PHPOffice/PhpSpreadsheet/issues/4875) [PR #4877](https://github.com/PHPOffice/PhpSpreadsheet/pull/4877)
|
||||||
|
- Work around Php8.6 deprecation in mpdf/mpdf. [PR #4878](https://github.com/PHPOffice/PhpSpreadsheet/pull/4878)
|
||||||
|
- Ability to reuse disconnected spreadsheet. [PR #4880](https://github.com/PHPOffice/PhpSpreadsheet/pull/4880)
|
||||||
|
- Allow int parameter for getChartIndex. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
|
||||||
|
|
||||||
|
## 2026-04-19 - 5.7.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Security patches.
|
||||||
|
|
||||||
|
## 2026-04-09 - 5.6.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Make Reader/Csv Extendable, add new preferred Reader/CsvNoEscape class. [Issue #4836](https://github.com/PHPOffice/PhpSpreadsheet/issues/4836) [PR #4837](https://github.com/PHPOffice/PhpSpreadsheet/pull/4837) [PR #4845](https://github.com/PHPOffice/PhpSpreadsheet/pull/4845)
|
||||||
|
- XLOOKUP function. [Issue #1453](https://github.com/PHPOffice/PhpSpreadsheet/issues/1453) [PR #4844](https://github.com/PHPOffice/PhpSpreadsheet/pull/4844)
|
||||||
|
- Introduction of a benchmark test suite, independent of the default unit test suite. Users can use it as a template for experimenting and making decisions concerning performance. [PR #4824](https://github.com/PHPOffice/PhpSpreadsheet/pull/4824)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Collection/Cells::MAX_COLUMN_ID - use Cell/AddressRange::MAX_COLUMN_INT.
|
||||||
|
- Writer/Xls/Worksheet constants MAX_XLS_COLUMN, MAX_XLS_COLUMN_STRING, MAX_XLS_ROW - use Cell/AddressRange MAX_COLUMN_INT_XLS, MAX_COLUMN_XLS, MAX_ROW_XLS
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Consistent handling of row and column limits. [PR #4820](https://github.com/PHPOffice/PhpSpreadsheet/pull/4820)
|
||||||
|
- Problems with Html Conditional Formatting Colorscale. [Issue #4838](https://github.com/PHPOffice/PhpSpreadsheet/issues/4838) [PR #4839](https://github.com/PHPOffice/PhpSpreadsheet/pull/4839)
|
||||||
|
- Fixed an issue where Date detection could misclassify invalid numeric values as dates. [PR #4841](https://github.com/PHPOffice/PhpSpreadsheet/pull/4841)
|
||||||
|
- Ods Reader/Writer Integer Styles with Leading Zeros. [Issue #1606](https://github.com/PHPOffice/PhpSpreadsheet/issues/1606) [PR #4822](https://github.com/PHPOffice/PhpSpreadsheet/pull/4822)
|
||||||
|
- Xlsx Writer Data URI for Images. [Issue #4823](https://github.com/PHPOffice/PhpSpreadsheet/issues/4823) [PR #4831](https://github.com/PHPOffice/PhpSpreadsheet/pull/4831)
|
||||||
|
- Confusion Checking for Union Arguments. [Issue #4832](https://github.com/PHPOffice/PhpSpreadsheet/issues/4832) [PR #4835](https://github.com/PHPOffice/PhpSpreadsheet/pull/4835)
|
||||||
|
- Reader Xlsx Hyperlink with Anchor. [Issue #4842](https://github.com/PHPOffice/PhpSpreadsheet/issues/4842) [PR #4843](https://github.com/PHPOffice/PhpSpreadsheet/pull/4843)
|
||||||
|
|
||||||
|
## 2026-02-28 - 5.5.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Much improved handling of Styles by Ods Reader. The relevant changes are listed at the end of the "Fixed" section below.
|
||||||
|
- Option to use OldCalculatedValue in ToArray and Relatives. [Issue #1810](https://github.com/PHPOffice/PhpSpreadsheet/issues/1810) [PR #4787](https://github.com/PHPOffice/PhpSpreadsheet/pull/4787)
|
||||||
|
- Add checkbox style (Xlsx and Html). [PR #4781](https://github.com/PHPOffice/PhpSpreadsheet/pull/4781)
|
||||||
|
- Option to whitelist external images. [PR #4793](https://github.com/PHPOffice/PhpSpreadsheet/pull/4793)
|
||||||
|
- Writer/Html add ability to set line ending. [PR #4779](https://github.com/PHPOffice/PhpSpreadsheet/pull/4779)
|
||||||
|
- Writer/Html optionally save formulas as data attributes. [PR #4783](https://github.com/PHPOffice/PhpSpreadsheet/pull/4783)
|
||||||
|
- User-supplied headers and footers in PDF. [Issue #3159](https://github.com/PHPOffice/PhpSpreadsheet/issues/3159) [PR #4789](https://github.com/PHPOffice/PhpSpreadsheet/pull/4789)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Writer/Html constant BODY_LINE no longer makes sense with a configurable line ending. No replacement.
|
||||||
|
- Calculation classes FormulaParser and FormulaToken are unused. No replacement.
|
||||||
|
- Writer/Xls/Worksheet methods insertBitMap, positionImage, writeObjPicture, processBitmapGd, and processBitmap are unused. No replacement.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improve performance in value binders. [PR #4780](https://github.com/PHPOffice/PhpSpreadsheet/pull/4780)
|
||||||
|
- Handle Unions as Function Arguments. [Issue #4656](https://github.com/PHPOffice/PhpSpreadsheet/issues/4656) [Issue #316](https://github.com/PHPOffice/PhpSpreadsheet/issues/316) [Issue #503](https://github.com/PHPOffice/PhpSpreadsheet/issues/503) [PR #4657](https://github.com/PHPOffice/PhpSpreadsheet/pull/4657)
|
||||||
|
- Unexpected Behavior of CONCATENATE. [Issue #4061](https://github.com/PHPOffice/PhpSpreadsheet/issues/4061) [PR #4797](https://github.com/PHPOffice/PhpSpreadsheet/pull/4797)
|
||||||
|
- Image Css size in millimeters. [Issue #4800](https://github.com/PHPOffice/PhpSpreadsheet/issues/4800) [PR #4801](https://github.com/PHPOffice/PhpSpreadsheet/pull/4801)
|
||||||
|
- Ods Reader column misalignment. [Issue #4802](https://github.com/PHPOffice/PhpSpreadsheet/issues/4802) [PR #4803](https://github.com/PHPOffice/PhpSpreadsheet/pull/4803)
|
||||||
|
- Ods improved handling of number formats. [Issue #3961](https://github.com/PHPOffice/PhpSpreadsheet/issues/3961) [Issue #4798](https://github.com/PHPOffice/PhpSpreadsheet/issues/4798) [PR #4806](https://github.com/PHPOffice/PhpSpreadsheet/pull/4806)
|
||||||
|
- Ods Reader fonts and fills. [Issue #2622](https://github.com/PHPOffice/PhpSpreadsheet/issues/2622) [Issue #1191](https://github.com/PHPOffice/PhpSpreadsheet/issues/1191) [PR #4810](https://github.com/PHPOffice/PhpSpreadsheet/pull/4810)
|
||||||
|
- Ods Reader alignment and cell protection. [PR #4813](https://github.com/PHPOffice/PhpSpreadsheet/pull/4813)
|
||||||
|
- Ods Reader borders. [PR #4814](https://github.com/PHPOffice/PhpSpreadsheet/pull/4814)
|
||||||
|
- Ods Reader column styles (partial). [PR #4815](https://github.com/PHPOffice/PhpSpreadsheet/pull/4815)
|
||||||
|
|
||||||
|
## 2026-01-10 - 5.4.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Store image in cell (Xlsx only). [Issue #4014](https://github.com/PHPOffice/PhpSpreadsheet/issues/4014) [Issue #4034](https://github.com/PHPOffice/PhpSpreadsheet/issues/4034) [Issue #913](https://github.com/PHPOffice/PhpSpreadsheet/issues/913) [PR #4677](https://github.com/PHPOffice/PhpSpreadsheet/pull/4677)
|
||||||
|
- Passthrough support (with some restrictions) for otherwise unsupported drawing elements. [Issue #4037](https://github.com/PHPOffice/PhpSpreadsheet/issues/4037) [Issue #4704](https://github.com/PHPOffice/PhpSpreadsheet/issues/4704) [PR #4712](https://github.com/PHPOffice/PhpSpreadsheet/pull/4712)
|
||||||
|
- Set all locale variables at once in a threadsafe manner. [Issue #954](https://github.com/PHPOffice/PhpSpreadsheet/issues/954) [PR #4760](https://github.com/PHPOffice/PhpSpreadsheet/pull/4760)
|
||||||
|
- Reader/Html add ability to suppress warning messages from loadhtml. [Issue #647](https://github.com/PHPOffice/PhpSpreadsheet/issues/647) [Issue #849](https://github.com/PHPOffice/PhpSpreadsheet/issues/849) [PR #4761](https://github.com/PHPOffice/PhpSpreadsheet/pull/4761)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Evaluation of WEBSERVICE no longer requires external client, but will use oldCalculatedValue unless the request is for a domain in a user-supplied whitelist. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
|
||||||
|
|
||||||
|
### Moved
|
||||||
|
|
||||||
|
- Code to merge cell base style with table and conditional styles moved from Html Writer to its own class. [Issue #1058](https://github.com/PHPOffice/PhpSpreadsheet/issues/1058) [PR #4763](https://github.com/PHPOffice/PhpSpreadsheet/pull/4763)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- Settings methods setHttpClient, unsetHttpClient, getHttpClient, and getRequestFactory are no longer used. No replacement.
|
||||||
|
- Reader/Html protected property dataArray, described as used only for testing, is not used for testing. No replacement.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Slightly better support for escaped characters in Xlsx Reader/Writer. [Discussion #4724](https://github.com/PHPOffice/PhpSpreadsheet/discussions/4724) [PR #4726](https://github.com/PHPOffice/PhpSpreadsheet/pull/4726)
|
||||||
|
- CODE/UNICODE and CHAR/UNICHAR. [PR #4727](https://github.com/PHPOffice/PhpSpreadsheet/pull/4727)
|
||||||
|
- Minor changes to TextGrid. [PR #4735](https://github.com/PHPOffice/PhpSpreadsheet/pull/4735) [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
|
||||||
|
- Single-character table names. [Issue #4739](https://github.com/PHPOffice/PhpSpreadsheet/issues/4739) [PR #4740](https://github.com/PHPOffice/PhpSpreadsheet/pull/4740)
|
||||||
|
- Improvements to SORT and SORTBY. [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
|
||||||
|
- Coverage-related changes in Shared. [PR #4745](https://github.com/PHPOffice/PhpSpreadsheet/pull/4745)
|
||||||
|
- ListWorksheetInfo improvements for Xlsx and Ods. [Issue #3255](https://github.com/PHPOffice/PhpSpreadsheet/issues/3255) [PR #4746](https://github.com/PHPOffice/PhpSpreadsheet/pull/4746)
|
||||||
|
- Fix functions related to Student-T distribution. [Issue #4167](https://github.com/PHPOffice/PhpSpreadsheet/issues/4167) [PR #4748](https://github.com/PHPOffice/PhpSpreadsheet/pull/4748)
|
||||||
|
- Fix drawing hyperlinks. [Issue #993](https://github.com/PHPOffice/PhpSpreadsheet/issues/993) [PR #4764](https://github.com/PHPOffice/PhpSpreadsheet/pull/4764)
|
||||||
|
- Fix clone spreadsheet with defined names. [PR #4753](https://github.com/PHPOffice/PhpSpreadsheet/pull/4753)
|
||||||
|
- Changes to WEBSERVICE. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
|
||||||
|
- More consistent handling of unsupported functions. [Issue #606](https://github.com/PHPOffice/PhpSpreadsheet/issues/606) [PR #4772](https://github.com/PHPOffice/PhpSpreadsheet/pull/4772)
|
||||||
|
- Chart shadow `kx` and `ky`. [PR #4770](https://github.com/PHPOffice/PhpSpreadsheet/pull/4770)
|
||||||
|
- SUBTOTAL and hidden rows. [Issue #820](https://github.com/PHPOffice/PhpSpreadsheet/issues/820) [PR #4765](https://github.com/PHPOffice/PhpSpreadsheet/pull/4765)
|
||||||
|
- Fix some hyperlink problems. [Issue #3889](https://github.com/PHPOffice/PhpSpreadsheet/issues/3889) [Issue #2464](https://github.com/PHPOffice/PhpSpreadsheet/issues/2464) [PR #4771](https://github.com/PHPOffice/PhpSpreadsheet/pull/4771)
|
||||||
|
- Xls Writer and empty RichText. [Issue #918](https://github.com/PHPOffice/PhpSpreadsheet/issues/918) [PR #4769](https://github.com/PHPOffice/PhpSpreadsheet/pull/4769)
|
||||||
|
- Strings that look like huge floating-point numbers. [Issue #4766](https://github.com/PHPOffice/PhpSpreadsheet/issues/4766) [PR #4768](https://github.com/PHPOffice/PhpSpreadsheet/pull/4768)
|
||||||
|
- Rowspan in Html. [Issue #1319](https://github.com/PHPOffice/PhpSpreadsheet/issues/1319) [PR #4767](https://github.com/PHPOffice/PhpSpreadsheet/pull/4767)
|
||||||
|
- Mpdf styling of multi-line strings. [Issue #4773](https://github.com/PHPOffice/PhpSpreadsheet/issues/4773) [PR #4775](https://github.com/PHPOffice/PhpSpreadsheet/pull/4775)
|
||||||
|
|
||||||
|
## 2025-11-24 - 5.3.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Formal support for Php8.5.
|
||||||
|
- Limited Printarea support for Html/Pdf. [Issue #3941](https://github.com/PHPOffice/PhpSpreadsheet/issues/3941) [PR #4711](https://github.com/PHPOffice/PhpSpreadsheet/pull/4711)
|
||||||
|
- Implement missing `INFO` function. [PR #4709](https://github.com/PHPOffice/PhpSpreadsheet/pull/4709)
|
||||||
|
- Implement missing `BAHTTEXT` function. [PR #4715](https://github.com/PHPOffice/PhpSpreadsheet/pull/4715)
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- $dataType, the second parameter of Cell::setValueExplicit, is currently optional. Omitting it is deprecated, and it will be required in a future release.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Protected ranges and insert/delete rows/columns. [Issue #4695](https://github.com/PHPOffice/PhpSpreadsheet/issues/4695) [PR #4702](https://github.com/PHPOffice/PhpSpreadsheet/pull/4702)
|
||||||
|
- Unexpected Exception in Php DateTime. [Issue #4696](https://github.com/PHPOffice/PhpSpreadsheet/issues/4696) [Issue #917](https://github.com/PHPOffice/PhpSpreadsheet/issues/917) [PR #4697](https://github.com/PHPOffice/PhpSpreadsheet/pull/4697)
|
||||||
|
- Add missing Dutch translation to translation file. [PR #4707](https://github.com/PHPOffice/PhpSpreadsheet/pull/4707)
|
||||||
|
- Fix lots of typos throughout codebase. [PR #4705](https://github.com/PHPOffice/PhpSpreadsheet/pull/4705)
|
||||||
|
- Apply small code style improvements. [PR #4708](https://github.com/PHPOffice/PhpSpreadsheet/pull/4708)
|
||||||
|
|
||||||
## 2025-10-25 - 5.2.0
|
## 2025-10-25 - 5.2.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -250,18 +458,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Worksheet::getProtectedCells - use getProtectedCellRanges instead.
|
- Worksheet::getProtectedCells - use getProtectedCellRanges instead.
|
||||||
- Writer/Html::isMpdf property - use instanceof Mpdf instead.
|
- Writer/Html::isMpdf property - use instanceof Mpdf instead.
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Xls writer Parser Mishandling True/False Argument. [Issue #4331](https://github.com/PHPOffice/PhpSpreadsheet/issues/4331) [PR #4333](https://github.com/PHPOffice/PhpSpreadsheet/pull/4333)
|
- Xls writer Parser Mishandling True/False Argument. [Issue #4331](https://github.com/PHPOffice/PhpSpreadsheet/issues/4331) [PR #4333](https://github.com/PHPOffice/PhpSpreadsheet/pull/4333)
|
||||||
@@ -314,22 +510,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
## 2024-12-08 - 3.6.0
|
## 2024-12-08 - 3.6.0
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Html Reader/Writer Better Handling of Booleans. [PR #4257](https://github.com/PHPOffice/PhpSpreadsheet/pull/4257)
|
- Html Reader/Writer Better Handling of Booleans. [PR #4257](https://github.com/PHPOffice/PhpSpreadsheet/pull/4257)
|
||||||
@@ -342,18 +522,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
## 2024-11-22 - 3.5.0
|
## 2024-11-22 - 3.5.0
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Settings::libXmlLoaderOptions is ignored. [PR #4233](https://github.com/PHPOffice/PhpSpreadsheet/pull/4233)
|
- Settings::libXmlLoaderOptions is ignored. [PR #4233](https://github.com/PHPOffice/PhpSpreadsheet/pull/4233)
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Settings::setLibXmlLoaderOptions() and Settings::getLibXmlLoaderOptions() are no longer needed - no replacement.
|
- Settings::setLibXmlLoaderOptions() and Settings::getLibXmlLoaderOptions() are no longer needed - no replacement.
|
||||||
@@ -386,10 +558,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Worksheet::getProtectedCells was deprecated in release 2, but was not properly documented, and not removed in release 3. Use getProtectedCellRanges instead.
|
- Worksheet::getProtectedCells was deprecated in release 2, but was not properly documented, and not removed in release 3. Use getProtectedCellRanges instead.
|
||||||
- Writer/Html::isMpdf property was deprecated in release 2, but was not properly documented, and not removed in release 3. Use instanceof Mpdf instead.
|
- Writer/Html::isMpdf property was deprecated in release 2, but was not properly documented, and not removed in release 3. Use instanceof Mpdf instead.
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Xls Writer Condtional Rules Applied to Whole Rows or Columns. [Issue #3185](https://github.com/PHPOffice/PhpSpreadsheet/issues/3185) [PR #4152](https://github.com/PHPOffice/PhpSpreadsheet/pull/4152)
|
- Xls Writer Condtional Rules Applied to Whole Rows or Columns. [Issue #3185](https://github.com/PHPOffice/PhpSpreadsheet/issues/3185) [PR #4152](https://github.com/PHPOffice/PhpSpreadsheet/pull/4152)
|
||||||
@@ -421,10 +589,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Currency and Accounting Wizards are changed to act like Excel, and a new CurrencyBase Wizard is added for for non-Excel formats. [Issue #4125](https://github.com/PHPOffice/PhpSpreadsheet/issues/4125) [Issue #4124](https://github.com/PHPOffice/PhpSpreadsheet/issues/4124) [PR #4127](https://github.com/PHPOffice/PhpSpreadsheet/pull/4127)
|
- Currency and Accounting Wizards are changed to act like Excel, and a new CurrencyBase Wizard is added for for non-Excel formats. [Issue #4125](https://github.com/PHPOffice/PhpSpreadsheet/issues/4125) [Issue #4124](https://github.com/PHPOffice/PhpSpreadsheet/issues/4124) [PR #4127](https://github.com/PHPOffice/PhpSpreadsheet/pull/4127)
|
||||||
- Images will not be added to spreadsheet if they cannot be validated as images.
|
- Images will not be added to spreadsheet if they cannot be validated as images.
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- The following items were deprecated in release 2 and are now removed.
|
- The following items were deprecated in release 2 and are now removed.
|
||||||
@@ -433,10 +597,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Calculation\Calculation::_translateFormulaToLocale (use method name translateFormulaToLocale without leading underscore).
|
- Calculation\Calculation::_translateFormulaToLocale (use method name translateFormulaToLocale without leading underscore).
|
||||||
- Calculation\Calculation::_translateFormulaToEnglish (use method name translateFormulaToEnglish without leading underscore).
|
- Calculation\Calculation::_translateFormulaToEnglish (use method name translateFormulaToEnglish without leading underscore).
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Several security patches.
|
- Several security patches.
|
||||||
@@ -446,22 +606,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
## 2024-08-07 - 2.2.2
|
## 2024-08-07 - 2.2.2
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing yet.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Html Reader Preserve Unicode Whitespace. [Issue #1284](https://github.com/PHPOffice/PhpSpreadsheet/issues/1284) [PR #4106](https://github.com/PHPOffice/PhpSpreadsheet/pull/4106)
|
- Html Reader Preserve Unicode Whitespace. [Issue #1284](https://github.com/PHPOffice/PhpSpreadsheet/issues/1284) [PR #4106](https://github.com/PHPOffice/PhpSpreadsheet/pull/4106)
|
||||||
@@ -500,10 +644,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
- Writer\Xls\Style\ColorMap is no longer needed.
|
- Writer\Xls\Style\ColorMap is no longer needed.
|
||||||
|
|
||||||
### Moved
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Incorrect Reader CSV with BOM. [Issue #4028](https://github.com/PHPOffice/PhpSpreadsheet/issues/4028) [PR #4029](https://github.com/PHPOffice/PhpSpreadsheet/pull/4029)
|
- Incorrect Reader CSV with BOM. [Issue #4028](https://github.com/PHPOffice/PhpSpreadsheet/issues/4028) [PR #4029](https://github.com/PHPOffice/PhpSpreadsheet/pull/4029)
|
||||||
@@ -538,18 +678,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Default Style Alignment Property (workaround for bug in non-Excel spreadsheet apps) [Issue #3918](https://github.com/PHPOffice/PhpSpreadsheet/issues/3918) [PR #3924](https://github.com/PHPOffice/PhpSpreadsheet/pull/3924)
|
- Default Style Alignment Property (workaround for bug in non-Excel spreadsheet apps) [Issue #3918](https://github.com/PHPOffice/PhpSpreadsheet/issues/3918) [PR #3924](https://github.com/PHPOffice/PhpSpreadsheet/pull/3924)
|
||||||
- Additional Support for Date/Time Styles [PR #3939](https://github.com/PHPOffice/PhpSpreadsheet/pull/3939)
|
- Additional Support for Date/Time Styles [PR #3939](https://github.com/PHPOffice/PhpSpreadsheet/pull/3939)
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Reader/Xml trySimpleXMLLoadString should not have had public visibility, and will be removed.
|
- Reader/Xml trySimpleXMLLoadString should not have had public visibility, and will be removed.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- IF Empty Arguments. [Issue #3875](https://github.com/PHPOffice/PhpSpreadsheet/issues/3875) [Issue #2146](https://github.com/PHPOffice/PhpSpreadsheet/issues/2146) [PR #3879](https://github.com/PHPOffice/PhpSpreadsheet/pull/3879)
|
- IF Empty Arguments. [Issue #3875](https://github.com/PHPOffice/PhpSpreadsheet/issues/3875) [Issue #2146](https://github.com/PHPOffice/PhpSpreadsheet/issues/2146) [PR #3879](https://github.com/PHPOffice/PhpSpreadsheet/pull/3879)
|
||||||
@@ -611,10 +743,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
- Functions `_translateFormulaToLocale` and `_translateFormulaEnglish` are replaced by versions without leading underscore. [PR #3828](https://github.com/PHPOffice/PhpSpreadsheet/pull/3828)
|
- Functions `_translateFormulaToLocale` and `_translateFormulaEnglish` are replaced by versions without leading underscore. [PR #3828](https://github.com/PHPOffice/PhpSpreadsheet/pull/3828)
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Take advantage of mitoteam/jpgraph Extended mode to enable rendering of more graphs. [PR #3603](https://github.com/PHPOffice/PhpSpreadsheet/pull/3603)
|
- Take advantage of mitoteam/jpgraph Extended mode to enable rendering of more graphs. [PR #3603](https://github.com/PHPOffice/PhpSpreadsheet/pull/3603)
|
||||||
@@ -682,14 +810,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Allow `CellRange` and `CellAddress` objects for the `range` argument in the `rangeToArray()` method. [PR #3494](https://github.com/PHPOffice/PhpSpreadsheet/pull/3494)
|
- Allow `CellRange` and `CellAddress` objects for the `range` argument in the `rangeToArray()` method. [PR #3494](https://github.com/PHPOffice/PhpSpreadsheet/pull/3494)
|
||||||
- Stock charts will now read and reproduce `upDownBars` and subsidiary tags; these were previously ignored on read and hard-coded on write. [PR #3515](https://github.com/PHPOffice/PhpSpreadsheet/pull/3515)
|
- Stock charts will now read and reproduce `upDownBars` and subsidiary tags; these were previously ignored on read and hard-coded on write. [PR #3515](https://github.com/PHPOffice/PhpSpreadsheet/pull/3515)
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Updates Cell formula absolute ranges/references, and Defined Name absolute ranges/references when inserting/deleting rows/columns. [Issue #3368](https://github.com/PHPOffice/PhpSpreadsheet/issues/3368) [PR #3402](https://github.com/PHPOffice/PhpSpreadsheet/pull/3402)
|
- Updates Cell formula absolute ranges/references, and Defined Name absolute ranges/references when inserting/deleting rows/columns. [Issue #3368](https://github.com/PHPOffice/PhpSpreadsheet/issues/3368) [PR #3402](https://github.com/PHPOffice/PhpSpreadsheet/pull/3402)
|
||||||
@@ -747,10 +867,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
- Rationalisation of Pre-defined Currency Format Masks [PR #3377](https://github.com/PHPOffice/PhpSpreadsheet/pull/3377)
|
- Rationalisation of Pre-defined Currency Format Masks [PR #3377](https://github.com/PHPOffice/PhpSpreadsheet/pull/3377)
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Calculation Engine doesn't evaluate Defined Name when default cell A1 is quote-prefixed [Issue #3335](https://github.com/PHPOffice/PhpSpreadsheet/issues/3335) [PR #3336](https://github.com/PHPOffice/PhpSpreadsheet/pull/3336)
|
- Calculation Engine doesn't evaluate Defined Name when default cell A1 is quote-prefixed [Issue #3335](https://github.com/PHPOffice/PhpSpreadsheet/issues/3335) [PR #3336](https://github.com/PHPOffice/PhpSpreadsheet/pull/3336)
|
||||||
@@ -764,22 +880,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
## 1.27.1 - 2023-02-08
|
## 1.27.1 - 2023-02-08
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fix Composer --dev dependency issue with dealerdirect/phpcodesniffer-composer-installer renaming their `master` branch to `main`
|
- Fix Composer --dev dependency issue with dealerdirect/phpcodesniffer-composer-installer renaming their `master` branch to `main`
|
||||||
@@ -794,14 +894,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Support for Structured References in the Calculation Engine [PR #3261](https://github.com/PHPOffice/PhpSpreadsheet/pull/3261)
|
- Support for Structured References in the Calculation Engine [PR #3261](https://github.com/PHPOffice/PhpSpreadsheet/pull/3261)
|
||||||
- Limited Support for Form Controls [PR #3130](https://github.com/PHPOffice/PhpSpreadsheet/pull/3130) [Issue #2396](https://github.com/PHPOffice/PhpSpreadsheet/issues/2396) [Issue #1770](https://github.com/PHPOffice/PhpSpreadsheet/issues/1770) [Issue #2388](https://github.com/PHPOffice/PhpSpreadsheet/issues/2388) [Issue #2904](https://github.com/PHPOffice/PhpSpreadsheet/issues/2904) [Issue #2661](https://github.com/PHPOffice/PhpSpreadsheet/issues/2661)
|
- Limited Support for Form Controls [PR #3130](https://github.com/PHPOffice/PhpSpreadsheet/pull/3130) [Issue #2396](https://github.com/PHPOffice/PhpSpreadsheet/issues/2396) [Issue #1770](https://github.com/PHPOffice/PhpSpreadsheet/issues/1770) [Issue #2388](https://github.com/PHPOffice/PhpSpreadsheet/issues/2388) [Issue #2904](https://github.com/PHPOffice/PhpSpreadsheet/issues/2904) [Issue #2661](https://github.com/PHPOffice/PhpSpreadsheet/issues/2661)
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Shared/JAMA is removed. [PR #3260](https://github.com/PHPOffice/PhpSpreadsheet/pull/3260)
|
- Shared/JAMA is removed. [PR #3260](https://github.com/PHPOffice/PhpSpreadsheet/pull/3260)
|
||||||
@@ -827,10 +919,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Xlsx Reader support for Pivot Tables [PR #2829](https://github.com/PHPOffice/PhpSpreadsheet/pull/2829)
|
- Xlsx Reader support for Pivot Tables [PR #2829](https://github.com/PHPOffice/PhpSpreadsheet/pull/2829)
|
||||||
- Permit Date/Time Entered on Spreadsheet to be calculated as Float [Issue #1416](https://github.com/PHPOffice/PhpSpreadsheet/issues/1416) [PR #3121](https://github.com/PHPOffice/PhpSpreadsheet/pull/3121)
|
- Permit Date/Time Entered on Spreadsheet to be calculated as Float [Issue #1416](https://github.com/PHPOffice/PhpSpreadsheet/issues/1416) [PR #3121](https://github.com/PHPOffice/PhpSpreadsheet/pull/3121)
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Direct update of Calculation::suppressFormulaErrors is replaced with setter.
|
- Direct update of Calculation::suppressFormulaErrors is replaced with setter.
|
||||||
@@ -838,10 +926,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- ExcelError public static variable errorCodes replaced with constant ERROR_CODES.
|
- ExcelError public static variable errorCodes replaced with constant ERROR_CODES.
|
||||||
- NumberFormat constant FORMAT_DATE_YYYYMMDD2 replaced with existing identical FORMAT_DATE_YYYYMMDD.
|
- NumberFormat constant FORMAT_DATE_YYYYMMDD2 replaced with existing identical FORMAT_DATE_YYYYMMDD.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed handling for `_xlws` prefixed functions from Office365 [Issue #3245](https://github.com/PHPOffice/PhpSpreadsheet/issues/3245) [PR #3247](https://github.com/PHPOffice/PhpSpreadsheet/pull/3247)
|
- Fixed handling for `_xlws` prefixed functions from Office365 [Issue #3245](https://github.com/PHPOffice/PhpSpreadsheet/issues/3245) [PR #3247](https://github.com/PHPOffice/PhpSpreadsheet/pull/3247)
|
||||||
@@ -870,22 +954,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
|
|
||||||
## 1.25.2 - 2022-09-25
|
## 1.25.2 - 2022-09-25
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Composer dependency clash with ezyang/htmlpurifier
|
- Composer dependency clash with ezyang/htmlpurifier
|
||||||
@@ -918,10 +986,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Misspelled Properties::LINE_STYLE_DASH_SQUERE_DOT deprecated in favor of LINE_STYLE_DASH_SQUARE_DOT.
|
- Misspelled Properties::LINE_STYLE_DASH_SQUERE_DOT deprecated in favor of LINE_STYLE_DASH_SQUARE_DOT.
|
||||||
- Clone not permitted for Spreadsheet. Spreadsheet->copy() can be used instead.
|
- Clone not permitted for Spreadsheet. Spreadsheet->copy() can be used instead.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fix update to defined names when inserting/deleting rows/columns [Issue #3076](https://github.com/PHPOffice/PhpSpreadsheet/issues/3076) [PR #3077](https://github.com/PHPOffice/PhpSpreadsheet/pull/3077)
|
- Fix update to defined names when inserting/deleting rows/columns [Issue #3076](https://github.com/PHPOffice/PhpSpreadsheet/issues/3076) [PR #3077](https://github.com/PHPOffice/PhpSpreadsheet/pull/3077)
|
||||||
@@ -950,18 +1014,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
|
|||||||
- Support for SimpleCache Interface versions 1.0, 2.0 and 3.0
|
- Support for SimpleCache Interface versions 1.0, 2.0 and 3.0
|
||||||
- Add Chart Axis Option textRotation [Issue #2705](https://github.com/PHPOffice/PhpSpreadsheet/issues/2705) [PR #2940](https://github.com/PHPOffice/PhpSpreadsheet/pull/2940)
|
- Add Chart Axis Option textRotation [Issue #2705](https://github.com/PHPOffice/PhpSpreadsheet/issues/2705) [PR #2940](https://github.com/PHPOffice/PhpSpreadsheet/pull/2940)
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fix Encoding issue with Html reader (PHP 8.2 deprecation for mb_convert_encoding) [Issue #2942](https://github.com/PHPOffice/PhpSpreadsheet/issues/2942) [PR #2943](https://github.com/PHPOffice/PhpSpreadsheet/pull/2943)
|
- Fix Encoding issue with Html reader (PHP 8.2 deprecation for mb_convert_encoding) [Issue #2942](https://github.com/PHPOffice/PhpSpreadsheet/issues/2942) [PR #2943](https://github.com/PHPOffice/PhpSpreadsheet/pull/2943)
|
||||||
@@ -1002,14 +1054,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
See [the Discussion section on github](https://github.com/PHPOffice/PhpSpreadsheet/discussions/2821) for details of performance across versions
|
See [the Discussion section on github](https://github.com/PHPOffice/PhpSpreadsheet/discussions/2821) for details of performance across versions
|
||||||
- Improved performance for removing rows/columns from a worksheet
|
- Improved performance for removing rows/columns from a worksheet
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Xls Reader resolving absolute named ranges to relative ranges [Issue #2826](https://github.com/PHPOffice/PhpSpreadsheet/issues/2826) [PR #2827](https://github.com/PHPOffice/PhpSpreadsheet/pull/2827)
|
- Xls Reader resolving absolute named ranges to relative ranges [Issue #2826](https://github.com/PHPOffice/PhpSpreadsheet/issues/2826) [PR #2827](https://github.com/PHPOffice/PhpSpreadsheet/pull/2827)
|
||||||
@@ -1089,10 +1133,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method);
|
This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method);
|
||||||
and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencing them by passing the defined name of a named range as the argument.
|
and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencing them by passing the defined name of a named range as the argument.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Make allowance for the AutoFilter dropdown icon in the first row of an Autofilter range when using Autosize columns. [Issue #2413](https://github.com/PHPOffice/PhpSpreadsheet/issues/2413) [PR #2754](https://github.com/PHPOffice/PhpSpreadsheet/pull/2754)
|
- Make allowance for the AutoFilter dropdown icon in the first row of an Autofilter range when using Autosize columns. [Issue #2413](https://github.com/PHPOffice/PhpSpreadsheet/issues/2413) [PR #2754](https://github.com/PHPOffice/PhpSpreadsheet/pull/2754)
|
||||||
@@ -1160,14 +1200,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
- Extract some methods from the Calculation Engine into dedicated classes [#2537](https://github.com/PHPOffice/PhpSpreadsheet/issues/2537)
|
- Extract some methods from the Calculation Engine into dedicated classes [#2537](https://github.com/PHPOffice/PhpSpreadsheet/issues/2537)
|
||||||
- Eliminate calls to `flattenSingleValue()` that are no longer required when we're checking for array values as arguments [#2590](https://github.com/PHPOffice/PhpSpreadsheet/issues/2590)
|
- Eliminate calls to `flattenSingleValue()` that are no longer required when we're checking for array values as arguments [#2590](https://github.com/PHPOffice/PhpSpreadsheet/issues/2590)
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed `ReferenceHelper@insertNewBefore` behavior when removing column before last column with null value [PR #2541](https://github.com/PHPOffice/PhpSpreadsheet/pull/2541)
|
- Fixed `ReferenceHelper@insertNewBefore` behavior when removing column before last column with null value [PR #2541](https://github.com/PHPOffice/PhpSpreadsheet/pull/2541)
|
||||||
@@ -1202,14 +1234,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
|
|
||||||
- Xlsx Writer will evaluate AutoFilter only if it is as yet unevaluated, or has changed since it was last evaluated [PR #2414](https://github.com/PHPOffice/PhpSpreadsheet/pull/2414)
|
- Xlsx Writer will evaluate AutoFilter only if it is as yet unevaluated, or has changed since it was last evaluated [PR #2414](https://github.com/PHPOffice/PhpSpreadsheet/pull/2414)
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Rounding in `NumberFormatter` [Issue #2385](https://github.com/PHPOffice/PhpSpreadsheet/issues/2385) [PR #2399](https://github.com/PHPOffice/PhpSpreadsheet/pull/2399)
|
- Rounding in `NumberFormatter` [Issue #2385](https://github.com/PHPOffice/PhpSpreadsheet/issues/2385) [PR #2399](https://github.com/PHPOffice/PhpSpreadsheet/pull/2399)
|
||||||
@@ -1240,14 +1264,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
- Drop support for PHP 7.2, according to https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support
|
- Drop support for PHP 7.2, according to https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support
|
||||||
- Use native typing for objects that were already documented as such
|
- Use native typing for objects that were already documented as such
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed null conversation for strToUpper [#2292](https://github.com/PHPOffice/PhpSpreadsheet/issues/2292)
|
- Fixed null conversation for strToUpper [#2292](https://github.com/PHPOffice/PhpSpreadsheet/issues/2292)
|
||||||
@@ -1281,18 +1297,10 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
- Ability to stream to an Amazon S3 bucket [Issue #2249](https://github.com/PHPOffice/PhpSpreadsheet/issues/2249)
|
- Ability to stream to an Amazon S3 bucket [Issue #2249](https://github.com/PHPOffice/PhpSpreadsheet/issues/2249)
|
||||||
- Provided a Size Helper class to validate size values (pt, px, em) [PR #1694](https://github.com/PHPOffice/PhpSpreadsheet/pull/1694)
|
- Provided a Size Helper class to validate size values (pt, px, em) [PR #1694](https://github.com/PHPOffice/PhpSpreadsheet/pull/1694)
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Nothing.
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- PHP 8.1 will deprecate auto_detect_line_endings. As a result of this change, Csv Reader using some release after PHP8.1 will no longer be able to handle a Csv with Mac line endings.
|
- PHP 8.1 will deprecate auto_detect_line_endings. As a result of this change, Csv Reader using some release after PHP8.1 will no longer be able to handle a Csv with Mac line endings.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Unexpected format in Xlsx Timestamp [Issue #2331](https://github.com/PHPOffice/PhpSpreadsheet/issues/2331) [PR #2332](https://github.com/PHPOffice/PhpSpreadsheet/pull/2332)
|
- Unexpected format in Xlsx Timestamp [Issue #2331](https://github.com/PHPOffice/PhpSpreadsheet/issues/2331) [PR #2332](https://github.com/PHPOffice/PhpSpreadsheet/pull/2332)
|
||||||
@@ -1415,10 +1423,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
|
|
||||||
This makes the logic in these classes easier to maintain; and will reduce the memory footprint required to execute formulae when calling these functions.
|
This makes the logic in these classes easier to maintain; and will reduce the memory footprint required to execute formulae when calling these functions.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Avoid Duplicate Titles When Reading Multiple HTML Files.[Issue #1823](https://github.com/PHPOffice/PhpSpreadsheet/issues/1823) [PR #1829](https://github.com/PHPOffice/PhpSpreadsheet/pull/1829)
|
- Avoid Duplicate Titles When Reading Multiple HTML Files.[Issue #1823](https://github.com/PHPOffice/PhpSpreadsheet/issues/1823) [PR #1829](https://github.com/PHPOffice/PhpSpreadsheet/pull/1829)
|
||||||
@@ -1448,10 +1452,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
|
|
||||||
- All Excel Function implementations in `Calculation\Database`, `Calculation\DateTime`, `Calculation\Engineering`, `Calculation\Financial`, `Calculation\Logical`, `Calculation\LookupRef`, `Calculation\MathTrig`, `Calculation\Statistical`, `Calculation\TextData` and `Calculation\Web` have been moved to dedicated classes for individual functions or groups of related functions. See the docblocks against all the deprecated methods for details of the new methods to call instead. At some point, these old classes will be deleted.
|
- All Excel Function implementations in `Calculation\Database`, `Calculation\DateTime`, `Calculation\Engineering`, `Calculation\Financial`, `Calculation\Logical`, `Calculation\LookupRef`, `Calculation\MathTrig`, `Calculation\Statistical`, `Calculation\TextData` and `Calculation\Web` have been moved to dedicated classes for individual functions or groups of related functions. See the docblocks against all the deprecated methods for details of the new methods to call instead. At some point, these old classes will be deleted.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed issue with absolute path in worksheets' Target [PR #1769](https://github.com/PHPOffice/PhpSpreadsheet/pull/1769)
|
- Fixed issue with absolute path in worksheets' Target [PR #1769](https://github.com/PHPOffice/PhpSpreadsheet/pull/1769)
|
||||||
@@ -1511,10 +1511,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
- **IMPORTANT NOTE:** This Introduces a **BC break** in the handling of named ranges. Previously, a named range cell reference of `B2` would be treated identically to a named range cell reference of `$B2` or `B$2` or `$B$2` because the calculation engine treated then all as absolute references. These changes "fix" that, so the calculation engine now handles relative references in named ranges correctly.
|
- **IMPORTANT NOTE:** This Introduces a **BC break** in the handling of named ranges. Previously, a named range cell reference of `B2` would be treated identically to a named range cell reference of `$B2` or `B$2` or `$B$2` because the calculation engine treated then all as absolute references. These changes "fix" that, so the calculation engine now handles relative references in named ranges correctly.
|
||||||
This change that resolves previously incorrect behaviour in the calculation may affect users who have dynamically defined named ranges using relative references when they should have used absolute references.
|
This change that resolves previously incorrect behaviour in the calculation may affect users who have dynamically defined named ranges using relative references when they should have used absolute references.
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Nothing.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- PrintArea causes exception [#1544](https://github.com/phpoffice/phpspreadsheet/pull/1544)
|
- PrintArea causes exception [#1544](https://github.com/phpoffice/phpspreadsheet/pull/1544)
|
||||||
@@ -1525,10 +1521,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
|
|||||||
|
|
||||||
## 1.14.1 - 2020-07-19
|
## 1.14.1 - 2020-07-19
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- nothing
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- WEBSERVICE is HTTP client agnostic and must be configured via `Settings::setHttpClient()` [#1562](https://github.com/PHPOffice/PhpSpreadsheet/issues/1562)
|
- WEBSERVICE is HTTP client agnostic and must be configured via `Settings::setHttpClient()` [#1562](https://github.com/PHPOffice/PhpSpreadsheet/issues/1562)
|
||||||
|
|||||||
+4
-4
@@ -43,7 +43,7 @@ This makes it easier to see exactly what is being tested when reviewing the PR.
|
|||||||
4. By default, Github removes markdown headings in the Release Notes. You can either edit to restore these, or, probably preferably, change the default comment character on your system - `git config core.commentChar ";"`.
|
4. By default, Github removes markdown headings in the Release Notes. You can either edit to restore these, or, probably preferably, change the default comment character on your system - `git config core.commentChar ";"`.
|
||||||
|
|
||||||
> **Note:** Tagged releases are made from the `master` branch. Only in an emergency should a tagged release be made from the `release` branch. (i.e. cherry-picked hot-fixes.) However, there are 4 branches which have been updated to apply security patches, and those may be tagged if future security updates are needed.
|
> **Note:** Tagged releases are made from the `master` branch. Only in an emergency should a tagged release be made from the `release` branch. (i.e. cherry-picked hot-fixes.) However, there are 4 branches which have been updated to apply security patches, and those may be tagged if future security updates are needed.
|
||||||
- release1291 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
|
- 1.30.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
|
||||||
- release210 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
|
- 2.1.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
|
||||||
- release222
|
- 2.4.x (no further updates aside from security patches)
|
||||||
- release390
|
- 3.10.x
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2019-2025 PhpSpreadsheet Authors
|
Copyright (c) 2019-2026 PhpSpreadsheet Authors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+3
-2
@@ -4,7 +4,7 @@
|
|||||||
[](https://coveralls.io/github/PHPOffice/PhpSpreadsheet?branch=master)
|
[](https://coveralls.io/github/PHPOffice/PhpSpreadsheet?branch=master)
|
||||||
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
||||||
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
||||||
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
[](https://packagist.org/packages/phpoffice/phpspreadsheet)
|
||||||
[](https://gitter.im/PHPOffice/PhpSpreadsheet)
|
[](https://gitter.im/PHPOffice/PhpSpreadsheet)
|
||||||
|
|
||||||
PhpSpreadsheet is a library written in pure PHP and offers a set of classes that
|
PhpSpreadsheet is a library written in pure PHP and offers a set of classes that
|
||||||
@@ -16,8 +16,9 @@ This is the master branch, and is maintained for security and bug fixes.
|
|||||||
|
|
||||||
LTS: For maintained branches, support for PHP versions will only be maintained for a period of six months beyond the
|
LTS: For maintained branches, support for PHP versions will only be maintained for a period of six months beyond the
|
||||||
[end of life](https://www.php.net/supported-versions) of that PHP version.
|
[end of life](https://www.php.net/supported-versions) of that PHP version.
|
||||||
|
Dropping support for versions after that date will not be considered a breaking change.
|
||||||
|
|
||||||
Currently the required PHP minimum version is PHP __8.1__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2026.
|
Currently the required PHP minimum version is PHP __8.2__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2027.
|
||||||
|
|
||||||
See the `composer.json` for other requirements.
|
See the `composer.json` for other requirements.
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -13,7 +13,7 @@
|
|||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"platform": {
|
"platform": {
|
||||||
"php" : "8.1.99"
|
"php": "8.2.99"
|
||||||
},
|
},
|
||||||
"process-timeout": 600,
|
"process-timeout": 600,
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Adrien Crivelli"
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -66,10 +69,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1",
|
"php": "^8.2",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
"ext-fileinfo": "*",
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
@@ -84,11 +88,11 @@
|
|||||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
"markbaker/complex": "^3.0",
|
"markbaker/complex": "^3.0",
|
||||||
"markbaker/matrix": "^3.0",
|
"markbaker/matrix": "^3.0",
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"psr/http-factory": "^1.0",
|
|
||||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"ext-intl": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
@@ -98,12 +102,13 @@
|
|||||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
"tecnickcom/tcpdf": "^6.5"
|
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
|
||||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer",
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer",
|
||||||
@@ -117,7 +122,8 @@
|
|||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"PhpOffice\\PhpSpreadsheetTests\\": "tests/PhpSpreadsheetTests",
|
"PhpOffice\\PhpSpreadsheetTests\\": "tests/PhpSpreadsheetTests",
|
||||||
"PhpOffice\\PhpSpreadsheetInfra\\": "infra"
|
"PhpOffice\\PhpSpreadsheetInfra\\": "infra",
|
||||||
|
"PhpOffice\\PhpSpreadsheetBenchmarks\\": "tests/Benchmark"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
||||||
|
|
||||||
class BinaryComparison
|
class BinaryComparison
|
||||||
@@ -41,17 +42,25 @@ class BinaryComparison
|
|||||||
return strcmp($str1, $str2);
|
return strcmp($str1, $str2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function compare(mixed $operand1, mixed $operand2, string $operator): bool
|
public static function compare(mixed $operand1, mixed $operand2, string $operator): bool|string
|
||||||
{
|
{
|
||||||
// Simple validate the two operands if they are string values
|
// Simple validate the two operands if they are string values
|
||||||
if (is_string($operand1) && $operand1 > '' && $operand1[0] == Calculation::FORMULA_STRING_QUOTE) {
|
if (is_string($operand1) && $operand1 > '' && $operand1[0] == Calculation::FORMULA_STRING_QUOTE) {
|
||||||
$operand1 = Calculation::unwrapResult($operand1);
|
$operand1 = Calculation::unwrapResult($operand1);
|
||||||
}
|
}
|
||||||
|
if (ErrorValue::isError($operand1, true)) {
|
||||||
|
/** @var string $operand1 */
|
||||||
|
return $operand1;
|
||||||
|
}
|
||||||
if (is_string($operand2) && $operand2 > '' && $operand2[0] == Calculation::FORMULA_STRING_QUOTE) {
|
if (is_string($operand2) && $operand2 > '' && $operand2[0] == Calculation::FORMULA_STRING_QUOTE) {
|
||||||
$operand2 = Calculation::unwrapResult($operand2);
|
$operand2 = Calculation::unwrapResult($operand2);
|
||||||
}
|
}
|
||||||
|
if (ErrorValue::isError($operand2, true)) {
|
||||||
|
/** @var string $operand2 */
|
||||||
|
return $operand2;
|
||||||
|
}
|
||||||
|
|
||||||
// Use case insensitive comparaison if not OpenOffice mode
|
// Use case-insensitive comparison if not OpenOffice mode
|
||||||
if (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE) {
|
if (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE) {
|
||||||
if (is_string($operand1)) {
|
if (is_string($operand1)) {
|
||||||
$operand1 = StringHelper::strToUpper($operand1);
|
$operand1 = StringHelper::strToUpper($operand1);
|
||||||
|
|||||||
+192
-47
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
||||||
|
|
||||||
|
use Composer\Pcre\Preg; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
|
use PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
|
use PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
|
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
|
||||||
@@ -13,6 +14,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
|||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
use PhpOffice\PhpSpreadsheet\DefinedName;
|
use PhpOffice\PhpSpreadsheet\DefinedName;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
|
||||||
use PhpOffice\PhpSpreadsheet\NamedRange;
|
use PhpOffice\PhpSpreadsheet\NamedRange;
|
||||||
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
|
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
||||||
@@ -36,7 +38,7 @@ class Calculation extends CalculationLocale
|
|||||||
const CALCULATION_REGEXP_OPENBRACE = '\(';
|
const CALCULATION_REGEXP_OPENBRACE = '\(';
|
||||||
// Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
|
// Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
|
||||||
const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
|
const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
|
||||||
// Cell reference (cell or range of cells, with or without a sheet reference)
|
// Cell reference, with or without a sheet reference)
|
||||||
const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
|
const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
|
||||||
// Used only to detect spill operator #
|
// Used only to detect spill operator #
|
||||||
const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
|
const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
|
||||||
@@ -88,9 +90,22 @@ class Calculation extends CalculationLocale
|
|||||||
*/
|
*/
|
||||||
private bool $calculationCacheEnabled = true;
|
private bool $calculationCacheEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of entries in the formula token cache.
|
||||||
|
* Default 0 (disabled). Set via setFormulaTokenCacheMaxSize() to enable.
|
||||||
|
*/
|
||||||
|
private int $formulaTokenCacheMaxSize = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache of parsed formula tokens, keyed by the raw formula string.
|
||||||
|
*
|
||||||
|
* @var array<string, array<mixed>|bool>
|
||||||
|
*/
|
||||||
|
private array $formulaTokenCache = [];
|
||||||
|
|
||||||
private BranchPruner $branchPruner;
|
private BranchPruner $branchPruner;
|
||||||
|
|
||||||
private bool $branchPruningEnabled = true;
|
protected bool $branchPruningEnabled = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of operators that can be used within formulae
|
* List of operators that can be used within formulae
|
||||||
@@ -239,6 +254,7 @@ class Calculation extends CalculationLocale
|
|||||||
{
|
{
|
||||||
$this->clearCalculationCache();
|
$this->clearCalculationCache();
|
||||||
$this->branchPruner->clearBranchStore();
|
$this->branchPruner->clearBranchStore();
|
||||||
|
$this->formulaTokenCache = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,6 +380,44 @@ class Calculation extends CalculationLocale
|
|||||||
$this->calculationCache = [];
|
$this->calculationCache = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the formula token cache.
|
||||||
|
*/
|
||||||
|
public function clearFormulaTokenCache(): void
|
||||||
|
{
|
||||||
|
$this->formulaTokenCache = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current number of entries in the formula token cache.
|
||||||
|
*/
|
||||||
|
public function getFormulaTokenCacheSize(): int
|
||||||
|
{
|
||||||
|
return count($this->formulaTokenCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the maximum number of entries in the formula token cache.
|
||||||
|
* Set to 0 to disable caching (default), or a positive integer to enable.
|
||||||
|
*/
|
||||||
|
public function setFormulaTokenCacheMaxSize(int $size): self
|
||||||
|
{
|
||||||
|
$this->formulaTokenCacheMaxSize = max(0, $size);
|
||||||
|
if ($this->formulaTokenCacheMaxSize === 0) {
|
||||||
|
$this->formulaTokenCache = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum number of entries allowed in the formula token cache.
|
||||||
|
*/
|
||||||
|
public function getFormulaTokenCacheMaxSize(): int
|
||||||
|
{
|
||||||
|
return $this->formulaTokenCacheMaxSize;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear calculation cache for a specified worksheet.
|
* Clear calculation cache for a specified worksheet.
|
||||||
*/
|
*/
|
||||||
@@ -415,7 +469,7 @@ class Calculation extends CalculationLocale
|
|||||||
{
|
{
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
// Error values cannot be "wrapped"
|
// Error values cannot be "wrapped"
|
||||||
if (preg_match('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
|
if (Preg::isMatch('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
|
||||||
// Return Excel errors "as is"
|
// Return Excel errors "as is"
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
@@ -494,13 +548,13 @@ class Calculation extends CalculationLocale
|
|||||||
try {
|
try {
|
||||||
$value = $cell->getValue();
|
$value = $cell->getValue();
|
||||||
if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
|
if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
|
||||||
$value = preg_replace_callback(
|
$value = Preg::replaceCallback(
|
||||||
self::CALCULATION_REGEXP_CELLREF_SPILL,
|
self::CALCULATION_REGEXP_CELLREF_SPILL,
|
||||||
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
|
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
|
||||||
$value
|
$value
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line
|
$result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore argument.type ($value can be mixed not string)
|
||||||
if ($this->spreadsheet === null) {
|
if ($this->spreadsheet === null) {
|
||||||
throw new Exception('null spreadsheet in calculateCellValue');
|
throw new Exception('null spreadsheet in calculateCellValue');
|
||||||
}
|
}
|
||||||
@@ -520,7 +574,7 @@ class Calculation extends CalculationLocale
|
|||||||
$cellAddress = array_pop($this->cellStack);
|
$cellAddress = array_pop($this->cellStack);
|
||||||
}
|
}
|
||||||
if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
|
if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
|
||||||
$sheetName = $cellAddress['sheet'] ?? null;
|
$sheetName = $cellAddress['sheet'];
|
||||||
$testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
|
$testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
|
||||||
if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
|
if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
|
||||||
/** @var array{cell: string} $cellAddress */
|
/** @var array{cell: string} $cellAddress */
|
||||||
@@ -557,11 +611,17 @@ class Calculation extends CalculationLocale
|
|||||||
*/
|
*/
|
||||||
public function parseFormula(string $formula): array|bool
|
public function parseFormula(string $formula): array|bool
|
||||||
{
|
{
|
||||||
$formula = preg_replace_callback(
|
// Check the formula token cache first (only when caching is enabled)
|
||||||
|
if ($this->formulaTokenCacheMaxSize > 0 && isset($this->formulaTokenCache[$formula])) {
|
||||||
|
return $this->formulaTokenCache[$formula];
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalFormula = $formula;
|
||||||
|
$formula = Preg::replaceCallback(
|
||||||
self::CALCULATION_REGEXP_CELLREF_SPILL,
|
self::CALCULATION_REGEXP_CELLREF_SPILL,
|
||||||
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
|
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
|
||||||
$formula
|
$formula
|
||||||
) ?? $formula;
|
);
|
||||||
// Basic validation that this is indeed a formula
|
// Basic validation that this is indeed a formula
|
||||||
// We return an empty array if not
|
// We return an empty array if not
|
||||||
$formula = trim($formula);
|
$formula = trim($formula);
|
||||||
@@ -574,7 +634,21 @@ class Calculation extends CalculationLocale
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the formula and return the token stack
|
// Parse the formula and return the token stack
|
||||||
return $this->internalParseFormula($formula);
|
$result = $this->internalParseFormula($formula);
|
||||||
|
|
||||||
|
// Cache the result when caching is enabled (clear cache if it exceeds the maximum size)
|
||||||
|
if ($this->formulaTokenCacheMaxSize > 0) {
|
||||||
|
// Phpstan says if condition is always false,
|
||||||
|
// but coverage report says next statement is covered.
|
||||||
|
if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) {
|
||||||
|
$this->formulaTokenCache = [];
|
||||||
|
}
|
||||||
|
// Cache key is the original formula string (before ANCHORARRAY transformation)
|
||||||
|
// to ensure consistent lookup regardless of internal transformations.
|
||||||
|
$this->formulaTokenCache[$originalFormula] = $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -657,8 +731,9 @@ class Calculation extends CalculationLocale
|
|||||||
return self::wrapResult((string) $formula);
|
return self::wrapResult((string) $formula);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
|
||||||
if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
|
if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
|
||||||
return self::wrapResult($formula);
|
return ExcelError::REF(); // returns #BLOCKED in newer versions
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic validation that this is indeed a formula
|
// Basic validation that this is indeed a formula
|
||||||
@@ -914,13 +989,14 @@ class Calculation extends CalculationLocale
|
|||||||
$pad = $rpad = ', ';
|
$pad = $rpad = ', ';
|
||||||
foreach ($value as $row) {
|
foreach ($value as $row) {
|
||||||
if (is_array($row)) {
|
if (is_array($row)) {
|
||||||
$returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row));
|
$returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore argument.type (array_map can theoretically return array<mixed> not array<string>)
|
||||||
$rpad = '; ';
|
$rpad = '; ';
|
||||||
} else {
|
} else {
|
||||||
$returnMatrix[] = $this->showValue($row);
|
$returnMatrix[] = $this->showValue($row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var string[] $returnMatrix */
|
||||||
return '{ ' . implode($rpad, $returnMatrix) . ' }';
|
return '{ ' . implode($rpad, $returnMatrix) . ' }';
|
||||||
} elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
|
} elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
|
||||||
return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
|
return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
|
||||||
@@ -1052,6 +1128,50 @@ class Calculation extends CalculationLocale
|
|||||||
'>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, // Comparison
|
'>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, // Comparison
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** @param array<?string> $matches */
|
||||||
|
private function unionForComma(array $matches): string
|
||||||
|
{
|
||||||
|
$matches5 = (string) $matches[5];
|
||||||
|
// Weirdly, the regexp which get us here for issue 4832
|
||||||
|
// only gets us here for Php8.4+. 8.4 introduced
|
||||||
|
// major changes for PCRE, but I cannot identify
|
||||||
|
// the exact change which caused this discrepancy.
|
||||||
|
// I do plan to update coverage to 8.4 at some point,
|
||||||
|
// and I can remove the coverage annotations after that.
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
|
||||||
|
if ($this->spreadsheet !== null) {
|
||||||
|
if ($this->spreadsheet->getSheetByName($matches5) === null) {
|
||||||
|
$matches0 = (string) $matches[0];
|
||||||
|
$this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
|
||||||
|
|
||||||
|
return $matches0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
$matches1 = (string) $matches[1];
|
||||||
|
$matches2 = (string) $matches[2];
|
||||||
|
|
||||||
|
return $matches1 . str_replace(',', '∪', $matches2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
|
||||||
|
= '(?:'
|
||||||
|
. self::CALCULATION_REGEXP_CELLREF // cell address
|
||||||
|
. '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
|
||||||
|
. '|' . self::CALCULATION_REGEXP_DEFINEDNAME
|
||||||
|
. ')'
|
||||||
|
;
|
||||||
|
|
||||||
|
public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
|
||||||
|
. '([(]' // open paren
|
||||||
|
. self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
|
||||||
|
. '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
|
||||||
|
. self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
|
||||||
|
. ')+' // one or more occurrences
|
||||||
|
. '\s*[)])/i'; // optional whitespace, end paren
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, mixed>|false
|
* @return array<int, mixed>|false
|
||||||
*/
|
*/
|
||||||
@@ -1060,6 +1180,12 @@ class Calculation extends CalculationLocale
|
|||||||
if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
|
if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$oldFormula = $formula;
|
||||||
|
$formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, $this->unionForComma(...), $formula);
|
||||||
|
if ($oldFormula !== $formula) {
|
||||||
|
$this->debugLog->writeDebugLog('Reformulated as %s', $formula);
|
||||||
|
}
|
||||||
$phpSpreadsheetFunctions = &self::getFunctionsAddress();
|
$phpSpreadsheetFunctions = &self::getFunctionsAddress();
|
||||||
|
|
||||||
// If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
|
// If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
|
||||||
@@ -1179,7 +1305,7 @@ class Calculation extends CalculationLocale
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
|
} elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
|
||||||
if (1 !== preg_match('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
|
if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
|
||||||
$argMatch = ['', '', '', ''];
|
$argMatch = ['', '', '', ''];
|
||||||
}
|
}
|
||||||
switch ($argMatch[2]) {
|
switch ($argMatch[2]) {
|
||||||
@@ -1236,16 +1362,14 @@ class Calculation extends CalculationLocale
|
|||||||
// because at least the braces are paired up (at this stage in the formula)
|
// because at least the braces are paired up (at this stage in the formula)
|
||||||
// MS Excel allows this if the content is cell references; but doesn't allow actual values,
|
// MS Excel allows this if the content is cell references; but doesn't allow actual values,
|
||||||
// but at this point, we can't differentiate (so allow both)
|
// but at this point, we can't differentiate (so allow both)
|
||||||
return $this->raiseFormulaError('Formula Error: Unexpected ,');
|
//return $this->raiseFormulaError('Formula Error: Unexpected ,');
|
||||||
/* The following code may be a better choice, but, with
|
|
||||||
the other changes for this PR, I can no longer come up
|
|
||||||
with a test case that gets here
|
|
||||||
$stack->push('Binary Operator', '∪');
|
$stack->push('Binary Operator', '∪');
|
||||||
|
|
||||||
++$index;
|
++$index;
|
||||||
$expectingOperator = false;
|
$expectingOperator = false;
|
||||||
|
|
||||||
continue;*/
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array<string, int> $d */
|
/** @var array<string, int> $d */
|
||||||
@@ -1267,11 +1391,12 @@ class Calculation extends CalculationLocale
|
|||||||
// do we now have a function/variable/number?
|
// do we now have a function/variable/number?
|
||||||
$expectingOperator = true;
|
$expectingOperator = true;
|
||||||
$expectingOperand = false;
|
$expectingOperand = false;
|
||||||
$val = $match[1] ?? ''; //* @phpstan-ignore-line
|
$val = $match[1] ?? '';
|
||||||
$length = strlen($val);
|
$length = strlen($val);
|
||||||
|
|
||||||
if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
|
if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
|
||||||
$val = (string) preg_replace('/\s/u', '', $val);
|
// $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
|
||||||
|
$val = Preg::replace('/\s/u', '', $val);
|
||||||
if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) { // it's a function
|
if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) { // it's a function
|
||||||
$valToUpper = strtoupper($val);
|
$valToUpper = strtoupper($val);
|
||||||
} else {
|
} else {
|
||||||
@@ -1410,7 +1535,7 @@ class Calculation extends CalculationLocale
|
|||||||
$refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
|
$refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctype_digit($val) && $val <= 1048576) {
|
if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
|
||||||
// Row range
|
// Row range
|
||||||
$stackItemType = 'Row Reference';
|
$stackItemType = 'Row Reference';
|
||||||
$valx = $val;
|
$valx = $val;
|
||||||
@@ -1419,7 +1544,7 @@ class Calculation extends CalculationLocale
|
|||||||
} elseif (ctype_alpha($val) && strlen($val) <= 3) {
|
} elseif (ctype_alpha($val) && strlen($val) <= 3) {
|
||||||
// Column range
|
// Column range
|
||||||
$stackItemType = 'Column Reference';
|
$stackItemType = 'Column Reference';
|
||||||
$endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007
|
$endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow() : AddressRange::MAX_ROW; // Max 1,048,576 rows for Excel2007
|
||||||
$val = "{$rangeWS2}{$val}{$endRowColRef}";
|
$val = "{$rangeWS2}{$val}{$endRowColRef}";
|
||||||
}
|
}
|
||||||
$stackItemReference = $val;
|
$stackItemReference = $val;
|
||||||
@@ -1445,7 +1570,7 @@ class Calculation extends CalculationLocale
|
|||||||
// unescape any apostrophes or double quotes in worksheet name
|
// unescape any apostrophes or double quotes in worksheet name
|
||||||
$val = str_replace(["''", '""'], ["'", '"'], $val);
|
$val = str_replace(["''", '""'], ["'", '"'], $val);
|
||||||
$column = 'A';
|
$column = 'A';
|
||||||
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
|
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevop must be non-null), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
|
||||||
$column = $pCellParent->getHighestDataColumn($val);
|
$column = $pCellParent->getHighestDataColumn($val);
|
||||||
}
|
}
|
||||||
$val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
|
$val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
|
||||||
@@ -1459,7 +1584,7 @@ class Calculation extends CalculationLocale
|
|||||||
// unescape any apostrophes or double quotes in worksheet name
|
// unescape any apostrophes or double quotes in worksheet name
|
||||||
$val = str_replace(["''", '""'], ["'", '"'], $val);
|
$val = str_replace(["''", '""'], ["'", '"'], $val);
|
||||||
$row = '1';
|
$row = '1';
|
||||||
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
|
if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevOp must be non-null?), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
|
||||||
$row = $pCellParent->getHighestDataRow($val);
|
$row = $pCellParent->getHighestDataRow($val);
|
||||||
}
|
}
|
||||||
$val = "{$val}{$row}";
|
$val = "{$val}{$row}";
|
||||||
@@ -1610,7 +1735,7 @@ class Calculation extends CalculationLocale
|
|||||||
foreach ($tokens as $tokenIdx => $tokenData) {
|
foreach ($tokens as $tokenIdx => $tokenData) {
|
||||||
/** @var mixed[] $tokenData */
|
/** @var mixed[] $tokenData */
|
||||||
$this->processingAnchorArray = false;
|
$this->processingAnchorArray = false;
|
||||||
if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line
|
if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore offsetAccess.nonOffsetAccessible ($tokens might be mixed not array)
|
||||||
$this->processingAnchorArray = true;
|
$this->processingAnchorArray = true;
|
||||||
}
|
}
|
||||||
$token = $tokenData['value'];
|
$token = $tokenData['value'];
|
||||||
@@ -1712,7 +1837,7 @@ class Calculation extends CalculationLocale
|
|||||||
return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
|
return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line
|
} elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore offsetAccess.invalidOffset ($token is mixed)
|
||||||
// if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
|
// if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
|
||||||
// We must have two operands, error if we don't
|
// We must have two operands, error if we don't
|
||||||
$operand2Data = $stack->pop();
|
$operand2Data = $stack->pop();
|
||||||
@@ -1830,7 +1955,7 @@ class Calculation extends CalculationLocale
|
|||||||
if ($breakNeeded) {
|
if ($breakNeeded) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
|
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow);
|
||||||
if ($pCellParent !== null && $this->spreadsheet !== null) {
|
if ($pCellParent !== null && $this->spreadsheet !== null) {
|
||||||
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
|
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
|
||||||
} else {
|
} else {
|
||||||
@@ -1901,7 +2026,7 @@ class Calculation extends CalculationLocale
|
|||||||
} elseif (Information\ErrorValue::isError($operand2)) {
|
} elseif (Information\ErrorValue::isError($operand2)) {
|
||||||
$result = $operand2;
|
$result = $operand2;
|
||||||
} else {
|
} else {
|
||||||
$result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line
|
$result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore binaryOp.invalid (unwrapresult can return mixed rather than string)
|
||||||
$result = StringHelper::substring(
|
$result = StringHelper::substring(
|
||||||
$result,
|
$result,
|
||||||
0,
|
0,
|
||||||
@@ -1934,8 +2059,8 @@ class Calculation extends CalculationLocale
|
|||||||
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
|
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
|
||||||
$stack->push('Error', ExcelError::null(), null);
|
$stack->push('Error', ExcelError::null(), null);
|
||||||
} else {
|
} else {
|
||||||
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
|
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
|
||||||
. Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
|
. Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
|
||||||
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
|
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
|
||||||
$stack->push('Value', $cellIntersect, $cellRef);
|
$stack->push('Value', $cellIntersect, $cellRef);
|
||||||
}
|
}
|
||||||
@@ -1950,13 +2075,13 @@ class Calculation extends CalculationLocale
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} elseif (($token === '~') || ($token === '%')) {
|
} elseif (($token === '~') || ($token === '%')) { // @phpstan-ignore booleanOr.alwaysFalse (phpstan says token can't be plain string), identical.alwaysFalse (ditto), identical.alwaysFalse (ditto)
|
||||||
// if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
|
// if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
|
||||||
if (($arg = $stack->pop()) === null) {
|
if (($arg = $stack->pop()) === null) {
|
||||||
return $this->raiseFormulaError('Internal error - Operand value missing from stack');
|
return $this->raiseFormulaError('Internal error - Operand value missing from stack');
|
||||||
}
|
}
|
||||||
$arg = $arg['value'];
|
$arg = $arg['value'];
|
||||||
if ($token === '~') {
|
if ($token === '~') { // @phpstan-ignore identical.alwaysFalse (phpstan says token can't be plain string)
|
||||||
$this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
|
$this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
|
||||||
$multiplier = -1;
|
$multiplier = -1;
|
||||||
} else {
|
} else {
|
||||||
@@ -1988,20 +2113,27 @@ class Calculation extends CalculationLocale
|
|||||||
} else {
|
} else {
|
||||||
$this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
|
$this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
|
||||||
}
|
}
|
||||||
} elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
|
} elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
|
||||||
$cellRef = null;
|
$cellRef = null;
|
||||||
|
|
||||||
/* Phpstan says matches[8/9/10] is never set,
|
/* Phpstan says matches[8/9/10] is never set,
|
||||||
and code coverage report seems to confirm.
|
and code coverage report seems to confirm.
|
||||||
Appease PhpStan for now;
|
regex101.com confirms - only 7 capturing groups.
|
||||||
probably delete this block later.
|
My theory is that this code expected regexp to
|
||||||
|
match cell *or* cellRange, but it does not
|
||||||
|
match the latter. Retain the code for now in case
|
||||||
|
we do want to add the range match later.
|
||||||
|
Probably delete this block later.
|
||||||
|
Until delete happens, turn code coverage off.
|
||||||
*/
|
*/
|
||||||
if (isset($matches[self::$matchIndex8])) {
|
if (isset($matches[self::$matchIndex8])) {
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
if ($cell === null) {
|
if ($cell === null) {
|
||||||
// We can't access the range, so return a REF error
|
// We can't access the range, so return a REF error
|
||||||
$cellValue = ExcelError::REF();
|
$cellValue = ExcelError::REF();
|
||||||
} else {
|
} else {
|
||||||
$cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
|
$cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
|
||||||
|
$matches[2] = (string) $matches[2];
|
||||||
if ($matches[2] > '') {
|
if ($matches[2] > '') {
|
||||||
$matches[2] = trim($matches[2], "\"'");
|
$matches[2] = trim($matches[2], "\"'");
|
||||||
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
|
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
|
||||||
@@ -2026,12 +2158,14 @@ class Calculation extends CalculationLocale
|
|||||||
$this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
|
$this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
} else {
|
} else {
|
||||||
if ($cell === null) {
|
if ($cell === null) {
|
||||||
// We can't access the cell, so return a REF error
|
// We can't access the cell, so return a REF error
|
||||||
$cellValue = ExcelError::REF();
|
$cellValue = ExcelError::REF();
|
||||||
} else {
|
} else {
|
||||||
$cellRef = $matches[6] . $matches[7];
|
$cellRef = $matches[6] . $matches[7];
|
||||||
|
$matches[2] = (string) $matches[2];
|
||||||
if ($matches[2] > '') {
|
if ($matches[2] > '') {
|
||||||
$matches[2] = trim($matches[2], "\"'");
|
$matches[2] = trim($matches[2], "\"'");
|
||||||
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
|
if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
|
||||||
@@ -2042,7 +2176,11 @@ class Calculation extends CalculationLocale
|
|||||||
if ($pCellParent !== null && $this->spreadsheet !== null) {
|
if ($pCellParent !== null && $this->spreadsheet !== null) {
|
||||||
$cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
|
$cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
|
||||||
if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
|
if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
|
||||||
|
try {
|
||||||
$cellSheet->setCellValue($cellRef, null);
|
$cellSheet->setCellValue($cellRef, null);
|
||||||
|
} catch (SpreadsheetException) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($cellSheet && $cellSheet->cellExists($cellRef)) {
|
if ($cellSheet && $cellSheet->cellExists($cellRef)) {
|
||||||
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
|
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
|
||||||
@@ -2073,7 +2211,7 @@ class Calculation extends CalculationLocale
|
|||||||
$cellValue = array_shift($cellValue);
|
$cellValue = array_shift($cellValue);
|
||||||
}
|
}
|
||||||
if (is_string($cellValue)) {
|
if (is_string($cellValue)) {
|
||||||
$cellValue = preg_replace('/"/', '""', $cellValue);
|
$cellValue = Preg::replace('/"/', '""', $cellValue);
|
||||||
}
|
}
|
||||||
$this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
|
$this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
|
||||||
}
|
}
|
||||||
@@ -2117,7 +2255,7 @@ class Calculation extends CalculationLocale
|
|||||||
$a = $argCount - $i - 1;
|
$a = $argCount - $i - 1;
|
||||||
if (
|
if (
|
||||||
($passByReference)
|
($passByReference)
|
||||||
&& (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line
|
&& (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore offsetAccess.nonOffsetAccessible (possibly need to pass 2 arguments to isset)
|
||||||
&& ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
|
&& ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
|
||||||
) {
|
) {
|
||||||
/** @var mixed[] $arg */
|
/** @var mixed[] $arg */
|
||||||
@@ -2184,7 +2322,7 @@ class Calculation extends CalculationLocale
|
|||||||
if ($functionName !== 'MKMATRIX') {
|
if ($functionName !== 'MKMATRIX') {
|
||||||
if ($this->debugLog->getWriteDebugLog()) {
|
if ($this->debugLog->getWriteDebugLog()) {
|
||||||
krsort($argArrayVals);
|
krsort($argArrayVals);
|
||||||
$this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
|
$this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore argument.type (flattenArray returns array<mixed> rather than array<string>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2221,7 +2359,7 @@ class Calculation extends CalculationLocale
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the token is a number, boolean, string or an Excel error, push it onto the stack
|
// if the token is a number, boolean, string or an Excel error, push it onto the stack
|
||||||
/** @var ?string $token */
|
/** @var null|numeric-string $token */
|
||||||
if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
|
if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
|
||||||
$excelConstant = strtoupper("$token");
|
$excelConstant = strtoupper("$token");
|
||||||
$stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
|
$stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
|
||||||
@@ -2229,7 +2367,7 @@ class Calculation extends CalculationLocale
|
|||||||
$branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
|
$branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
|
||||||
}
|
}
|
||||||
$this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
|
$this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
|
||||||
} elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line
|
} elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore function.alreadyNarrowedType (phpstan says is_bool has argument of *NEVER*?), equal.alwaysFalse (ditto)
|
||||||
/** @var array{type: string, reference: ?string} $tokenData */
|
/** @var array{type: string, reference: ?string} $tokenData */
|
||||||
$stack->push($tokenData['type'], $token, $tokenData['reference']);
|
$stack->push($tokenData['type'], $token, $tokenData['reference']);
|
||||||
if (isset($storeKey)) {
|
if (isset($storeKey)) {
|
||||||
@@ -2355,7 +2493,7 @@ class Calculation extends CalculationLocale
|
|||||||
$r = $stack->pop();
|
$r = $stack->pop();
|
||||||
$result[$x] = $r['value'];
|
$result[$x] = $r['value'];
|
||||||
}
|
}
|
||||||
} elseif (is_array($operand2) && is_array($operand1)) {
|
} elseif (is_array($operand2) /*&& is_array($operand1)*/) {
|
||||||
// Operand 1 and Operand 2 are both arrays
|
// Operand 1 and Operand 2 are both arrays
|
||||||
if (!$recursingArrays) {
|
if (!$recursingArrays) {
|
||||||
self::checkMatrixOperands($operand1, $operand2, 2);
|
self::checkMatrixOperands($operand1, $operand2, 2);
|
||||||
@@ -2368,7 +2506,7 @@ class Calculation extends CalculationLocale
|
|||||||
$result[$x] = $r['value'];
|
$result[$x] = $r['value'];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Exception('Neither operand is an arra');
|
throw new Exception('Neither operand is an array');
|
||||||
}
|
}
|
||||||
// Log the result details
|
// Log the result details
|
||||||
$this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
|
$this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
|
||||||
@@ -2378,8 +2516,8 @@ class Calculation extends CalculationLocale
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return bool|mixed[] */
|
/** @return array<mixed>|bool|string */
|
||||||
private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool
|
private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
|
||||||
{
|
{
|
||||||
// If we're dealing with matrix operations, we want a matrix result
|
// If we're dealing with matrix operations, we want a matrix result
|
||||||
if ((is_array($operand1)) || (is_array($operand2))) {
|
if ((is_array($operand1)) || (is_array($operand2))) {
|
||||||
@@ -2581,6 +2719,8 @@ class Calculation extends CalculationLocale
|
|||||||
if (!isset($aReferences[1])) {
|
if (!isset($aReferences[1])) {
|
||||||
// Single cell in range
|
// Single cell in range
|
||||||
sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
|
sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
|
||||||
|
/** @var string $currentCol */
|
||||||
|
/** @var int $currentRow */
|
||||||
if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
|
if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
|
||||||
$worksheet->setCellValue($aReferences[0], null);
|
$worksheet->setCellValue($aReferences[0], null);
|
||||||
}
|
}
|
||||||
@@ -2600,6 +2740,8 @@ class Calculation extends CalculationLocale
|
|||||||
foreach ($aReferences as $reference) {
|
foreach ($aReferences as $reference) {
|
||||||
// Extract range
|
// Extract range
|
||||||
sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
|
sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
|
||||||
|
/** @var string $currentCol */
|
||||||
|
/** @var int $currentRow */
|
||||||
if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
|
if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
|
||||||
$worksheet->setCellValue($reference, null);
|
$worksheet->setCellValue($reference, null);
|
||||||
}
|
}
|
||||||
@@ -2760,6 +2902,7 @@ class Calculation extends CalculationLocale
|
|||||||
// read constant value
|
// read constant value
|
||||||
if (str_contains($constantName, '::')) {
|
if (str_contains($constantName, '::')) {
|
||||||
[$className, $constantName] = explode('::', $constantName);
|
[$className, $constantName] = explode('::', $constantName);
|
||||||
|
/** @var class-string $className */
|
||||||
$constantReflector = new ReflectionClassConstant($className, $constantName);
|
$constantReflector = new ReflectionClassConstant($className, $constantName);
|
||||||
|
|
||||||
return $constantReflector->getValue();
|
return $constantReflector->getValue();
|
||||||
@@ -2814,10 +2957,12 @@ class Calculation extends CalculationLocale
|
|||||||
$definedNameValue = $namedRange->getValue();
|
$definedNameValue = $namedRange->getValue();
|
||||||
$definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
|
$definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
|
||||||
if ($definedNameType === 'Range') {
|
if ($definedNameType === 'Range') {
|
||||||
if (preg_match('/^(.*!)?(.*)$/', $definedNameValue, $matches) === 1) {
|
if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
|
||||||
$matches2 = trim($matches[2]);
|
$matches2 = Preg::replace(
|
||||||
$matches2 = preg_replace('/ +/', ' ∩ ', $matches2) ?? $matches2;
|
['/ +/', '/,/'],
|
||||||
$matches2 = preg_replace('/,/', ' ∪ ', $matches2) ?? $matches2;
|
[' ∩ ', ' ∪ '],
|
||||||
|
trim($matches[2])
|
||||||
|
);
|
||||||
$definedNameValue = $matches[1] . $matches2;
|
$definedNameValue = $matches[1] . $matches2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dedicated Calculation singleton for formula parsing only.
|
||||||
|
*
|
||||||
|
* This class provides an isolated instance specifically for parsing formulas
|
||||||
|
* without branch pruning, avoiding state conflicts with the main Calculation
|
||||||
|
* singleton used for cell value calculations.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CalculationParserOnly extends Calculation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Instance of this class.
|
||||||
|
*/
|
||||||
|
private static ?CalculationParserOnly $parserInstance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch pruning is disabled by default for parsing-only operations.
|
||||||
|
*/
|
||||||
|
protected bool $branchPruningEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance of this parser-only calculator.
|
||||||
|
*/
|
||||||
|
public static function getParserInstance(): self
|
||||||
|
{
|
||||||
|
if (!self::$parserInstance) {
|
||||||
|
self::$parserInstance = new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$parserInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param mixed $enabled Unused, property will always be false in this class */
|
||||||
|
public function setBranchPruningEnabled(mixed $enabled): self
|
||||||
|
{
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
-2
@@ -53,7 +53,7 @@ abstract class DatabaseAbstract
|
|||||||
|
|
||||||
/** @var callable */
|
/** @var callable */
|
||||||
$callable = 'strtoupper';
|
$callable = 'strtoupper';
|
||||||
$fieldNames = array_map($callable, array_shift($database)); //* @phpstan-ignore-line
|
$fieldNames = array_map($callable, array_shift($database)); //* @phpstan-ignore argument.type (array_shift can return mixed not array?)
|
||||||
if (is_numeric($field)) {
|
if (is_numeric($field)) {
|
||||||
$field = (int) $field - 1;
|
$field = (int) $field - 1;
|
||||||
if ($field < 0 || $field >= count($fieldNames)) {
|
if ($field < 0 || $field >= count($fieldNames)) {
|
||||||
@@ -143,7 +143,7 @@ abstract class DatabaseAbstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
$rowQuery = array_map(
|
$rowQuery = array_map(
|
||||||
fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore-line
|
fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore nullCoalesce.offset ($rowValue[0] always exists?)
|
||||||
$baseQuery
|
$baseQuery
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ class Date
|
|||||||
* Excel Function:
|
* Excel Function:
|
||||||
* DATE(year,month,day)
|
* DATE(year,month,day)
|
||||||
*
|
*
|
||||||
* PhpSpreadsheet is a lot more forgiving than MS Excel when passing non numeric values to this function.
|
* PhpSpreadsheet is a lot more forgiving than MS Excel when passing non-numeric values to this function.
|
||||||
* A Month name or abbreviation (English only at this point) such as 'January' or 'Jan' will still be accepted,
|
* A Month name or abbreviation (English only at this point) such as 'January' or 'Jan' will still be accepted,
|
||||||
* as will a day value with a suffix (e.g. '21st' rather than simply 21); again only English language.
|
* as will a day value with a suffix (e.g. '21st' rather than simply 21); again only English language.
|
||||||
*
|
*
|
||||||
|
|||||||
+14
-5
@@ -46,13 +46,22 @@ class DateValue
|
|||||||
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $dateValue);
|
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $dateValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return self::fromString2($dateValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<mixed>|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
|
||||||
|
* depending on the value of the ReturnDateType flag
|
||||||
|
*/
|
||||||
|
public static function fromString2(null|string|int|bool|float $dateValue, ?int $calendar = null): array|string|float|int|DateTime
|
||||||
|
{
|
||||||
// try to parse as date iff there is at least one digit
|
// try to parse as date iff there is at least one digit
|
||||||
if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) {
|
if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) {
|
||||||
return ExcelError::VALUE();
|
return ExcelError::VALUE();
|
||||||
}
|
}
|
||||||
|
|
||||||
$dti = new DateTimeImmutable();
|
$dti = new DateTimeImmutable();
|
||||||
$baseYear = SharedDateHelper::getExcelCalendar();
|
$baseYear = $calendar ?? SharedDateHelper::getExcelCalendar();
|
||||||
$dateValue = trim((string) $dateValue, '"');
|
$dateValue = trim((string) $dateValue, '"');
|
||||||
// Strip any ordinals because they're allowed in Excel (English only)
|
// Strip any ordinals because they're allowed in Excel (English only)
|
||||||
$dateValue = (string) preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue);
|
$dateValue = (string) preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue);
|
||||||
@@ -86,7 +95,7 @@ class DateValue
|
|||||||
return self::finalResults($PHPDateArray, $dti, $baseYear);
|
return self::finalResults($PHPDateArray, $dti, $baseYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param mixed[] $t1 */
|
/** @param list<float|int|string> $t1 */
|
||||||
private static function t1ToString(array $t1, DateTimeImmutable $dti, bool $yearFound): string
|
private static function t1ToString(array $t1, DateTimeImmutable $dti, bool $yearFound): string
|
||||||
{
|
{
|
||||||
if (count($t1) == 2) {
|
if (count($t1) == 2) {
|
||||||
@@ -118,7 +127,7 @@ class DateValue
|
|||||||
if (!Helpers::dateParseSucceeded($PHPDateArray)) {
|
if (!Helpers::dateParseSucceeded($PHPDateArray)) {
|
||||||
// If original count was 1, we've already returned.
|
// If original count was 1, we've already returned.
|
||||||
// If it was 2, we added another.
|
// If it was 2, we added another.
|
||||||
// Therefore, neither of the first 2 stroks below can fail.
|
// Therefore, neither of the first 2 strtoks below can fail.
|
||||||
$testVal1 = strtok($dateValue, '- ');
|
$testVal1 = strtok($dateValue, '- ');
|
||||||
$testVal2 = strtok('- ');
|
$testVal2 = strtok('- ');
|
||||||
$testVal3 = strtok('- ') ?: $dti->format('Y');
|
$testVal3 = strtok('- ') ?: $dti->format('Y');
|
||||||
@@ -160,9 +169,9 @@ class DateValue
|
|||||||
$day = self::getInt($PHPDateArray, 'day');
|
$day = self::getInt($PHPDateArray, 'day');
|
||||||
$year = self::getInt($PHPDateArray, 'year');
|
$year = self::getInt($PHPDateArray, 'year');
|
||||||
if (!checkdate($month, $day, $year)) {
|
if (!checkdate($month, $day, $year)) {
|
||||||
return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0) : ExcelError::VALUE();
|
return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0, calendar: $baseYear) : ExcelError::VALUE();
|
||||||
}
|
}
|
||||||
$retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true);
|
$retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true, calendar: $baseYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $retValue;
|
return $retValue;
|
||||||
|
|||||||
-2
@@ -50,12 +50,10 @@ class Days
|
|||||||
|
|
||||||
$days = ExcelError::VALUE();
|
$days = ExcelError::VALUE();
|
||||||
$diff = $PHPStartDateObject->diff($PHPEndDateObject);
|
$diff = $PHPStartDateObject->diff($PHPEndDateObject);
|
||||||
if (!is_bool($diff->days)) {
|
|
||||||
$days = $diff->days;
|
$days = $diff->days;
|
||||||
if ($diff->invert) {
|
if ($diff->invert) {
|
||||||
$days = -$days;
|
$days = -$days;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $days;
|
return $days;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ class Days360
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the number of days between two dates based on a 360 day calendar.
|
* Return the number of days between two dates based on a 360-day calendar.
|
||||||
*/
|
*/
|
||||||
private static function dateDiff360(int $startDay, int $startMonth, int $startYear, int $endDay, int $endMonth, int $endYear, bool $methodUS): int
|
private static function dateDiff360(int $startDay, int $startMonth, int $startYear, int $endDay, int $endMonth, int $endYear, bool $methodUS): int
|
||||||
{
|
{
|
||||||
|
|||||||
+28
-19
@@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Exception;
|
|||||||
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
|
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
|
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class Helpers
|
class Helpers
|
||||||
{
|
{
|
||||||
@@ -29,10 +30,10 @@ class Helpers
|
|||||||
*
|
*
|
||||||
* @return float Excel date/time serial value
|
* @return float Excel date/time serial value
|
||||||
*/
|
*/
|
||||||
public static function getDateValue(mixed $dateValue, bool $allowBool = true): float
|
public static function getDateValue(mixed $dateValue, bool $allowBool = true, ?int $calendar = null): float
|
||||||
{
|
{
|
||||||
if (is_object($dateValue)) {
|
if (is_object($dateValue)) {
|
||||||
$retval = SharedDateHelper::PHPToExcel($dateValue);
|
$retval = SharedDateHelper::PHPToExcel($dateValue, calendar: $calendar);
|
||||||
if (is_bool($retval)) {
|
if (is_bool($retval)) {
|
||||||
throw new Exception(ExcelError::VALUE());
|
throw new Exception(ExcelError::VALUE());
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ class Helpers
|
|||||||
return $retval;
|
return $retval;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::nullFalseTrueToNumber($dateValue, $allowBool);
|
self::nullFalseTrueToNumber($dateValue, $allowBool, $calendar);
|
||||||
if (!is_numeric($dateValue)) {
|
if (!is_numeric($dateValue)) {
|
||||||
$saveReturnDateType = Functions::getReturnDateType();
|
$saveReturnDateType = Functions::getReturnDateType();
|
||||||
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
|
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
|
||||||
@@ -56,6 +57,12 @@ class Helpers
|
|||||||
throw new Exception(ExcelError::NAN());
|
throw new Exception(ExcelError::NAN());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SharedDateHelper::excelToDateTimeObject((float) $dateValue, calendar: $calendar);
|
||||||
|
} catch (Throwable) {
|
||||||
|
throw new Exception(ExcelError::NAN());
|
||||||
|
}
|
||||||
|
|
||||||
return (float) $dateValue;
|
return (float) $dateValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +87,10 @@ class Helpers
|
|||||||
*
|
*
|
||||||
* @param float|int $dateValue date to be adjusted
|
* @param float|int $dateValue date to be adjusted
|
||||||
*/
|
*/
|
||||||
public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0): DateTime
|
public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0, ?int $calendar = null): DateTime
|
||||||
{
|
{
|
||||||
// Execute function
|
// Execute function
|
||||||
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
|
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue, calendar: $calendar);
|
||||||
$oMonth = (int) $PHPDateObject->format('m');
|
$oMonth = (int) $PHPDateObject->format('m');
|
||||||
$oYear = (int) $PHPDateObject->format('Y');
|
$oYear = (int) $PHPDateObject->format('Y');
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ class Helpers
|
|||||||
*
|
*
|
||||||
* @param array{year: int, month: int, day: int, hour: int, minute: int, second: int} $dateArray
|
* @param array{year: int, month: int, day: int, hour: int, minute: int, second: int} $dateArray
|
||||||
*/
|
*/
|
||||||
public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false): DateTime|float|int
|
public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false, ?int $calendar = null): DateTime|float|int
|
||||||
{
|
{
|
||||||
$retType = Functions::getReturnDateType();
|
$retType = Functions::getReturnDateType();
|
||||||
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
|
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
|
||||||
@@ -154,58 +161,60 @@ class Helpers
|
|||||||
$dateArray['day'],
|
$dateArray['day'],
|
||||||
$dateArray['hour'],
|
$dateArray['hour'],
|
||||||
$dateArray['minute'],
|
$dateArray['minute'],
|
||||||
$dateArray['second']
|
$dateArray['second'],
|
||||||
|
calendar: $calendar
|
||||||
);
|
);
|
||||||
if ($retType === Functions::RETURNDATE_EXCEL) {
|
if ($retType === Functions::RETURNDATE_EXCEL) {
|
||||||
return $noFrac ? floor($excelDateValue) : $excelDateValue;
|
return $noFrac ? floor($excelDateValue) : $excelDateValue;
|
||||||
}
|
}
|
||||||
// RETURNDATE_UNIX_TIMESTAMP)
|
// RETURNDATE_UNIX_TIMESTAMP)
|
||||||
|
|
||||||
return SharedDateHelper::excelToTimestamp($excelDateValue);
|
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return result in one of three formats.
|
* Return result in one of three formats.
|
||||||
*/
|
*/
|
||||||
public static function returnIn3FormatsFloat(float $excelDateValue): float|int|DateTime
|
public static function returnIn3FormatsFloat(float $excelDateValue, ?int $calendar = null): float|int|DateTime
|
||||||
{
|
{
|
||||||
$retType = Functions::getReturnDateType();
|
$retType = Functions::getReturnDateType();
|
||||||
if ($retType === Functions::RETURNDATE_EXCEL) {
|
if ($retType === Functions::RETURNDATE_EXCEL) {
|
||||||
return $excelDateValue;
|
return $excelDateValue;
|
||||||
}
|
}
|
||||||
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
|
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
|
||||||
return SharedDateHelper::excelToTimestamp($excelDateValue);
|
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
|
||||||
}
|
}
|
||||||
// RETURNDATE_PHP_DATETIME_OBJECT
|
// RETURNDATE_PHP_DATETIME_OBJECT
|
||||||
|
|
||||||
return SharedDateHelper::excelToDateTimeObject($excelDateValue);
|
return SharedDateHelper::excelToDateTimeObject($excelDateValue, calendar: $calendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return result in one of three formats.
|
* Return result in one of three formats.
|
||||||
*/
|
*/
|
||||||
public static function returnIn3FormatsObject(DateTime $PHPDateObject): DateTime|float|int
|
public static function returnIn3FormatsObject(DateTime $PHPDateObject, ?int $calendar = null): DateTime|float|int
|
||||||
{
|
{
|
||||||
$retType = Functions::getReturnDateType();
|
$retType = Functions::getReturnDateType();
|
||||||
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
|
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
|
||||||
return $PHPDateObject;
|
return $PHPDateObject;
|
||||||
}
|
}
|
||||||
if ($retType === Functions::RETURNDATE_EXCEL) {
|
if ($retType === Functions::RETURNDATE_EXCEL) {
|
||||||
return (float) SharedDateHelper::PHPToExcel($PHPDateObject);
|
return (float) SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
|
||||||
}
|
}
|
||||||
// RETURNDATE_UNIX_TIMESTAMP
|
// RETURNDATE_UNIX_TIMESTAMP
|
||||||
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject);
|
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
|
||||||
$stamp = is_bool($stamp) ? ((int) $stamp) : $stamp;
|
$stamp = is_bool($stamp) ? ((int) $stamp) : $stamp;
|
||||||
|
|
||||||
return SharedDateHelper::excelToTimestamp($stamp);
|
return SharedDateHelper::excelToTimestamp($stamp, calendar: $calendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function baseDate(): int
|
private static function baseDate(?int $calendar): int
|
||||||
{
|
{
|
||||||
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
|
if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (SharedDateHelper::getExcelCalendar() === SharedDateHelper::CALENDAR_MAC_1904) {
|
$calendar ??= SharedDateHelper::getExcelCalendar();
|
||||||
|
if ($calendar === SharedDateHelper::CALENDAR_MAC_1904) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,10 +224,10 @@ class Helpers
|
|||||||
/**
|
/**
|
||||||
* Many functions accept null/false/true argument treated as 0/0/1.
|
* Many functions accept null/false/true argument treated as 0/0/1.
|
||||||
*/
|
*/
|
||||||
public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true): void
|
public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true, ?int $calendar = null): void
|
||||||
{
|
{
|
||||||
$number = Functions::flattenSingleValue($number);
|
$number = Functions::flattenSingleValue($number);
|
||||||
$nullVal = self::baseDate();
|
$nullVal = self::baseDate($calendar);
|
||||||
if ($number === null) {
|
if ($number === null) {
|
||||||
$number = $nullVal;
|
$number = $nullVal;
|
||||||
} elseif ($allowBool && is_bool($number)) {
|
} elseif ($allowBool && is_bool($number)) {
|
||||||
|
|||||||
+17
@@ -4,7 +4,9 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
|
|||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
|
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
|
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
|
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class TimeParts
|
class TimeParts
|
||||||
{
|
{
|
||||||
@@ -44,6 +46,11 @@ class TimeParts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute function
|
// Execute function
|
||||||
|
try {
|
||||||
|
SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return ExcelError::NAN();
|
||||||
|
}
|
||||||
$timeValue = fmod($timeValue, 1);
|
$timeValue = fmod($timeValue, 1);
|
||||||
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
SharedDateHelper::roundMicroseconds($timeValue);
|
SharedDateHelper::roundMicroseconds($timeValue);
|
||||||
@@ -85,6 +92,11 @@ class TimeParts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute function
|
// Execute function
|
||||||
|
try {
|
||||||
|
SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return ExcelError::NAN();
|
||||||
|
}
|
||||||
$timeValue = fmod($timeValue, 1);
|
$timeValue = fmod($timeValue, 1);
|
||||||
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
SharedDateHelper::roundMicroseconds($timeValue);
|
SharedDateHelper::roundMicroseconds($timeValue);
|
||||||
@@ -126,6 +138,11 @@ class TimeParts
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute function
|
// Execute function
|
||||||
|
try {
|
||||||
|
SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return ExcelError::NAN();
|
||||||
|
}
|
||||||
$timeValue = fmod($timeValue, 1);
|
$timeValue = fmod($timeValue, 1);
|
||||||
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
|
||||||
SharedDateHelper::roundMicroseconds($timeValue);
|
SharedDateHelper::roundMicroseconds($timeValue);
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@
|
|||||||
namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
|
namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
|
||||||
|
|
||||||
use Composer\Pcre\Preg;
|
use Composer\Pcre\Preg;
|
||||||
use Datetime;
|
use DateTime;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
|
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
|
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
|
||||||
@@ -45,12 +45,12 @@ class TimeValue
|
|||||||
* Date information in time_text is ignored.
|
* Date information in time_text is ignored.
|
||||||
* Or can be an array of date/time values
|
* Or can be an array of date/time values
|
||||||
*
|
*
|
||||||
* @return array<mixed>|Datetime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
|
* @return array<mixed>|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
|
||||||
* depending on the value of the ReturnDateType flag
|
* depending on the value of the ReturnDateType flag
|
||||||
* If an array of numbers is passed as the argument, then the returned result will also be an array
|
* If an array of numbers is passed as the argument, then the returned result will also be an array
|
||||||
* with the same dimensions
|
* with the same dimensions
|
||||||
*/
|
*/
|
||||||
public static function fromString(null|array|string|int|bool|float $timeValue): array|string|Datetime|int|float
|
public static function fromString(null|array|string|int|bool|float $timeValue): array|string|DateTime|int|float
|
||||||
{
|
{
|
||||||
if (is_array($timeValue)) {
|
if (is_array($timeValue)) {
|
||||||
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $timeValue);
|
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $timeValue);
|
||||||
@@ -88,7 +88,7 @@ class TimeValue
|
|||||||
} elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
|
} elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
|
||||||
$retValue = (int) SharedDateHelper::excelToTimestamp($excelDateValue + 25569) - 3600;
|
$retValue = (int) SharedDateHelper::excelToTimestamp($excelDateValue + 25569) - 3600;
|
||||||
} else {
|
} else {
|
||||||
$retValue = new Datetime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
|
$retValue = new DateTime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -78,8 +78,8 @@ class Logger
|
|||||||
{
|
{
|
||||||
// Only write the debug log if logging is enabled
|
// Only write the debug log if logging is enabled
|
||||||
if ($this->writeDebugLog) {
|
if ($this->writeDebugLog) {
|
||||||
$message = sprintf($message, ...$args); //* @phpstan-ignore-line
|
$message = sprintf($message, ...$args); //* @phpstan-ignore argument.type (...$args could contain items that sprintf can't handle)
|
||||||
$cellReference = implode(' -> ', $this->cellStack->showStack());
|
$cellReference = implode(' -> ', $this->cellStack->showStack()); //* @phpstan-ignore argument.type (showStack can return array<mixed> not array<string>)
|
||||||
if ($this->echoDebugLog) {
|
if ($this->echoDebugLog) {
|
||||||
echo $cellReference,
|
echo $cellReference,
|
||||||
($this->cellStack->count() > 0 ? ' => ' : ''),
|
($this->cellStack->count() > 0 ? ' => ' : ''),
|
||||||
|
|||||||
+6
-4
@@ -204,8 +204,9 @@ final class StructuredReference implements Operand, Stringable
|
|||||||
/** @var string $reference */
|
/** @var string $reference */
|
||||||
$reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
|
$reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
|
||||||
|
|
||||||
foreach ($this->columns as $columnId => $columnName) {
|
foreach ($this->columns as $columnId => $columnNamex) {
|
||||||
$columnName = str_replace("\u{a0}", ' ', $columnName); //* @phpstan-ignore-line
|
/** @var string $columnNamex */
|
||||||
|
$columnName = str_replace("\u{a0}", ' ', $columnNamex);
|
||||||
$reference = $this->adjustRowReference($columnName, $reference, $cell, $columnId);
|
$reference = $this->adjustRowReference($columnName, $reference, $cell, $columnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +339,9 @@ final class StructuredReference implements Operand, Stringable
|
|||||||
private function getColumnsForColumnReference(string $reference, int $startRow, int $endRow): string
|
private function getColumnsForColumnReference(string $reference, int $startRow, int $endRow): string
|
||||||
{
|
{
|
||||||
$columnsSelected = false;
|
$columnsSelected = false;
|
||||||
foreach ($this->columns as $columnId => $columnName) {
|
foreach ($this->columns as $columnId => $columnNamex) {
|
||||||
$columnName = str_replace("\u{a0}", ' ', $columnName ?? ''); //* @phpstan-ignore-line
|
/** @var ?string $columnNamex */
|
||||||
|
$columnName = str_replace("\u{a0}", ' ', $columnNamex ?? '');
|
||||||
$cellFrom = "{$columnId}{$startRow}";
|
$cellFrom = "{$columnId}{$startRow}";
|
||||||
$cellTo = "{$columnId}{$endRow}";
|
$cellTo = "{$columnId}{$endRow}";
|
||||||
$cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
|
$cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
|
||||||
|
|||||||
Vendored
+1
-1
@@ -150,7 +150,7 @@ class ConvertDecimal extends ConvertBase
|
|||||||
/**
|
/**
|
||||||
* toOctal.
|
* toOctal.
|
||||||
*
|
*
|
||||||
* Return an decimal value as octal.
|
* Return a decimal value as octal.
|
||||||
*
|
*
|
||||||
* Excel Function:
|
* Excel Function:
|
||||||
* DEC2OCT(x[,places])
|
* DEC2OCT(x[,places])
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ class ErfC
|
|||||||
* Note: In Excel 2007 or earlier, if you input a negative value for the lower bound argument,
|
* Note: In Excel 2007 or earlier, if you input a negative value for the lower bound argument,
|
||||||
* the function would return a #NUM! error. However, in Excel 2010, the function algorithm was
|
* the function would return a #NUM! error. However, in Excel 2010, the function algorithm was
|
||||||
* improved, so that it can now calculate the function for both positive and negative x values.
|
* improved, so that it can now calculate the function for both positive and negative x values.
|
||||||
* PhpSpreadsheet follows Excel 2010 behaviour, and accepts nagative arguments.
|
* PhpSpreadsheet follows Excel 2010 behaviour, and accepts negative arguments.
|
||||||
*
|
*
|
||||||
* Excel Function:
|
* Excel Function:
|
||||||
* ERFC(x)
|
* ERFC(x)
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ class Interest
|
|||||||
* =ISPMT(interest_rate, period, number_payments, pv)
|
* =ISPMT(interest_rate, period, number_payments, pv)
|
||||||
*
|
*
|
||||||
* @param mixed $interestRate is the interest rate for the investment
|
* @param mixed $interestRate is the interest rate for the investment
|
||||||
* @param mixed $period is the period to calculate the interest rate. It must be betweeen 1 and number_payments.
|
* @param mixed $period is the period to calculate the interest rate. It must be between 1 and number_payments.
|
||||||
* @param mixed $numberOfPeriods is the number of payments for the annuity
|
* @param mixed $numberOfPeriods is the number of payments for the annuity
|
||||||
* @param mixed $principleRemaining is the loan amount or present value of the payments
|
* @param mixed $principleRemaining is the loan amount or present value of the payments
|
||||||
*/
|
*/
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace PhpOffice\PhpSpreadsheet\Calculation\Financial\CashFlow\Constant\Periodic;
|
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Calculation\Financial\Constants as FinancialConstants;
|
|
||||||
|
|
||||||
class InterestAndPrincipal
|
|
||||||
{
|
|
||||||
protected float $interest;
|
|
||||||
|
|
||||||
protected float $principal;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
float $rate = 0.0,
|
|
||||||
int $period = 0,
|
|
||||||
int $numberOfPeriods = 0,
|
|
||||||
float $presentValue = 0,
|
|
||||||
float $futureValue = 0,
|
|
||||||
int $type = FinancialConstants::PAYMENT_END_OF_PERIOD
|
|
||||||
) {
|
|
||||||
$payment = Payments::annuity($rate, $numberOfPeriods, $presentValue, $futureValue, $type);
|
|
||||||
$capital = $presentValue;
|
|
||||||
$interest = 0.0;
|
|
||||||
$principal = 0.0;
|
|
||||||
for ($i = 1; $i <= $period; ++$i) {
|
|
||||||
$interest = ($type === FinancialConstants::PAYMENT_BEGINNING_OF_PERIOD && $i == 1) ? 0 : -$capital * $rate;
|
|
||||||
$principal = (float) $payment - $interest;
|
|
||||||
$capital += $principal;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->interest = $interest;
|
|
||||||
$this->principal = $principal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function interest(): float
|
|
||||||
{
|
|
||||||
return $this->interest;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function principal(): float
|
|
||||||
{
|
|
||||||
return $this->principal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -166,7 +166,7 @@ class NonPeriodic
|
|||||||
$foundneg = false;
|
$foundneg = false;
|
||||||
for ($i = 0; $i < $valCount; ++$i) {
|
for ($i = 0; $i < $valCount; ++$i) {
|
||||||
$fld = $values[$i];
|
$fld = $values[$i];
|
||||||
if (!is_numeric($fld)) { //* @phpstan-ignore-line
|
if (!is_numeric($fld)) {
|
||||||
return ExcelError::VALUE();
|
return ExcelError::VALUE();
|
||||||
} elseif ($fld > 0) {
|
} elseif ($fld > 0) {
|
||||||
$foundpos = true;
|
$foundpos = true;
|
||||||
|
|||||||
+6
-15
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
|
|||||||
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
|
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
|
||||||
* software or the use or other dealings in the software.
|
* software or the use or other dealings in the software.
|
||||||
*
|
*
|
||||||
|
* The following links are no longer valid.
|
||||||
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
|
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
|
||||||
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
|
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
|
||||||
|
*
|
||||||
|
* @deprecated 5.5.0 No replacement.
|
||||||
*/
|
*/
|
||||||
class FormulaParser
|
class FormulaParser
|
||||||
{
|
{
|
||||||
@@ -463,16 +466,8 @@ class FormulaParser
|
|||||||
$tokenCount = count($tokens1);
|
$tokenCount = count($tokens1);
|
||||||
for ($i = 0; $i < $tokenCount; ++$i) {
|
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||||
$token = $tokens1[$i];
|
$token = $tokens1[$i];
|
||||||
if (isset($tokens1[$i - 1])) {
|
$previousToken = $tokens1[$i - 1] ?? null;
|
||||||
$previousToken = $tokens1[$i - 1];
|
$nextToken = $tokens1[$i + 1] ?? null;
|
||||||
} else {
|
|
||||||
$previousToken = null;
|
|
||||||
}
|
|
||||||
if (isset($tokens1[$i + 1])) {
|
|
||||||
$nextToken = $tokens1[$i + 1];
|
|
||||||
} else {
|
|
||||||
$nextToken = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($token->getTokenType() != FormulaToken::TOKEN_TYPE_WHITESPACE) {
|
if ($token->getTokenType() != FormulaToken::TOKEN_TYPE_WHITESPACE) {
|
||||||
$tokens2[] = $token;
|
$tokens2[] = $token;
|
||||||
@@ -518,11 +513,7 @@ class FormulaParser
|
|||||||
$tokenCount = count($tokens2);
|
$tokenCount = count($tokens2);
|
||||||
for ($i = 0; $i < $tokenCount; ++$i) {
|
for ($i = 0; $i < $tokenCount; ++$i) {
|
||||||
$token = $tokens2[$i];
|
$token = $tokens2[$i];
|
||||||
if (isset($tokens2[$i - 1])) {
|
$previousToken = $tokens2[$i - 1] ?? null;
|
||||||
$previousToken = $tokens2[$i - 1];
|
|
||||||
} else {
|
|
||||||
$previousToken = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($token->getTokenType() == FormulaToken::TOKEN_TYPE_OPERATORINFIX && $token->getValue() == '-') {
|
if ($token->getTokenType() == FormulaToken::TOKEN_TYPE_OPERATORINFIX && $token->getValue() == '-') {
|
||||||
if ($i == 0) {
|
if ($i == 0) {
|
||||||
|
|||||||
+3
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
|
|||||||
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
|
* whether in an action of contract, tort or otherwise, arising from, out of or in connection with the
|
||||||
* software or the use or other dealings in the software.
|
* software or the use or other dealings in the software.
|
||||||
*
|
*
|
||||||
|
* The following links are no longer valid.
|
||||||
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
|
* https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
|
||||||
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
|
* https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
|
||||||
|
*
|
||||||
|
* @deprecated 5.5.0 No replacement.
|
||||||
*/
|
*/
|
||||||
class FormulaToken
|
class FormulaToken
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-12
@@ -9,7 +9,7 @@ class FunctionArray extends CalculationBase
|
|||||||
*
|
*
|
||||||
* @var array<string, array{category: string, functionCall: string|string[], argumentCount: string, passCellReference?: bool, passByReference?: bool[], custom?: bool}>
|
* @var array<string, array{category: string, functionCall: string|string[], argumentCount: string, passCellReference?: bool, passByReference?: bool[], custom?: bool}>
|
||||||
*/
|
*/
|
||||||
protected static array $phpSpreadsheetFunctions = [
|
protected static array $phpSpreadsheetFunctions = [ // @phpstan-ignore property.defaultValue (Phpstan is wrong)
|
||||||
'ABS' => [
|
'ABS' => [
|
||||||
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
||||||
'functionCall' => [MathTrig\Absolute::class, 'evaluate'],
|
'functionCall' => [MathTrig\Absolute::class, 'evaluate'],
|
||||||
@@ -149,7 +149,7 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'BAHTTEXT' => [
|
'BAHTTEXT' => [
|
||||||
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [TextData\Thai::class, 'getBahtText'],
|
||||||
'argumentCount' => '1',
|
'argumentCount' => '1',
|
||||||
],
|
],
|
||||||
'BASE' => [
|
'BASE' => [
|
||||||
@@ -405,6 +405,7 @@ class FunctionArray extends CalculationBase
|
|||||||
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
||||||
'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
|
'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
|
||||||
'argumentCount' => '1+',
|
'argumentCount' => '1+',
|
||||||
|
'passCellReference' => true,
|
||||||
],
|
],
|
||||||
'CONFIDENCE' => [
|
'CONFIDENCE' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
@@ -919,7 +920,7 @@ class FunctionArray extends CalculationBase
|
|||||||
'FLOOR' => [
|
'FLOOR' => [
|
||||||
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
||||||
'functionCall' => [MathTrig\Floor::class, 'floor'],
|
'functionCall' => [MathTrig\Floor::class, 'floor'],
|
||||||
'argumentCount' => '1-2', // Excel requries 2, Ods/Gnumeric 1-2
|
'argumentCount' => '1-2', // Excel requires 2, Ods/Gnumeric 1-2
|
||||||
],
|
],
|
||||||
'FLOOR.MATH' => [
|
'FLOOR.MATH' => [
|
||||||
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
||||||
@@ -1284,8 +1285,9 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'INFO' => [
|
'INFO' => [
|
||||||
'category' => Category::CATEGORY_INFORMATION,
|
'category' => Category::CATEGORY_INFORMATION,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [Information\Info::class, 'getInfo'],
|
||||||
'argumentCount' => '1',
|
'argumentCount' => '1',
|
||||||
|
'passCellReference' => true,
|
||||||
],
|
],
|
||||||
'INT' => [
|
'INT' => [
|
||||||
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
'category' => Category::CATEGORY_MATH_AND_TRIG,
|
||||||
@@ -2335,17 +2337,17 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'T.DIST' => [
|
'T.DIST' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDist'],
|
||||||
'argumentCount' => '3',
|
'argumentCount' => '3',
|
||||||
],
|
],
|
||||||
'T.DIST.2T' => [
|
'T.DIST.2T' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDot2T'],
|
||||||
'argumentCount' => '2',
|
'argumentCount' => '2',
|
||||||
],
|
],
|
||||||
'T.DIST.RT' => [
|
'T.DIST.RT' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDotRT'],
|
||||||
'argumentCount' => '2',
|
'argumentCount' => '2',
|
||||||
],
|
],
|
||||||
'TEXT' => [
|
'TEXT' => [
|
||||||
@@ -2425,12 +2427,12 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'T.INV' => [
|
'T.INV' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
|
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotInv'],
|
||||||
'argumentCount' => '2',
|
'argumentCount' => '2',
|
||||||
],
|
],
|
||||||
'T.INV.2T' => [
|
'T.INV.2T' => [
|
||||||
'category' => Category::CATEGORY_STATISTICAL,
|
'category' => Category::CATEGORY_STATISTICAL,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
|
||||||
'argumentCount' => '2',
|
'argumentCount' => '2',
|
||||||
],
|
],
|
||||||
'TODAY' => [
|
'TODAY' => [
|
||||||
@@ -2495,12 +2497,12 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'UNICHAR' => [
|
'UNICHAR' => [
|
||||||
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
||||||
'functionCall' => [TextData\CharacterConvert::class, 'character'],
|
'functionCall' => [TextData\CharacterConvert::class, 'characterUnicode'],
|
||||||
'argumentCount' => '1',
|
'argumentCount' => '1',
|
||||||
],
|
],
|
||||||
'UNICODE' => [
|
'UNICODE' => [
|
||||||
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
'category' => Category::CATEGORY_TEXT_AND_DATA,
|
||||||
'functionCall' => [TextData\CharacterConvert::class, 'code'],
|
'functionCall' => [TextData\CharacterConvert::class, 'codeUnicode'],
|
||||||
'argumentCount' => '1',
|
'argumentCount' => '1',
|
||||||
],
|
],
|
||||||
'UNIQUE' => [
|
'UNIQUE' => [
|
||||||
@@ -2577,6 +2579,7 @@ class FunctionArray extends CalculationBase
|
|||||||
'category' => Category::CATEGORY_WEB,
|
'category' => Category::CATEGORY_WEB,
|
||||||
'functionCall' => [Web\Service::class, 'webService'],
|
'functionCall' => [Web\Service::class, 'webService'],
|
||||||
'argumentCount' => '1',
|
'argumentCount' => '1',
|
||||||
|
'passCellReference' => true,
|
||||||
],
|
],
|
||||||
'WEEKDAY' => [
|
'WEEKDAY' => [
|
||||||
'category' => Category::CATEGORY_DATE_AND_TIME,
|
'category' => Category::CATEGORY_DATE_AND_TIME,
|
||||||
@@ -2625,7 +2628,7 @@ class FunctionArray extends CalculationBase
|
|||||||
],
|
],
|
||||||
'XLOOKUP' => [
|
'XLOOKUP' => [
|
||||||
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
|
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
|
||||||
'functionCall' => [Functions::class, 'DUMMY'],
|
'functionCall' => [LookupRef\XLookup::class, 'lookup'],
|
||||||
'argumentCount' => '3-6',
|
'argumentCount' => '3-6',
|
||||||
],
|
],
|
||||||
'XNPV' => [
|
'XNPV' => [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
namespace PhpOffice\PhpSpreadsheet\Calculation;
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
|
||||||
|
|
||||||
@@ -360,4 +361,47 @@ class Functions
|
|||||||
|
|
||||||
return $coordinate;
|
return $coordinate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param mixed[] $array */
|
||||||
|
public static function convertArrayToCellRange(array $array): string
|
||||||
|
{
|
||||||
|
$retVal = '';
|
||||||
|
$lastRow = $lastColumn = $firstRow = $firstColumn = 0;
|
||||||
|
foreach ($array as $rowkey => $row) {
|
||||||
|
if (!is_array($row) || !is_int($rowkey) || $rowkey < 1) {
|
||||||
|
$firstRow = 0;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($firstRow > $rowkey || $firstRow === 0) {
|
||||||
|
$firstRow = $rowkey;
|
||||||
|
}
|
||||||
|
if ($lastRow < $rowkey) {
|
||||||
|
$lastRow = $rowkey;
|
||||||
|
}
|
||||||
|
foreach ($row as $colkey => $cellValue) {
|
||||||
|
if (!preg_match('/^[A-Z]{1,3}$/', $colkey)) {
|
||||||
|
$firstRow = 0;
|
||||||
|
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
$column = Coordinate::columnIndexFromString($colkey);
|
||||||
|
if ($firstColumn > $column || $firstColumn === 0) {
|
||||||
|
$firstColumn = $column;
|
||||||
|
}
|
||||||
|
if ($lastColumn < $column) {
|
||||||
|
$lastColumn = $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($firstRow > 0 && $firstColumn > 0 && ($firstRow !== $lastRow || $firstColumn !== $lastColumn)) {
|
||||||
|
$retVal = Coordinate::stringFromColumnIndex($firstColumn)
|
||||||
|
. $firstRow
|
||||||
|
. ':'
|
||||||
|
. Coordinate::stringFromColumnIndex($lastColumn)
|
||||||
|
. $lastRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $retVal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PhpOffice\PhpSpreadsheet\Calculation\Information;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
|
||||||
|
class Info
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public static bool $infoSupported = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INFO.
|
||||||
|
*
|
||||||
|
* Excel Function:
|
||||||
|
* =INFO(type_text)
|
||||||
|
*
|
||||||
|
* @param mixed $typeText String specifying the type of information to be returned
|
||||||
|
* @param ?Cell $cell Cell from which spreadsheet information is retrieved
|
||||||
|
*
|
||||||
|
* @return int|string The requested information about the current operating environment
|
||||||
|
*/
|
||||||
|
public static function getInfo(mixed $typeText = '', ?Cell $cell = null): int|string
|
||||||
|
{
|
||||||
|
if (!self::$infoSupported) {
|
||||||
|
return Functions::DUMMY();
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (is_string($typeText) ? strtolower($typeText) : $typeText) {
|
||||||
|
'directory' => '/',
|
||||||
|
'numfile' => $cell?->getWorksheetOrNull()?->getParent()?->getSheetCount() ?? 1,
|
||||||
|
'origin' => '$A:$A$1',
|
||||||
|
'osversion' => 'PHP ' . PHP_VERSION,
|
||||||
|
'recalc' => 'Automatic',
|
||||||
|
'release' => PHP_VERSION,
|
||||||
|
'system' => 'PHP',
|
||||||
|
'memavail', 'memused', 'totmem' => ExcelError::NA(),
|
||||||
|
default => ExcelError::VALUE(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -52,8 +52,10 @@ class Value
|
|||||||
if (!empty($worksheet) && $cell->getWorksheet()->getParentOrThrow()->getSheetByName($worksheet) === null) {
|
if (!empty($worksheet) && $cell->getWorksheet()->getParentOrThrow()->getSheetByName($worksheet) === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
[$column, $row] = Coordinate::indexesFromString($cellValue ?? '');
|
|
||||||
if ($column > 16384 || $row > 1048576) {
|
try {
|
||||||
|
[$column, $row] = Coordinate::indexesFromString($cellValue ?? ''); //* @phpstan-ignore nullCoalesce.variable (Not sure whether Phpstan is correct)
|
||||||
|
} catch (SpreadsheetException) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +267,7 @@ class Value
|
|||||||
if (is_bool($value)) {
|
if (is_bool($value)) {
|
||||||
return (int) $value;
|
return (int) $value;
|
||||||
}
|
}
|
||||||
if (is_string($value) && substr($value, 0, 1) === '#') {
|
if (is_string($value) && str_starts_with($value, '#')) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ class Conditional
|
|||||||
{
|
{
|
||||||
$condition = ($condition === null) ? true : Functions::flattenSingleValue($condition);
|
$condition = ($condition === null) ? true : Functions::flattenSingleValue($condition);
|
||||||
|
|
||||||
if (ErrorValue::isError($condition)) {
|
if (ErrorValue::isError($condition, true)) {
|
||||||
return $condition;
|
return $condition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ class ChooseRowsEtc
|
|||||||
*/
|
*/
|
||||||
public static function transpose(array $array): array
|
public static function transpose(array $array): array
|
||||||
{
|
{
|
||||||
return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore-line
|
return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore argument.type (phstan is wrong), argument.type (phpstan is wrong)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return mixed[] */
|
/** @return mixed[] */
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ class ExcelMatch
|
|||||||
return $e->getMessage();
|
return $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// MATCH() is not case sensitive, so we convert lookup value to be lower cased if it's a string type.
|
// MATCH() is not case-sensitive, so we convert lookup value to be lower cased if it's a string type.
|
||||||
if (is_string($lookupValue)) {
|
if (is_string($lookupValue)) {
|
||||||
$lookupValue = StringHelper::strToLower($lookupValue);
|
$lookupValue = StringHelper::strToLower($lookupValue);
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ class ExcelMatch
|
|||||||
};
|
};
|
||||||
|
|
||||||
if ($valueKey !== null) {
|
if ($valueKey !== null) {
|
||||||
return ++$valueKey; //* @phpstan-ignore-line
|
return ++$valueKey; //* @phpstan-ignore preInc.type ($valueKey can be mixed not numeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsuccessful in finding a match, return #N/A error value
|
// Unsuccessful in finding a match, return #N/A error value
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ class Filter
|
|||||||
*/
|
*/
|
||||||
private static function filterByRow(array $lookupArray, array $matchArray): array
|
private static function filterByRow(array $lookupArray, array $matchArray): array
|
||||||
{
|
{
|
||||||
$matchArray = array_values(array_column($matchArray, 0)); // @phpstan-ignore-line
|
$matchArray = array_values(array_column($matchArray, 0));
|
||||||
|
|
||||||
return array_filter(
|
return array_filter(
|
||||||
array_values($lookupArray),
|
array_values($lookupArray),
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ class Hstack
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$transpose = array_map(null, ...$inputData); //* @phpstan-ignore-line
|
$transpose = array_map(null, ...$inputData); //* @phpstan-ignore argument.type (inputData is mixed not array)
|
||||||
$returnMatrix = [];
|
$returnMatrix = [];
|
||||||
foreach ($transpose as $array) {
|
foreach ($transpose as $array) {
|
||||||
$returnMatrix[] = Functions::flattenArray($array);
|
$returnMatrix[] = Functions::flattenArray($array);
|
||||||
|
|||||||
+13
-7
@@ -23,6 +23,13 @@ class Hyperlink
|
|||||||
*/
|
*/
|
||||||
public static function set(mixed $linkURL = '', mixed $displayName = null, ?Cell $cell = null): string
|
public static function set(mixed $linkURL = '', mixed $displayName = null, ?Cell $cell = null): string
|
||||||
{
|
{
|
||||||
|
$worksheet = null;
|
||||||
|
$coordinate = '';
|
||||||
|
if ($cell !== null) {
|
||||||
|
$coordinate = $cell->getCoordinate();
|
||||||
|
$worksheet = $cell->getWorksheetOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
$linkURL = ($linkURL === null) ? '' : StringHelper::convertToString(Functions::flattenSingleValue($linkURL));
|
$linkURL = ($linkURL === null) ? '' : StringHelper::convertToString(Functions::flattenSingleValue($linkURL));
|
||||||
$displayName = ($displayName === null) ? '' : Functions::flattenSingleValue($displayName);
|
$displayName = ($displayName === null) ? '' : Functions::flattenSingleValue($displayName);
|
||||||
|
|
||||||
@@ -30,17 +37,16 @@ class Hyperlink
|
|||||||
return ExcelError::REF();
|
return ExcelError::REF();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_object($displayName)) {
|
$displayName = StringHelper::convertToString($displayName, false);
|
||||||
$displayName = $linkURL;
|
|
||||||
}
|
|
||||||
$displayName = StringHelper::convertToString($displayName);
|
|
||||||
if (trim($displayName) === '') {
|
if (trim($displayName) === '') {
|
||||||
$displayName = $linkURL;
|
$displayName = $linkURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cell->getHyperlink()
|
$worksheet?->getCell($coordinate)
|
||||||
->setUrl($linkURL);
|
->getHyperlink()
|
||||||
$cell->getHyperlink()->setTooltip($displayName);
|
->setUrl($linkURL)
|
||||||
|
->setTooltip($displayName)
|
||||||
|
->setDisplay('');
|
||||||
|
|
||||||
return $displayName;
|
return $displayName;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -106,14 +106,14 @@ class Indirect
|
|||||||
*/
|
*/
|
||||||
private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
|
private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
|
||||||
{
|
{
|
||||||
return Calculation::getInstance($worksheet !== null ? $worksheet->getParent() : null)
|
return Calculation::getInstance($worksheet?->getParent())
|
||||||
->extractCellRange($cellAddress, $worksheet, false, createCell: true);
|
->extractCellRange($cellAddress, $worksheet, false, createCell: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function handleRowColumnRanges(?Worksheet $worksheet, string $start, string $end): string
|
private static function handleRowColumnRanges(?Worksheet $worksheet, string $start, string $end): string
|
||||||
{
|
{
|
||||||
// Being lazy, we're only checking a single row/column to get the max
|
// Being lazy, we're only checking a single row/column to get the max
|
||||||
if (ctype_digit($start) && $start <= 1048576) {
|
if (ctype_digit($start) && $start <= AddressRange::MAX_ROW) {
|
||||||
// Max 16,384 columns for Excel2007
|
// Max 16,384 columns for Excel2007
|
||||||
$endColRef = ($worksheet !== null) ? $worksheet->getHighestDataColumn((int) $start) : AddressRange::MAX_COLUMN;
|
$endColRef = ($worksheet !== null) ? $worksheet->getHighestDataColumn((int) $start) : AddressRange::MAX_COLUMN;
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -40,15 +40,15 @@ class Lookup
|
|||||||
$lookupColumns = self::columnCount($lookupVector);
|
$lookupColumns = self::columnCount($lookupVector);
|
||||||
}
|
}
|
||||||
|
|
||||||
$resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore-line
|
$resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore argument.type (resultVector can be mixed not array<array<mixed>>)
|
||||||
|
|
||||||
if ($lookupRows === 2 && !$hasResultVector) {
|
if ($lookupRows === 2 && !$hasResultVector) {
|
||||||
$resultVector = array_pop($lookupVector);
|
$resultVector = array_pop($lookupVector);
|
||||||
$lookupVector = array_shift($lookupVector);
|
$lookupVector = array_shift($lookupVector);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var mixed[] $lookupVector */
|
/** @var array<int, mixed> $lookupVector */
|
||||||
/** @var mixed[] $resultVector */
|
/** @var array<int, mixed> $resultVector */
|
||||||
if ($lookupColumns !== 2) {
|
if ($lookupColumns !== 2) {
|
||||||
$lookupVector = self::verifyLookupValues($lookupVector, $resultVector);
|
$lookupVector = self::verifyLookupValues($lookupVector, $resultVector);
|
||||||
}
|
}
|
||||||
@@ -57,8 +57,8 @@ class Lookup
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed[] $lookupVector
|
* @param array<int, mixed> $lookupVector
|
||||||
* @param mixed[] $resultVector
|
* @param array<int, mixed> $resultVector
|
||||||
*
|
*
|
||||||
* @return mixed[]
|
* @return mixed[]
|
||||||
*/
|
*/
|
||||||
@@ -80,6 +80,7 @@ class Lookup
|
|||||||
if (is_array($dataValue2)) {
|
if (is_array($dataValue2)) {
|
||||||
$dataValue2 = array_shift($dataValue2);
|
$dataValue2 = array_shift($dataValue2);
|
||||||
}
|
}
|
||||||
|
/** @var int $key2 */
|
||||||
$value = [$key1 => $dataValue1, $key2 => $dataValue2];
|
$value = [$key1 => $dataValue1, $key2 => $dataValue2];
|
||||||
}
|
}
|
||||||
unset($value);
|
unset($value);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user