Codex's updates...

This commit is contained in:
2026-10-18 19:27:17 -04:00
parent a1643eb1b7
commit 02223083f6
338 changed files with 14757 additions and 7986 deletions
+4
View File
@@ -0,0 +1,4 @@
/xml/settings.local.xml
/.env
/.tools/
/tests/.local/
+10
View File
@@ -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
+155
View File
@@ -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.
+68
View File
@@ -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.
+5
View File
@@ -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.
+7
View File
@@ -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";
+15
View File
@@ -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.');
+26
View File
@@ -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";
+216
View File
@@ -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]);
}
}
+35 -380
View File
@@ -1,394 +1,49 @@
<?php
// =====================================================================
// Title: Authentication Class
// 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
// ==============
/** Reset page controller; account storage and email are shared with the other flows. */
class Authentication extends Base {
public function resetPassword() {
$username = filter_input(INPUT_POST, "user_name") ?: "";
if (empty($username)) {
return false;
}
$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 {
//Server settings
$email->isSMTP();
$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;
$token = $_POST['token'] ?? $_GET['token'] ?? '';
$params = ['TOKEN' => $token, 'MESSAGE' => '', 'ERROR' => '', 'COMPLETE' => 'FALSE'];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
if (($_POST['step'] ?? '') === 'reset') {
try {
$auth = new AuthService();
if (!$auth->allowAttempt('reset-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900)) {
throw new InvalidArgumentException('Too many attempts. Please try again later.');
}
$email->addAddress($address);
}
} else {
$email->addAddress($to);
}
$email->Subject = $subject;
$email->Body = $message;
if (is_array($attachments)) {
foreach ($attachments as $index => $item) {
if (!empty($item)) {
$email->addStringAttachment($item, ($attachment_names[$index] ?? "unknown"));
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 {
if (!empty($attachments)) {
$email->addStringAttachment($attachments, $attachment_names);
try {
(new AuthService())->requestReset(trim($_POST['user_email'] ?? ''));
} catch (Throwable $e) {
error_log('Password reset request failed: ' . $e->getMessage());
}
$_SESSION['password_reset_requested'] = true;
header('Location: main.php?Xaction=resetpassword', true, 303);
exit;
}
$success = $email->Send();
} catch (phpMailerException $e) {
$this->journalEntry($e->errorMessage());
$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;
if (!empty($_SESSION['password_reset_success'])) {
$params['MESSAGE'] = 'Your password has been changed. Please sign in with your new password.';
$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.';
unset($_SESSION['password_reset_requested']);
}
$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;
echo $this->applyXSL('<XML/>', $this->getXSL('resetPassword'), $params);
exit;
}
}
+13 -11
View File
@@ -13,35 +13,37 @@ class Base_Autoloader {
if (function_exists("__autoload")) {
spl_autoload_register("__autoload");
}
return spl_autoload_register(array("Base_Autoloader", "Load"), true, true);
}
public static function Load($pClassName) {
if (class_exists($pClassName, 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.
$pClassFilePath = "classes/{$pClassName}.php";
$pClassFilePath = __DIR__ . "/{$pClassName}.php";
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)) {
return false;
}
}
require($pClassFilePath);
}
}
?>
?>
+237 -186
View File
@@ -4,8 +4,8 @@
// Base Class
// ==========
require_once "vendor/autoload.php";
require_once "classes/Autoloader.php";
require_once __DIR__ . "/../vendor/autoload.php";
require_once __DIR__ . "/Autoloader.php";
class Base {
@@ -13,31 +13,30 @@ class Base {
// Constants
// =========
const APPLICATION_NAME = "Hunting Club Roster";
const APPLICATION_NAME = "Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.00";
const TIMEZONE = "America/New_York";
const VERSION = "1.00";
const TIMEZONE = "America/New_York";
// =================
// Session Variables
// =================
const ENVIRONMENT = "HUNTINGCLUBROSTER_ENVIRONMENT";
const ACTIVE_SESSION = "HUNTINGCLUBROSTER_ACTIVE_SESSION";
const USER_SERIAL = "HUNTINGCLUBROSTER_USER_SERIAL";
const USER_NAME = "HUNTINGCLUBROSTER_USER_NAME";
const USER_FULL_NAME = "HUNTINGCLUBROSTER_USER_FULL_NAME";
const USER_EMAIL = "HUNTINGCLUBROSTER_USER_EMAIL";
const USER_ROLE = "HUNTINGCLUBROSTER_USER_ROLE";
const USER_AUTHENTICATED = "HUNTINGCLUBROSTER_USER_AUTHENTICATED";
const REPORTS_GET = "HUNTINGCLUBROSTER_REPORTS_GET";
const REPORTS_POST = "HUNTINGCLUBROSTER_REPORTS_POST";
const THEME = "HUNTINGCLUBROSTER_THEME";
const AJAX = "HUNTINGCLUBROSTER_AJAX";
const TODAY = "HUNTINGCLUBROSTER_TODAY";
const COPYRIGHT = "HUNTINGCLUBROSTER_COPYRIGHT";
const PAGE = "HUNTINGCLUBROSTER_PAGE";
const PAGINATION_PAGE = "HUNTINGCLUBROSTER_PAGINATION_PAGE";
const ENVIRONMENT = "HUNTINGCLUBROSTER_ENVIRONMENT";
const ACTIVE_SESSION = "HUNTINGCLUBROSTER_ACTIVE_SESSION";
const USER_SERIAL = "HUNTINGCLUBROSTER_USER_SERIAL";
const USER_NAME = "HUNTINGCLUBROSTER_USER_NAME";
const USER_FULL_NAME = "HUNTINGCLUBROSTER_USER_FULL_NAME";
const USER_EMAIL = "HUNTINGCLUBROSTER_USER_EMAIL";
const USER_ROLE = "HUNTINGCLUBROSTER_USER_ROLE";
const USER_AUTHENTICATED = "HUNTINGCLUBROSTER_USER_AUTHENTICATED";
const REPORTS_GET = "HUNTINGCLUBROSTER_REPORTS_GET";
const REPORTS_POST = "HUNTINGCLUBROSTER_REPORTS_POST";
const THEME = "HUNTINGCLUBROSTER_THEME";
const AJAX = "HUNTINGCLUBROSTER_AJAX";
const TODAY = "HUNTINGCLUBROSTER_TODAY";
const COPYRIGHT = "HUNTINGCLUBROSTER_COPYRIGHT";
const PAGE = "HUNTINGCLUBROSTER_PAGE";
const PAGINATION_PAGE = "HUNTINGCLUBROSTER_PAGINATION_PAGE";
// =================
// Class Constructor
@@ -56,7 +55,7 @@ class Base {
public function appendSXE(SimpleXMLElement $from, SimpleXMLElement $to) {
$toDOM = dom_import_simplexml($to);
$toDOM = dom_import_simplexml($to);
$fromDOM = dom_import_simplexml($from);
$toDOM->appendChild($toDOM->ownerDocument->importNode($fromDOM, true));
@@ -80,30 +79,31 @@ class Base {
$XSLParms = array();
}
$XSLParms["APPLICATION_NAME"] = self::APPLICATION_NAME;
$XSLParms["CSRF_TOKEN"] = $this->csrfToken();
$XSLParms["APPLICATION_NAME"] = self::APPLICATION_NAME;
$XSLParms["APPLICATION_MNEMONIC"] = self::APPLICATION_MNEMONIC;
$XSLParms["VERSION"] = self::VERSION;
$XSLParms["THEME"] = $_SESSION[self::THEME] ?? self::THEME;
$XSLParms["ENVIRONMENT"] = $_SESSION[self::ENVIRONMENT] ?? "Production";
$XSLParms["USER_NAME"] = $_SESSION[self::USER_NAME] ?? "";
$XSLParms["USER_FULL_NAME"] = $_SESSION[self::USER_FULL_NAME] ?? "";
$XSLParms["USER_ROLE"] = $_SESSION[self::USER_ROLE] ?? "Guest";
$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");
$XSLParms["VERSION"] = self::VERSION;
$XSLParms["THEME"] = $_SESSION[self::THEME] ?? self::THEME;
$XSLParms["ENVIRONMENT"] = $_SESSION[self::ENVIRONMENT] ?? "Production";
$XSLParms["USER_NAME"] = $XSLParms["USER_NAME"] ?? $_SESSION[self::USER_NAME] ?? "";
$XSLParms["USER_FULL_NAME"] = $_SESSION[self::USER_FULL_NAME] ?? "";
$XSLParms["USER_ROLE"] = $_SESSION[self::USER_ROLE] ?? "Guest";
$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();
$XML = new DOMDocument();
$XSL = new DOMDocument();
$XSLT = new XSLTProcessor();
if ($XMLString) {
$XML->loadXML($XMLString);
$XML->loadXML($XMLString, LIBXML_NONET);
}
if ($XSLString) {
$XSL->loadXML($XSLString);
$XSL->loadXML($XSLString, LIBXML_NONET);
}
$XSLT->importStylesheet($XSL);
@@ -112,6 +112,8 @@ class Base {
$XSLT->setParameter("", $XSLParms);
}
if (!$XML->documentElement)
$XML->loadXML('<XML/>');
$document = $XSLT->transformToXml($XML);
return $document;
@@ -122,27 +124,26 @@ class Base {
// ============================
public function connect(): PDO {
$settings = $this->getSettings();
$host = ((string) $settings->Environment === 'Production') ? 'localhost' : '192.168.1.190';
$database = (string) $settings->DatabaseName;
$username = (string) $settings->DatabaseUser;
$password = (string) $settings->DatabasePassword;
$host = (string) $settings->DatabaseServer;
$database = (string) $settings->DatabaseName;
$username = (string) $settings->DatabaseUser;
$password = (string) $settings->DatabasePassword;
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,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,]);
return new PDO($dsn, $username, $password,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,]);
} 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);
}
@@ -182,32 +183,17 @@ class Base {
// Custom error handler to display informative error page
// ======================================================
public function displayError($error_number = "", $error_message = "", $error_file = "", $error_line = "") {
if (error_reporting() == 0) {
return;
public function displayError($error_number = 0, $error_message = '', $error_file = '', $error_line = 0) {
if (!(error_reporting() & $error_number)) {
return false;
}
// 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);
$error_file = end($error_file);
$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);
if (in_array($error_number, [E_DEPRECATED, E_USER_DEPRECATED], true)) {
return true;
}
$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.
throw new ErrorException($error_message, 0, $error_number, $error_file, $error_line);
}
// =====================
@@ -241,71 +227,139 @@ class Base {
public function do() {
$_SESSION[self::TODAY] = date("Y-m-d");
// 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") {
if (empty($_SESSION[self::ACTIVE_SESSION])) {
$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 (method_exists(__CLASS__, $method)) {
$this->$method();
return;
}
} else {
if (method_exists($class, $method)) {
(new $class())->$method();
return;
}
if (!in_array($method, ['GET', 'POST'], true)) {
http_response_code(405);
exit('Method not allowed.');
}
// Default to the Home Page.
$this->displayHome();
// Reject arrays and oversized values before passing input to string operations.
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;
}
// 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();
}
public function csrfToken(): string {
if (empty($_SESSION['csrf_token']))
$_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.');
}
}
public function logoutSession(): void {
$_SESSION = [];
session_regenerate_id(true);
$this->initializeSession();
$this->csrfToken();
}
// ==========================
@@ -314,12 +368,12 @@ class Base {
protected function encrypt($string = "", $key = "") {
$key = (empty($key)) ? __CLASS__ : $key;
$key = (empty($key)) ? __CLASS__ : $key;
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = openssl_random_pseudo_bytes($ivlen);
$raw = openssl_encrypt($string, $cipher, $key, $options = OPENSSL_RAW_DATA, $iv);
$hmac = hash_hmac("sha256", $raw, $key, $as_binary = true);
$ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
$iv = openssl_random_pseudo_bytes($ivlen);
$raw = openssl_encrypt($string, $cipher, $key, $options = OPENSSL_RAW_DATA, $iv);
$hmac = hash_hmac("sha256", $raw, $key, $as_binary = true);
$encrypted = base64_encode($iv . $hmac . $raw);
return $encrypted;
@@ -366,7 +420,15 @@ class Base {
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;
}
@@ -378,11 +440,11 @@ class Base {
public function getAge($birthdate = "", $date = "") {
$birthdate = filter_input(INPUT_POST, "birthdate", FILTER_UNSAFE_RAW) ?: $birthdate;
$date = filter_input(INPUT_POST, "date", FILTER_UNSAFE_RAW) ?: $date;
$date = filter_input(INPUT_POST, "date", FILTER_UNSAFE_RAW) ?: $date;
$age = 0;
$age = 0;
$date = (empty($date)) ? new Datetime() : new DateTime($date);
$date = (empty($date)) ? new Datetime() : new DateTime($date);
$birthdate = (empty($birthdate)) ? new Datetime() : new DateTime($birthdate);
$age = $birthdate->diff($date)->format("%y");
@@ -529,8 +591,8 @@ class Base {
return "";
}
$document = "xml/" . trim($document) . ".xml";
$XML = (file_exists($document) ? file_get_contents($document) : "");
$document = __DIR__ . "/../xml/" . trim($document) . ".xml";
$XML = (file_exists($document) ? file_get_contents($document) : "");
return $XML;
}
@@ -545,8 +607,8 @@ class Base {
return "";
}
$document = "xsl/" . trim($document) . ".xsl";
$XSL = (file_exists($document) ? file_get_contents($document) : "");
$document = __DIR__ . "/../xsl/" . trim($document) . ".xsl";
$XSL = (file_exists($document) ? file_get_contents($document) : "");
return $XSL;
}
@@ -557,13 +619,13 @@ class Base {
public function getZipcode($zip_code = 0) {
$zip_code = filter_input(INPUT_POST, "zip_code", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$zip_code = (int) substr((string) $zip_code, 0, 5);
$zip_code = filter_input(INPUT_POST, "zip_code", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$zip_code = (int) substr((string) $zip_code, 0, 5);
$zipCodeDetail = null;
$SQL = "select *
from zipcodes
$SQL = "select *
from zipcodes
where zipcode_code = {$zip_code}";
$zipcodes = $this->getTable("zipcodes", $SQL);
@@ -571,9 +633,9 @@ class Base {
if ($zipcodes->count() > 0) {
$zipCodeDetail["ZipCode"] = $zip_code;
$zipCodeDetail["City"] = (string) $zipcodes->record->zipcode_city;
$zipCodeDetail["State"] = (string) $zipcodes->record->zipcode_state;
$zipCodeDetail["County"] = (string) $zipcodes->record->zipcode_county;
$zipCodeDetail["City"] = (string) $zipcodes->record->zipcode_city;
$zipCodeDetail["State"] = (string) $zipcodes->record->zipcode_state;
$zipCodeDetail["County"] = (string) $zipcodes->record->zipcode_county;
$zipCodeDetail["Country"] = (string) $zipcodes->record->zipcode_country;
}
@@ -592,18 +654,18 @@ class Base {
$settings = $this->getSettings();
$_SESSION[self::ACTIVE_SESSION] = true;
$_SESSION[self::THEME] = "light";
$_SESSION[self::ENVIRONMENT] = (string) ($settings->Environment ?? "Production");
$_SESSION[self::ACTIVE_SESSION] = true;
$_SESSION[self::THEME] = "light";
$_SESSION[self::ENVIRONMENT] = (string) ($settings->Environment ?? "Production");
$_SESSION[self::USER_AUTHENTICATED] = false;
$_SESSION[self::USER_SERIAL] = 0;
$_SESSION[self::USER_NAME] = "";
$_SESSION[self::USER_FULL_NAME] = "";
$_SESSION[self::USER_EMAIL] = "";
$_SESSION[self::USER_ROLE] = "Guest";
$_SESSION[self::REPORTS_GET] = "";
$_SESSION[self::REPORTS_POST] = "";
$_SESSION[self::AJAX] = false;
$_SESSION[self::USER_SERIAL] = 0;
$_SESSION[self::USER_NAME] = "";
$_SESSION[self::USER_FULL_NAME] = "";
$_SESSION[self::USER_EMAIL] = "";
$_SESSION[self::USER_ROLE] = "Guest";
$_SESSION[self::REPORTS_GET] = "";
$_SESSION[self::REPORTS_POST] = "";
$_SESSION[self::AJAX] = false;
}
// ============
@@ -647,7 +709,7 @@ class Base {
$thatDOM = new DOMDocument();
$thatDOM->loadXML($thatXML);
$xpath = new DOMXpath($thisDOM);
$xpath = new DOMXpath($thisDOM);
$xpathQuery = $xpath->query("/*");
for ($i = 0; $i < $xpathQuery->length; $i++) {
@@ -665,11 +727,15 @@ class Base {
public function print() {
$_SESSION[self::REPORTS_GET] = $_GET;
$_SESSION[self::REPORTS_GET] = $_GET;
$_SESSION[self::REPORTS_POST] = $_POST;
$report = filter_input(INPUT_POST, "report") ?: "";
if ($report !== 'Print_All_PermitsByBuilder') {
http_response_code(404);
exit('Report not found.');
}
(new $report())->print();
}
@@ -697,10 +763,10 @@ class Base {
public function validateDate($date = "", $format = "Y-m-d") {
$date = filter_input(INPUT_POST, "date") ?: $date;
$date = filter_input(INPUT_POST, "date") ?: $date;
$datetime = DateTime::createFromFormat($format, $date);
$valid = $datetime && $datetime->format($format) === $date;
$valid = $datetime && $datetime->format($format) === $date;
if ($_SESSION[self::AJAX]) {
echo json_encode($valid);
@@ -716,10 +782,10 @@ class Base {
public function generateRandomPassword($length = 12) {
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_+=';
$password = '';
$password = '';
for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1);
$index = random_int(0, strlen($characters) - 1);
$password .= $characters[$index];
}
@@ -759,7 +825,7 @@ class Base {
public function testing() {
$to = "james.richie@onesourceits.com";
$to = "james.richie@onesourceits.com";
$subject = "Test Subject";
$message = "Test Message";
@@ -773,19 +839,4 @@ class Base {
// =====================
// 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
View File
@@ -9,6 +9,13 @@ use PHPMailer\PHPMailer\Exception;
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.
private $current_user_name = "";
@@ -32,45 +39,14 @@ class Email extends Base {
// Email a new users credentials
// =============================
public function emailNewUserCredentials($user, $password) {
if (!$user instanceof SimpleXMLElement) {
return;
}
// Prepare the email
$to = (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);
public function emailNewUserCredentials($user, $password = null) {
$this->emailUserPasswordResetLink($user);
}
// =================================
// Email a users Password Reset Link
// =================================
public function emailUserPasswordResetLink($user, $password) {
if (!$user instanceof SimpleXMLElement) {
return;
public function emailUserPasswordResetLink($user, $password = null) {
if ($user instanceof SimpleXMLElement) {
(new AuthService())->requestReset((string) $user->record->user_email);
}
// 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
$email->isSMTP();
$email->Host = $SmtpHost;
$email->Host = (string) $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->Username = (string) $SmtpUsername;
$email->Password = (string) $SmtpPassword;
$email->Port = (int) $SmtpPort;
$email->SMTPSecure = $email->Port === 465 ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
$email->Timeout = 15;
$email->CharSet = 'UTF-8';
$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)) {
foreach ($to as $address) {
@@ -125,6 +103,7 @@ class Email extends Base {
$email->Subject = $subject;
$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)) {
foreach ($attachments as $index => $item) {
@@ -139,13 +118,9 @@ class Email extends Base {
}
$success = $email->Send();
} catch (phpMailerException $e) {
} catch (\Throwable $e) {
(new Journal())->journalEntry($e->errorMessage());
$success = false;
} catch (Exception $e) {
(new Journal())->journalEntry($e->getMessage());
error_log('Email delivery failed: ' . $e->getMessage());
$success = false;
}
+10 -112
View File
@@ -1,118 +1,16 @@
<?php
// =============
// Sign In Class
// =============
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() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$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();
}
if ((string) $user->record->user_status !== 'active') {
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
die();
}
$stored_hashed_password = $user->record->user_password_hash;
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();
$params = ['BTN_SUBMIT' => 'main.php', 'INVALID_SIGNIN' => 'FALSE'];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
if ((new AuthService())->login(trim($_POST['user_email'] ?? $_POST['user_name'] ?? ''), $_POST['user_password'] ?? '')) {
header('Location: main.php', true, 303);
exit;
}
$params['INVALID_SIGNIN'] = 'TRUE';
}
echo $this->applyXSL('<XML/>', $this->getXSL('login'), $params);
exit;
}
}
?>
+23 -94
View File
@@ -1,103 +1,32 @@
<?php
// =============
// Sign In Class
// =============
class SignUp {
// ===============================
// __construct : Class Constructor
// ===============================
public function __construct() {
}
// ==============
// Create Account
// ==============
class SignUp extends Base {
public function createAccount() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$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();
$params = ['BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf();
try {
$auth = new AuthService();
if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) {
throw new InvalidArgumentException('Too many account requests. Please try again later.');
}
if ((string) $user->record->user_status !== 'active') {
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
die();
$auth->register($_POST);
$_SESSION['signup_success'] = true;
header('Location: main.php?Xaction=signup', true, 303);
exit;
} catch (InvalidArgumentException $e) {
$params['SIGNUP_ERROR'] = $e->getMessage();
foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name'] as $field) {
$params[strtoupper($field)] = $_POST[$field] ?? '';
}
$stored_hashed_password = $user->record->user_password_hash;
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;
}
}
?>
+64 -133
View File
@@ -25,6 +25,7 @@ class Users extends Base {
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_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
@@ -100,9 +101,13 @@ class Users extends Base {
case "add":
$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_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));
@@ -110,31 +115,31 @@ class Users extends Base {
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
$user_change_password = (int) true;
$user_active = (int) true;
$user_active = 'active';
// Add the User
$connection = $this->connect();
$SQL = "insert into users
( user_name,
user_email,
user_client_name,
user_password,
user_first_name,
user_password_hash,
user_role,
user_change_password,
user_active,
user_status,
user_creator,
user_created )
values ( :user_name,
:user_email,
:user_client_name,
:user_password,
:user_first_name,
:user_password_hash,
:user_role,
:user_change_password,
:user_active,
:user_status,
:user_creator,
:user_created )";
@@ -142,11 +147,11 @@ class Users extends Base {
$statement->bindValue(":user_name", $user_name);
$statement->bindValue(":user_email", $user_email);
$statement->bindValue(":user_client_name", $user_client_name);
$statement->bindValue(":user_password", $user_temp_password);
$statement->bindValue(":user_first_name", $user_first_name);
$statement->bindValue(":user_password_hash", $user_temp_password);
$statement->bindValue(":user_role", $user_role);
$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_created", $this->getTimestamp());
@@ -158,7 +163,7 @@ class Users extends Base {
// Send User Credentials
(new Email())->emailNewUserCredentials($user, $user_random_password);
(new AuthService())->requestReset($user_email);
break;
@@ -226,9 +231,13 @@ class Users extends Base {
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
$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_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);
@@ -238,12 +247,13 @@ class Users extends Base {
// Update the User
$SQL = "update users
$SQL = "update users
set user_name = :user_name,
user_email = :user_email,
user_client_name = :user_client_name,
user_first_name = :user_first_name,
user_role = :user_role,
user_active = :user_active,
user_auth_version = user_auth_version + 1,
user_status = :user_status,
user_changer = :user_changer,
user_changed = :user_changed
where user_serial = :user_serial";
@@ -253,9 +263,9 @@ class Users extends Base {
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_name", $user_name);
$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_active", $user_active, PDO::PARAM_BOOL);
$statement->bindValue(":user_status", $user_active);
$statement->bindValue(":user_changer", $this->current_user_name);
$statement->bindValue(":user_changed", $this->getTimestamp());
@@ -274,6 +284,7 @@ class Users extends Base {
// ====================
public function getUser($user_serial = 0) {
$user_serial = (int) $user_serial;
$SQL = "select view_users.*
from view_users
@@ -287,12 +298,9 @@ class Users extends Base {
// ====================
public function getUserByUserName($user_name) {
$SQL = "select view_users.*
from view_users
where user_name = '{$user_name}'";
return $this->getTable("user", $SQL);
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_name = :name');
$stmt->execute([':name' => $user_name]);
return $this->getTableXML('users', $stmt);
}
// =====================
@@ -337,14 +345,14 @@ class Users extends Base {
$SQL = "select count(*) as count
from view_users
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_role regexp :user_role";
$statement = $connection->prepare($SQL);
$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_role", $find_words);
@@ -379,7 +387,7 @@ class Users extends Base {
$SQL = "select view_users.*
from view_users
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_role regexp :user_role
limit {$this->pagination_size}
@@ -388,7 +396,7 @@ class Users extends Base {
$statement = $connection->prepare($SQL);
$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_role", $find_words);
@@ -485,111 +493,33 @@ class Users extends Base {
// ===============
public function changePassword() {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
switch ($step) {
case "prompt":
$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();
$params = ['PASSWORD_ERROR' => ''];
if (($_POST['step'] ?? '') === 'change') {
$this->requireCsrf();
try {
$auth = new AuthService();
if (!$auth->allowAttempt('change-password', (string) $_SESSION[self::USER_SERIAL], 10, 900)) {
throw new InvalidArgumentException('Too many attempts. Please try again later.');
}
$user_new_hashed_password = password_hash($user_new_password, PASSWORD_DEFAULT);
// 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]);
if (!$auth->changePassword((int) $_SESSION[self::USER_SERIAL], $_POST['user_current_password'] ?? '', $_POST['user_new_password'] ?? '', $_POST['user_verify_password'] ?? '')) {
throw new InvalidArgumentException('Your current password is incorrect or your session has expired.');
}
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} changed their password.");
break;
default:
$this->displayHome();
$this->logoutSession();
header('Location: main.php', true, 303);
exit;
} catch (InvalidArgumentException $e) { $params['PASSWORD_ERROR'] = $e->getMessage(); }
}
$this->displayContent($this->applyXSL('<XML/>', $this->getXSL('changePassword'), $params));
}
// ==============
// Reset Password
// ==============
public function resetPassword() {
$reset = false;
$user_serial = filter_input(INPUT_POST, "user_serial") ?: "";
$user = $this->getUser($user_serial);
$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);
$this->requireCsrf();
$stmt = $this->connect()->prepare('SELECT user_email FROM users WHERE user_serial = :id');
$stmt->execute([':id' => (int) ($_POST['user_serial'] ?? 0)]);
$email = $stmt->fetchColumn();
if ($email) (new AuthService())->requestReset($email);
header('Content-Type: application/json');
echo json_encode(true);
}
// ===============================================
@@ -599,7 +529,7 @@ class Users extends Base {
public function updateLogin() {
$SQL = "update users
set user_login = :user_login
set user_last_login = :user_login
where user_serial = :user_serial";
$statement = $this->connect()->prepare($SQL);
@@ -617,10 +547,11 @@ class Users extends Base {
public function updateActiveStatus() {
$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
set user_active = :user_active,
set user_status = :user_status,
user_auth_version = user_auth_version + 1,
user_changed = :user_changed,
user_changer = :user_changer
where user_serial = :user_serial";
@@ -628,7 +559,7 @@ class Users extends Base {
$statement = $this->connect()->prepare($SQL);
$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_changer", $this->current_user_name);
+14
View File
@@ -1,7 +1,21 @@
{
"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",
"phpmailer/phpmailer": "^7.0",
"phpoffice/phpspreadsheet": "^5.2"
},
"scripts": {
"test:auth": "php tests/auth_integration.php"
}
}
Generated
+50 -196
View File
@@ -4,32 +4,33 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6621356a0da1644f04f6f40bd0e0b948",
"content-hash": "0389de306dca31ea5873c1d9ed6a4c5d",
"packages": [
{
"name": "composer/pcre",
"version": "3.3.2",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
@@ -67,7 +68,7 @@
],
"support": {
"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": [
{
@@ -77,26 +78,22 @@
{
"url": "https://github.com/composer",
"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",
"version": "3.2.0",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
@@ -107,7 +104,7 @@
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
@@ -153,7 +150,7 @@
],
"support": {
"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": [
{
@@ -161,7 +158,7 @@
"type": "github"
}
],
"time": "2025-07-17T11:15:13+00:00"
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
@@ -354,16 +351,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.2.0",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb"
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
"reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": ""
},
"require": {
@@ -371,6 +368,7 @@
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
@@ -384,14 +382,14 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
@@ -399,13 +397,14 @@
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^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",
"tecnickcom/tcpdf": "^6.5"
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
},
"suggest": {
"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",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -438,6 +437,9 @@
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -454,169 +456,9 @@
],
"support": {
"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"
},
{
"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"
"time": "2026-09-17T03:54:50+00:00"
},
{
"name": "psr/simple-cache",
@@ -747,7 +589,19 @@
"stability-flags": {},
"prefer-stable": 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": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
View File
+3 -3
View File
@@ -83,7 +83,7 @@ $(document).ready(function () {
$("#addUser").submit(function (e) {
var user_name = $("#user_name"),
user_client_name = $("#user_client_name"),
user_first_name = $("#user_first_name"),
user_email = $("#user_email"),
user_role = $("#user_role");
@@ -108,8 +108,8 @@ $(document).ready(function () {
return false;
}
if (isBlank(user_client_name)) {
createInputError(user_client_name, "Client Name is required");
if (isBlank(user_first_name)) {
createInputError(user_first_name, "Client Name is required");
return false;
}
+14
View File
@@ -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
+3 -3
View File
@@ -97,7 +97,7 @@ $(document).ready(function () {
var user_name = $("#user_name"),
user_email = $("#user_email"),
user_role = $("#user_role"),
user_client_name = $("#user_client_name");
user_first_name = $("#user_first_name");
clearInputErrors();
@@ -114,8 +114,8 @@ $(document).ready(function () {
return false;
}
if (isBlank(user_client_name)) {
createInputError(user_client_name, "Client Name is required");
if (isBlank(user_first_name)) {
createInputError(user_first_name, "Client Name is required");
return false;
}
+1 -1
View File
@@ -11,7 +11,7 @@ $(document).ready(function () {
// Use our Bootstrap validation messages.
$login.add($signUp).add($resetPassword).attr("novalidate", "novalidate");
$(".btnSignUp").on("click", function (e) {
$("#login .btnSignUp").on("click", function (e) {
e.preventDefault();
window.location.href = "./?Xaction=signup";
});
+26 -5
View File
@@ -1,8 +1,29 @@
<?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();
(new Base())->do();
?>
$application->do();
+18
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
<?php
require_once ("tcpdf/tcpdf.php");
require_once __DIR__ . '/../vendor/autoload.php';
// ==========================
// Print Selected Work Orders
+9 -37
View File
@@ -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
define("INITIALIZATION_FILE_NAME", "../xml/settings.xml");
<?php
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");
// 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) {
echoLog("Rebuild is disabled. Set \$allow_rebuild to true to continue.", "warning");
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
// --------------------------
@@ -247,7 +217,9 @@
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 (
user_name,
+2 -10
View File
@@ -1,14 +1,6 @@
<style>
body {
background-color: #333333;
color: white;
font-family: sans-serif;
padding: 1em;
}
</style>
<?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.
+2 -1
View File
@@ -12,6 +12,7 @@ $SQL = "create table users (
user_password_hash varchar(255) not null,
user_role varchar(255) not null,
user_change_password boolean default false,
user_auth_version int unsigned not null default 0,
user_status varchar(255) not null default 'active',
user_last_login datetime default null,
@@ -26,6 +27,6 @@ $SQL = "create table users (
unique key (user_email),
index (user_name)
) default character set = utf8mb4
) engine = InnoDB default character set = utf8mb4
collate = utf8mb4_unicode_ci";
?>
+127
View File
@@ -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)
+134
View File
@@ -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'], '&lt;b&gt;Jane &amp; club&lt;/b&gt;'), '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}`");
}
+33
View File
@@ -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
View File
@@ -277,7 +277,7 @@ class InstalledVersions
if (null === self::$installed) {
// 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
if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
@@ -378,7 +378,7 @@ class InstalledVersions
if (null === self::$installed) {
// 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
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 */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
+2
View File
@@ -1,3 +1,4 @@
Copyright (c) Nils Adermann, Jordi Boggiano
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,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-16
View File
@@ -8,25 +8,9 @@ $baseDir = dirname($vendorDir);
return array(
'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/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'),
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/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'),
);
-81
View File
@@ -14,8 +14,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
'P' =>
array (
'Psr\\SimpleCache\\' => 16,
'Psr\\Http\\Message\\' => 17,
'Psr\\Http\\Client\\' => 16,
'PhpOffice\\PhpSpreadsheet\\' => 25,
'PHPMailer\\PHPMailer\\' => 20,
),
@@ -27,20 +25,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
array (
'Composer\\Pcre\\' => 14,
'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 (
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\\' =>
array (
0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
@@ -82,62 +57,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
array (
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 (
+38 -1204
View File
File diff suppressed because it is too large Load Diff
+15 -168
View File
@@ -1,9 +1,9 @@
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-development',
'version' => 'dev-development',
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -11,27 +11,27 @@
),
'versions' => array(
'__root__' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-development',
'version' => 'dev-development',
'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'composer/pcre' => array(
'pretty_version' => '3.3.2',
'version' => '3.3.2.0',
'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
'pretty_version' => '3.4.0',
'version' => '3.4.0.0',
'reference' => 'd5a341b3fb61f3001970940afb1d332968a183ed',
'type' => 'library',
'install_path' => __DIR__ . '/./pcre',
'aliases' => array(),
'dev_requirement' => false,
),
'maennchen/zipstream-php' => array(
'pretty_version' => '3.2.0',
'version' => '3.2.0.0',
'reference' => '9712d8fa4cdf9240380b01eb4be55ad8dcf71416',
'pretty_version' => '3.2.2',
'version' => '3.2.2.0',
'reference' => '77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e',
'type' => 'library',
'install_path' => __DIR__ . '/../maennchen/zipstream-php',
'aliases' => array(),
@@ -65,41 +65,14 @@
'dev_requirement' => false,
),
'phpoffice/phpspreadsheet' => array(
'pretty_version' => '5.2.0',
'version' => '5.2.0.0',
'reference' => '3b8994b3aac4b61018bc04fc8c441f4fd68c18eb',
'pretty_version' => '5.10.0',
'version' => '5.10.0.0',
'reference' => 'eb18727acf6b1f4cc67145a52ab04f9fd4c28d53',
'type' => 'library',
'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
'aliases' => array(),
'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(
'pretty_version' => '3.0.0',
'version' => '3.0.0.0',
@@ -109,132 +82,6 @@
'aliases' => array(),
'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(
'pretty_version' => '6.10.0',
'version' => '6.10.0.0',
+9 -5
View File
@@ -20,12 +20,13 @@
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2"
"phpunit/phpunit": "^9",
"phpstan/phpstan": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpstan/phpstan-deprecation-rules": "^2"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
"phpstan/phpstan": "<2.2.2"
},
"autoload": {
"psr-4": {
@@ -48,7 +49,10 @@
}
},
"scripts": {
"test": "@php vendor/bin/phpunit",
"test": [
"@php vendor/bin/phpunit",
"@php vendor/bin/phpunit --testsuite phpstan"
],
"phpstan": "@php phpstan analyse"
}
}
@@ -57,18 +57,38 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
{
$args = $node->getArgs();
$patternArg = $args[0] ?? null;
$subjectArg = $args[1] ?? null;
$matchesArg = $args[2] ?? null;
$flagsArg = $args[3] ?? null;
$subjectTypes = new SpecifiedTypes();
if ($patternArg === null) {
return $subjectTypes;
}
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);
if ($flagsType === null) {
return new SpecifiedTypes();
return $subjectTypes;
}
if (stripos($methodReflection->getName(), 'matchAll') !== false) {
@@ -78,7 +98,7 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
}
if ($matchedType === null) {
return new SpecifiedTypes();
return $subjectTypes;
}
if (
@@ -93,27 +113,13 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
$context = $context->negate();
}
// @phpstan-ignore function.alreadyNarrowedType
if (method_exists('PHPStan\Analyser\SpecifiedTypes', 'setRootExpr')) {
$typeSpecifier = $this->typeSpecifier->create(
$matchesArg->value,
$matchedType,
$context,
$scope
)->setRootExpr($node);
return $overwrite ? $typeSpecifier->setAlwaysOverwriteTypes() : $typeSpecifier;
}
// @phpstan-ignore arguments.count
return $this->typeSpecifier->create(
$specifiedTypes = $this->typeSpecifier->create(
$matchesArg->value,
$matchedType,
$context,
// @phpstan-ignore argument.type
$overwrite,
$scope,
$node
);
$scope
)->setRootExpr($node);
return $subjectTypes->unionWith($overwrite ? $specifiedTypes->setAlwaysOverwriteTypes() : $specifiedTypes);
}
}
+2 -2
View File
@@ -395,7 +395,7 @@ class Preg
* @return array<int|string, string>
* @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) {
if (is_string($match) || (is_array($match) && is_string($match[0]))) {
@@ -414,7 +414,7 @@ class Preg
* @return array<int|string, list<string>>
* @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 ($groupMatches as $match) {
+1
View File
@@ -71,4 +71,5 @@ return $config->setRules([
])
->setFinder($finder)
->setRiskyAllowed(true)
->setUnsupportedPhpVersionAllowed(true)
->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
+1 -1
View File
@@ -1 +1 @@
php 8.4.10
php 8.5.0
+1 -1
View File
@@ -32,7 +32,7 @@
"ext-zip": "*",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"friendsofphp/php-cs-fixer": "^3.16",
"friendsofphp/php-cs-fixer": "^3.86",
"vimeo/psalm": "^6.0",
"brianium/paratest": "^7.7"
},
+245
View File
@@ -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
+1
View File
@@ -16,6 +16,7 @@ user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
Nginx
Varnish
ContentLength
LargeFilesToS3
Installation
---------------
+9 -9
View File
@@ -110,8 +110,8 @@ final class File
$detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
if (
$this->isSimulation() &&
$detectedSize !== null
$this->isSimulation()
&& $detectedSize !== null
) {
$this->uncompressedSize = $detectedSize;
$this->compressedSize = $detectedSize;
@@ -202,10 +202,10 @@ final class File
compressionMethod: $this->compressionMethod,
lastModificationDateTime: $this->lastModificationDateTime,
crc32UncompressedData: $this->crc,
compressedSize: $zip64Enabled
compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
? 0xFFFFFFFF
: $this->compressedSize,
uncompressedSize: $zip64Enabled
uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
? 0xFFFFFFFF
: $this->uncompressedSize,
fileName: $this->fileName,
@@ -238,8 +238,8 @@ final class File
// Sets Bit 11: Language encoding flag (EFS). If this bit is set,
// the filename and comment fields for this file
// MUST be encoded using UTF-8. (see APPENDIX D)
if (mb_check_encoding($this->fileName, 'UTF-8') &&
mb_check_encoding($this->comment, 'UTF-8')) {
if (mb_check_encoding($this->fileName, 'UTF-8')
&& mb_check_encoding($this->comment, 'UTF-8')) {
$this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
}
}
@@ -321,9 +321,9 @@ final class File
$deflate = $this->compressionInit();
while (
!feof($this->unpackStream()) &&
($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
!feof($this->unpackStream())
&& ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
&& ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
) {
$readLength = min(
($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
+6 -6
View File
@@ -29,11 +29,11 @@ abstract class Time
[$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
return
((int) $year << 25) |
((int) $month << 21) |
((int) $day << 16) |
((int) $hour << 11) |
((int) $minute << 5) |
((int) $second >> 1);
((int) $year << 25)
| ((int) $month << 21)
| ((int) $day << 16)
| ((int) $hour << 11)
| ((int) $minute << 5)
| ((int) $second >> 1);
}
}
@@ -23,10 +23,10 @@ abstract class ExtendedInformationExtraField
new PackField(format: 'v', value: self::TAG),
new PackField(
format: 'v',
value: ($originalSize === null ? 0 : 8) +
($compressedSize === null ? 0 : 8) +
($relativeHeaderOffset === null ? 0 : 8) +
($diskStartNumber === null ? 0 : 4)
value: ($originalSize === null ? 0 : 8)
+ ($compressedSize === null ? 0 : 8)
+ ($relativeHeaderOffset === null ? 0 : 8)
+ ($diskStartNumber === null ? 0 : 4)
),
...($originalSize === null ? [] : [
new PackField(format: 'P', value: $originalSize),
+3 -3
View File
@@ -714,9 +714,9 @@ class ZipStream
}
// Add 64bit headers (if applicable)
if (count($this->centralDirectoryRecords) >= 0xFFFF ||
$centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
$sizeOfCentralDirectory > 0xFFFFFFFF) {
if (count($this->centralDirectoryRecords) >= 0xFFFF
|| $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF
|| $sizeOfCentralDirectory > 0xFFFFFFFF) {
if (!$this->enableZip64) {
throw new OverflowException();
}
+15 -6
View File
@@ -24,7 +24,9 @@ final class CallbackOutputTest extends TestCase
$buf = '';
$zip = new ZipStream(
outputStream: CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf): void { $buf .= $chunk; }
static function (string $chunk) use (&$buf): void {
$buf .= $chunk;
}
),
sendHttpHeaders: false
);
@@ -53,10 +55,14 @@ final class CallbackOutputTest extends TestCase
$buf2 = '';
$stream1 = CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf1): void { $buf1 .= $chunk; }
static function (string $chunk) use (&$buf1): void {
$buf1 .= $chunk;
}
);
$stream2 = CallbackStreamWrapper::open(
static function (string $chunk) use (&$buf2): void { $buf2 .= $chunk; }
static function (string $chunk) use (&$buf2): void {
$buf2 .= $chunk;
}
);
$this->assertIsResource($stream1);
@@ -112,7 +118,8 @@ final class CallbackOutputTest extends TestCase
public function testStreamPositionTracking(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
$this->assertSame(0, ftell($stream));
@@ -129,7 +136,8 @@ final class CallbackOutputTest extends TestCase
public function testInvalidModeRejection(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
// Close the stream first
@@ -143,7 +151,8 @@ final class CallbackOutputTest extends TestCase
public function testStreamStatistics(): void
{
$stream = CallbackStreamWrapper::open(
static function (string $chunk): void { /* no-op */ }
static function (string $chunk): void { // no-op
}
);
fwrite($stream, 'test data');
@@ -35,26 +35,26 @@ class CentralDirectoryFileHeaderTest extends TestCase
$this->assertSame(
bin2hex($header),
'504b0102' . // 4 bytes; central file header signature
'0306' . // 2 bytes; version made by
'2d00' . // 2 bytes; version needed to extract
'2222' . // 2 bytes; general purpose bit flag
'0800' . // 2 bytes; compression method
'2008' . // 2 bytes; last mod file time
'2154' . // 2 bytes; last mod file date
'11111111' . // 4 bytes; crc-32
'77777777' . // 4 bytes; compressed size
'99999999' . // 4 bytes; uncompressed size
'0800' . // 2 bytes; file name length (n)
'0c00' . // 2 bytes; extra field length (m)
'0c00' . // 2 bytes; file comment length (o)
'0000' . // 2 bytes; disk number start
'0000' . // 2 bytes; internal file attributes
'20000000' . // 4 bytes; external file attributes
'34120000' . // 4 bytes; relative offset of local header
'746573742e706e67' . // n bytes; file name
'736f6d6520636f6e74656e74' . // m bytes; extra field
'736f6d6520636f6d6d656e74' // o bytes; file comment
'504b0102' // 4 bytes; central file header signature
. '0306' // 2 bytes; version made by
. '2d00' // 2 bytes; version needed to extract
. '2222' // 2 bytes; general purpose bit flag
. '0800' // 2 bytes; compression method
. '2008' // 2 bytes; last mod file time
. '2154' // 2 bytes; last mod file date
. '11111111' // 4 bytes; crc-32
. '77777777' // 4 bytes; compressed size
. '99999999' // 4 bytes; uncompressed size
. '0800' // 2 bytes; file name length (n)
. '0c00' // 2 bytes; extra field length (m)
. '0c00' // 2 bytes; file comment length (o)
. '0000' // 2 bytes; disk number start
. '0000' // 2 bytes; internal file attributes
. '20000000' // 4 bytes; external file attributes
. '34120000' // 4 bytes; relative offset of local header
. '746573742e706e67' // n bytes; file name
. '736f6d6520636f6e74656e74' // m bytes; extra field
. '736f6d6520636f6d6d656e74' // o bytes; file comment
);
}
}
+4 -4
View File
@@ -17,10 +17,10 @@ class DataDescriptorTest extends TestCase
compressedSize: 0x77777777,
uncompressedSize: 0x99999999,
)),
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'77777777' . // 4 bytes; Compressed size
'99999999' // 4 bytes; Uncompressed size
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '77777777' // 4 bytes; Compressed size
. '99999999' // 4 bytes; Uncompressed size
);
}
}
@@ -21,15 +21,15 @@ class EndOfCentralDirectoryTest extends TestCase
centralDirectoryStartOffsetOnDisk: 0x33,
zipFileComment: 'foo',
)),
'504b0506' . // 4 bytes; end of central dir signature 0x06054b50
'0000' . // 2 bytes; number of this disk
'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
'22000000' . // 4 bytes; size of the central directory
'33000000' . // 4 bytes; offset of start of central directory with respect to the starting disk number
'0300' . // 2 bytes; .ZIP file comment length
bin2hex('foo')
'504b0506' // 4 bytes; end of central dir signature 0x06054b50
. '0000' // 2 bytes; number of this disk
. '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
. '22000000' // 4 bytes; size of the central directory
. '33000000' // 4 bytes; offset of start of central directory with respect to the starting disk number
. '0300' // 2 bytes; .ZIP file comment length
. bin2hex('foo')
);
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ class EndlessCycleStream implements StreamInterface
throw new RuntimeException('Infinite Stream!');
}
public function getMetadata(?string $key = null): array|null
public function getMetadata(?string $key = null): ?array
{
return $key !== null ? null : [];
}
+13 -13
View File
@@ -29,19 +29,19 @@ class LocalFileHeaderTest extends TestCase
$this->assertSame(
bin2hex((string) $header),
'504b0304' . // 4 bytes; Local file header signature
'2d00' . // 2 bytes; Version needed to extract (minimum)
'2222' . // 2 bytes; General purpose bit flag
'0800' . // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
'2008' . // 2 bytes; File last modification time
'2154' . // 2 bytes; File last modification date
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'77777777' . // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
'99999999' . // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
'0800' . // 2 bytes; File name length (n)
'0c00' . // 2 bytes; Extra field length (m)
'746573742e706e67' . // n bytes; File name
'736f6d6520636f6e74656e74' // m bytes; Extra field
'504b0304' // 4 bytes; Local file header signature
. '2d00' // 2 bytes; Version needed to extract (minimum)
. '2222' // 2 bytes; General purpose bit flag
. '0800' // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
. '2008' // 2 bytes; File last modification time
. '2154' // 2 bytes; File last modification date
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '77777777' // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
. '99999999' // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
. '0800' // 2 bytes; File name length (n)
. '0c00' // 2 bytes; Extra field length (m)
. '746573742e706e67' // n bytes; File name
. '736f6d6520636f6e74656e74' // m bytes; Extra field
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ZipStream\Test;
trait Tempfile
{
protected string|null $tempfile;
protected ?string $tempfile;
/**
* @var resource
@@ -19,10 +19,10 @@ class DataDescriptorTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
'11111111' . // 4 bytes; CRC-32 of uncompressed data
'6666666677777777' . // 8 bytes; Compressed size
'8888888899999999' // 8 bytes; Uncompressed size
'504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
. '11111111' // 4 bytes; CRC-32 of uncompressed data
. '6666666677777777' // 8 bytes; Compressed size
. '8888888899999999' // 8 bytes; Uncompressed size
);
}
}
@@ -19,10 +19,10 @@ class EndOfCentralDirectoryLocatorTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'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
'3333333322222222' . // 28 bytes; relative offset of the zip64 end of central directory record
'44444444' // 4 bytes;total number of disks
'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
. '3333333322222222' // 28 bytes; relative offset of the zip64 end of central directory record
. '44444444' // 4 bytes;total number of disks
);
}
}
@@ -25,17 +25,17 @@ class EndOfCentralDirectoryTest extends TestCase
$this->assertSame(
bin2hex($descriptor),
'504b0606' . // 4 bytes;zip64 end of central dir signature - 0x06064b50
'2f00000000000000' . // 8 bytes; size of zip64 end of central directory record
'3333' . // 2 bytes; version made by
'4444' . // 2 bytes; version needed to extract
'55555555' . // 4 bytes; number of this disk
'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
'aaaaaaaa99999999' . // 8 bytes; total number of entries in 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
bin2hex('foo')
'504b0606' // 4 bytes;zip64 end of central dir signature - 0x06064b50
. '2f00000000000000' // 8 bytes; size of zip64 end of central directory record
. '3333' // 2 bytes; version made by
. '4444' // 2 bytes; version needed to extract
. '55555555' // 4 bytes; number of this disk
. '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
. 'aaaaaaaa99999999' // 8 bytes; total number of entries in 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
. bin2hex('foo')
);
}
}
@@ -20,12 +20,12 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex($extraField),
'0100' . // 2 bytes; Tag for this "extra" block type
'1c00' . // 2 bytes; Size of this "extra" block
'6666666677777777' . // 8 bytes; Original uncompressed file size
'8888888899999999' . // 8 bytes; Size of compressed data
'1111111122222222' . // 8 bytes; Offset of local header record
'33333333' // 4 bytes; Number of the disk on which this file starts
'0100' // 2 bytes; Tag for this "extra" block type
. '1c00' // 2 bytes; Size of this "extra" block
. '6666666677777777' // 8 bytes; Original uncompressed file size
. '8888888899999999' // 8 bytes; Size of compressed data
. '1111111122222222' // 8 bytes; Offset of local header record
. '33333333' // 4 bytes; Number of the disk on which this file starts
);
}
@@ -35,8 +35,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex($extraField),
'0100' . // 2 bytes; Tag for this "extra" block type
'0000' // 2 bytes; Size of this "extra" block
'0100' // 2 bytes; Tag for this "extra" block type
. '0000' // 2 bytes; Size of this "extra" block
);
}
}
+24 -9
View File
@@ -62,10 +62,10 @@ class ZipStreamTest extends TestCase
$name = 'árvíztűrő tükörfúrógép.txt';
$content = 'Sample String Data';
$comment =
'Filename has every special characters ' .
'from Hungarian language in lowercase. ' .
'In uppercase: ÁÍŰŐÜÖÚÓÉ';
$comment
= 'Filename has every special characters '
. 'from Hungarian language in lowercase. '
. 'In uppercase: ÁÍŰŐÜÖÚÓÉ';
$zip->addFile(fileName: $name, data: $content, comment: $comment);
$zip->finish();
@@ -327,11 +327,14 @@ class ZipStreamTest extends TestCase
defaultEnableZeroHeader: false,
);
if (file_exists('/dev/null')) {
$streamUnseekable = fopen('/dev/null', 'w+');
} elseif (file_exists('NUL')) {
$streamUnseekable = fopen('NUL', 'w+');
} else {
foreach (['cat', 'more'] as $cmd) {
$streamUnseekable = @popen($cmd, 'w');
if ($streamUnseekable !== false) {
break;
}
}
if ($streamUnseekable === false) {
$this->markTestSkipped('Needs file /dev/null');
}
@@ -546,12 +549,24 @@ class ZipStreamTest extends TestCase
);
}
$zip->addDirectory('mydir/');
$zip->finish();
$tmpDir = $this->validateAndExtractZip($this->tempfile);
$files = $this->getRecursiveFileList($tmpDir);
$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')]
@@ -15,8 +15,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
$this->assertSame(
bin2hex((string) $extraField),
'5356' . // 2 bytes; Tag for this "extra" block type
'0000' // 2 bytes; TODO: Document
'5356' // 2 bytes; Tag for this "extra" block type
. '0000' // 2 bytes; TODO: Document
);
}
}
+210 -218
View File
@@ -3,9 +3,13 @@
All notable changes to this project will be documented in this file.
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
@@ -31,6 +35,210 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- 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
### Added
@@ -250,18 +458,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
- Worksheet::getProtectedCells - use getProtectedCellRanges instead.
- Writer/Html::isMpdf property - use instanceof Mpdf instead.
### Changed
- Nothing yet.
### Moved
- Nothing yet.
### Deprecated
- Nothing yet.
### 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)
@@ -314,22 +510,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 2024-12-08 - 3.6.0
### Added
- Nothing yet.
### Changed
- Nothing yet.
### Moved
- Nothing yet.
### Deprecated
- Nothing yet.
### Fixed
- 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
### Added
- Nothing yet.
### Changed
- Settings::libXmlLoaderOptions is ignored. [PR #4233](https://github.com/PHPOffice/PhpSpreadsheet/pull/4233)
### Moved
- Nothing yet.
### Deprecated
- 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.
- 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
- 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)
- Images will not be added to spreadsheet if they cannot be validated as images.
### Deprecated
- Nothing yet.
### 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::_translateFormulaToEnglish (use method name translateFormulaToEnglish without leading underscore).
### Moved
- Nothing yet.
### Fixed
- 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
### Added
- Nothing yet.
### Changed
- Nothing yet.
### Deprecated
- Nothing yet.
### Moved
- Nothing yet.
### Fixed
- 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.
### Moved
- Nothing
### Fixed
- 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)
- Additional Support for Date/Time Styles [PR #3939](https://github.com/PHPOffice/PhpSpreadsheet/pull/3939)
### Changed
- Nothing
### Deprecated
- Reader/Xml trySimpleXMLLoadString should not have had public visibility, and will be removed.
### Removed
- Nothing
### 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)
@@ -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)
### Removed
- Nothing
### Fixed
- 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)
- 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
- 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)
### Removed
- Nothing
### 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)
@@ -764,22 +880,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 1.27.1 - 2023-02-08
### Added
- Nothing
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- 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)
- 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
- 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)
- 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
- 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.
- NumberFormat constant FORMAT_DATE_YYYYMMDD2 replaced with existing identical FORMAT_DATE_YYYYMMDD.
### Removed
- Nothing
### 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)
@@ -870,22 +954,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
## 1.25.2 - 2022-09-25
### Added
- Nothing
### Changed
- Nothing
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- 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.
- Clone not permitted for Spreadsheet. Spreadsheet->copy() can be used instead.
### Removed
- Nothing
### 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)
@@ -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
- 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
- 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
- Improved performance for removing rows/columns from a worksheet
### Deprecated
- Nothing
### Removed
- Nothing
### 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)
@@ -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);
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
- 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)
- 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 `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)
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- 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
- Use native typing for objects that were already documented as such
### Deprecated
- Nothing
### Removed
- Nothing
### Fixed
- 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)
- Provided a Size Helper class to validate size values (pt, px, em) [PR #1694](https://github.com/PHPOffice/PhpSpreadsheet/pull/1694)
### Changed
- Nothing.
### 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.
### Removed
- Nothing.
### Fixed
- 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.
### Removed
- Nothing.
### 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)
@@ -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.
### Removed
- Nothing.
### Fixed
- 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.
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
- 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
### Added
- nothing
### Fixed
- WEBSERVICE is HTTP client agnostic and must be configured via `Settings::setHttpClient()` [#1562](https://github.com/PHPOffice/PhpSpreadsheet/issues/1562)
+4 -4
View File
@@ -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 ";"`.
> **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)
- release210 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
- release222
- release390
- 1.30.x (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)
- 2.4.x (no further updates aside from security patches)
- 3.10.x
+1 -1
View File
@@ -1,6 +1,6 @@
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
of this software and associated documentation files (the "Software"), to deal
+3 -2
View File
@@ -4,7 +4,7 @@
[![Code Coverage](https://coveralls.io/repos/github/PHPOffice/PhpSpreadsheet/badge.svg?branch=master)](https://coveralls.io/github/PHPOffice/PhpSpreadsheet?branch=master)
[![Total Downloads](https://img.shields.io/packagist/dt/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![Latest Stable Version](https://img.shields.io/github/v/release/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![License](https://img.shields.io/github/license/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![License](https://poser.pugx.org/phpoffice/phpspreadsheet/license)](https://packagist.org/packages/phpoffice/phpspreadsheet)
[![Join the chat at https://gitter.im/PHPOffice/PhpSpreadsheet](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/PHPOffice/PhpSpreadsheet)
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
[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.
+14 -8
View File
@@ -13,7 +13,7 @@
],
"config": {
"platform": {
"php" : "8.1.99"
"php": "8.2.99"
},
"process-timeout": 600,
"sort-packages": true,
@@ -42,6 +42,9 @@
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"scripts": {
@@ -66,10 +69,11 @@
]
},
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
@@ -84,11 +88,11 @@
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^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"
},
"require-dev": {
"ext-intl": "*",
"ext-openssl": "*",
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
@@ -98,12 +102,13 @@
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^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",
"tecnickcom/tcpdf": "^6.5"
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
},
"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",
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer",
@@ -117,7 +122,8 @@
"autoload-dev": {
"psr-4": {
"PhpOffice\\PhpSpreadsheetTests\\": "tests/PhpSpreadsheetTests",
"PhpOffice\\PhpSpreadsheetInfra\\": "infra"
"PhpOffice\\PhpSpreadsheetInfra\\": "infra",
"PhpOffice\\PhpSpreadsheetBenchmarks\\": "tests/Benchmark"
}
}
}
@@ -2,6 +2,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
class BinaryComparison
@@ -41,17 +42,25 @@ class BinaryComparison
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
if (is_string($operand1) && $operand1 > '' && $operand1[0] == Calculation::FORMULA_STRING_QUOTE) {
$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) {
$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 (is_string($operand1)) {
$operand1 = StringHelper::strToUpper($operand1);
@@ -2,6 +2,7 @@
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\CyclicReferenceStack;
use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
@@ -13,6 +14,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\DefinedName;
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
use PhpOffice\PhpSpreadsheet\NamedRange;
use PhpOffice\PhpSpreadsheet\ReferenceHelper;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -36,7 +38,7 @@ class Calculation extends CalculationLocale
const CALCULATION_REGEXP_OPENBRACE = '\(';
// 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]*\(';
// 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.])';
// Used only to detect spill operator #
const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
@@ -88,9 +90,22 @@ class Calculation extends CalculationLocale
*/
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 bool $branchPruningEnabled = true;
protected bool $branchPruningEnabled = true;
/**
* List of operators that can be used within formulae
@@ -239,6 +254,7 @@ class Calculation extends CalculationLocale
{
$this->clearCalculationCache();
$this->branchPruner->clearBranchStore();
$this->formulaTokenCache = [];
}
/**
@@ -364,6 +380,44 @@ class Calculation extends CalculationLocale
$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.
*/
@@ -415,7 +469,7 @@ class Calculation extends CalculationLocale
{
if (is_string($value)) {
// 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 $value;
}
@@ -494,13 +548,13 @@ class Calculation extends CalculationLocale
try {
$value = $cell->getValue();
if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
$value = preg_replace_callback(
$value = Preg::replaceCallback(
self::CALCULATION_REGEXP_CELLREF_SPILL,
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
$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) {
throw new Exception('null spreadsheet in calculateCellValue');
}
@@ -520,7 +574,7 @@ class Calculation extends CalculationLocale
$cellAddress = array_pop($this->cellStack);
}
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;
if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
/** @var array{cell: string} $cellAddress */
@@ -557,11 +611,17 @@ class Calculation extends CalculationLocale
*/
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,
fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
$formula
) ?? $formula;
);
// Basic validation that this is indeed a formula
// We return an empty array if not
$formula = trim($formula);
@@ -574,7 +634,21 @@ class Calculation extends CalculationLocale
}
// 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);
}
// https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
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
@@ -914,13 +989,14 @@ class Calculation extends CalculationLocale
$pad = $rpad = ', ';
foreach ($value as $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 = '; ';
} else {
$returnMatrix[] = $this->showValue($row);
}
}
/** @var string[] $returnMatrix */
return '{ ' . implode($rpad, $returnMatrix) . ' }';
} elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
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
];
/** @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
*/
@@ -1060,6 +1180,12 @@ class Calculation extends CalculationLocale
if (($formula = $this->convertMatrixReferences(trim($formula))) === 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();
// 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 !== '*') {
if (1 !== preg_match('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
$argMatch = ['', '', '', ''];
}
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)
// 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)
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
//return $this->raiseFormulaError('Formula Error: Unexpected ,');
$stack->push('Binary Operator', '∪');
++$index;
$expectingOperator = false;
continue;*/
continue;
}
/** @var array<string, int> $d */
@@ -1267,11 +1391,12 @@ class Calculation extends CalculationLocale
// do we now have a function/variable/number?
$expectingOperator = true;
$expectingOperand = false;
$val = $match[1] ?? ''; //* @phpstan-ignore-line
$val = $match[1] ?? '';
$length = strlen($val);
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
$valToUpper = strtoupper($val);
} else {
@@ -1410,7 +1535,7 @@ class Calculation extends CalculationLocale
$refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
}
if (ctype_digit($val) && $val <= 1048576) {
if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
// Row range
$stackItemType = 'Row Reference';
$valx = $val;
@@ -1419,7 +1544,7 @@ class Calculation extends CalculationLocale
} elseif (ctype_alpha($val) && strlen($val) <= 3) {
// Column range
$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}";
}
$stackItemReference = $val;
@@ -1445,7 +1570,7 @@ class Calculation extends CalculationLocale
// unescape any apostrophes or double quotes in worksheet name
$val = str_replace(["''", '""'], ["'", '"'], $val);
$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);
}
$val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
@@ -1459,7 +1584,7 @@ class Calculation extends CalculationLocale
// unescape any apostrophes or double quotes in worksheet name
$val = str_replace(["''", '""'], ["'", '"'], $val);
$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);
}
$val = "{$val}{$row}";
@@ -1610,7 +1735,7 @@ class Calculation extends CalculationLocale
foreach ($tokens as $tokenIdx => $tokenData) {
/** @var mixed[] $tokenData */
$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;
}
$token = $tokenData['value'];
@@ -1712,7 +1837,7 @@ class Calculation extends CalculationLocale
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
// We must have two operands, error if we don't
$operand2Data = $stack->pop();
@@ -1830,7 +1955,7 @@ class Calculation extends CalculationLocale
if ($breakNeeded) {
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) {
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
} else {
@@ -1901,7 +2026,7 @@ class Calculation extends CalculationLocale
} elseif (Information\ErrorValue::isError($operand2)) {
$result = $operand2;
} 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,
0,
@@ -1934,8 +2059,8 @@ class Calculation extends CalculationLocale
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
$stack->push('Error', ExcelError::null(), null);
} else {
$cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
. Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($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 argument.type ($oCol or $oRow might be empty), argument.type (ditto)
$this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
$stack->push('Value', $cellIntersect, $cellRef);
}
@@ -1950,13 +2075,13 @@ class Calculation extends CalculationLocale
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 (($arg = $stack->pop()) === null) {
return $this->raiseFormulaError('Internal error - Operand value missing from stack');
}
$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));
$multiplier = -1;
} else {
@@ -1988,20 +2113,27 @@ class Calculation extends CalculationLocale
} else {
$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;
/* Phpstan says matches[8/9/10] is never set,
and code coverage report seems to confirm.
Appease PhpStan for now;
probably delete this block later.
regex101.com confirms - only 7 capturing groups.
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])) {
// @codeCoverageIgnoreStart
if ($cell === null) {
// We can't access the range, so return a REF error
$cellValue = ExcelError::REF();
} else {
$cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
$matches[2] = (string) $matches[2];
if ($matches[2] > '') {
$matches[2] = trim($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));
}
}
// @codeCoverageIgnoreEnd
} else {
if ($cell === null) {
// We can't access the cell, so return a REF error
$cellValue = ExcelError::REF();
} else {
$cellRef = $matches[6] . $matches[7];
$matches[2] = (string) $matches[2];
if ($matches[2] > '') {
$matches[2] = trim($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) {
$cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
$cellSheet->setCellValue($cellRef, null);
try {
$cellSheet->setCellValue($cellRef, null);
} catch (SpreadsheetException) {
// do nothing
}
}
if ($cellSheet && $cellSheet->cellExists($cellRef)) {
$cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
@@ -2073,7 +2211,7 @@ class Calculation extends CalculationLocale
$cellValue = array_shift($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));
}
@@ -2117,7 +2255,7 @@ class Calculation extends CalculationLocale
$a = $argCount - $i - 1;
if (
($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])
) {
/** @var mixed[] $arg */
@@ -2184,7 +2322,7 @@ class Calculation extends CalculationLocale
if ($functionName !== 'MKMATRIX') {
if ($this->debugLog->getWriteDebugLog()) {
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 {
// 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 ?? '')])) {
$excelConstant = strtoupper("$token");
$stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
@@ -2229,7 +2367,7 @@ class Calculation extends CalculationLocale
$branchStore[$storeKey] = 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 */
$stack->push($tokenData['type'], $token, $tokenData['reference']);
if (isset($storeKey)) {
@@ -2355,7 +2493,7 @@ class Calculation extends CalculationLocale
$r = $stack->pop();
$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
if (!$recursingArrays) {
self::checkMatrixOperands($operand1, $operand2, 2);
@@ -2368,7 +2506,7 @@ class Calculation extends CalculationLocale
$result[$x] = $r['value'];
}
} else {
throw new Exception('Neither operand is an arra');
throw new Exception('Neither operand is an array');
}
// Log the result details
$this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
@@ -2378,8 +2516,8 @@ class Calculation extends CalculationLocale
return $result;
}
/** @return bool|mixed[] */
private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool
/** @return array<mixed>|bool|string */
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 ((is_array($operand1)) || (is_array($operand2))) {
@@ -2581,6 +2719,8 @@ class Calculation extends CalculationLocale
if (!isset($aReferences[1])) {
// Single cell in range
sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
/** @var string $currentCol */
/** @var int $currentRow */
if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
$worksheet->setCellValue($aReferences[0], null);
}
@@ -2600,6 +2740,8 @@ class Calculation extends CalculationLocale
foreach ($aReferences as $reference) {
// Extract range
sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
/** @var string $currentCol */
/** @var int $currentRow */
if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
$worksheet->setCellValue($reference, null);
}
@@ -2760,6 +2902,7 @@ class Calculation extends CalculationLocale
// read constant value
if (str_contains($constantName, '::')) {
[$className, $constantName] = explode('::', $constantName);
/** @var class-string $className */
$constantReflector = new ReflectionClassConstant($className, $constantName);
return $constantReflector->getValue();
@@ -2814,10 +2957,12 @@ class Calculation extends CalculationLocale
$definedNameValue = $namedRange->getValue();
$definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
if ($definedNameType === 'Range') {
if (preg_match('/^(.*!)?(.*)$/', $definedNameValue, $matches) === 1) {
$matches2 = trim($matches[2]);
$matches2 = preg_replace('/ +/', ' ∩ ', $matches2) ?? $matches2;
$matches2 = preg_replace('/,/', ' ∪ ', $matches2) ?? $matches2;
if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
$matches2 = Preg::replace(
['/ +/', '/,/'],
[' ∩ ', ' ∪ '],
trim($matches[2])
);
$definedNameValue = $matches[1] . $matches2;
}
}
@@ -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;
}
}
@@ -53,7 +53,7 @@ abstract class DatabaseAbstract
/** @var callable */
$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)) {
$field = (int) $field - 1;
if ($field < 0 || $field >= count($fieldNames)) {
@@ -143,7 +143,7 @@ abstract class DatabaseAbstract
}
$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
);
@@ -24,7 +24,7 @@ class Date
* Excel Function:
* 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,
* as will a day value with a suffix (e.g. '21st' rather than simply 21); again only English language.
*
@@ -46,13 +46,22 @@ class 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
if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) {
return ExcelError::VALUE();
}
$dti = new DateTimeImmutable();
$baseYear = SharedDateHelper::getExcelCalendar();
$baseYear = $calendar ?? SharedDateHelper::getExcelCalendar();
$dateValue = trim((string) $dateValue, '"');
// Strip any ordinals because they're allowed in Excel (English only)
$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);
}
/** @param mixed[] $t1 */
/** @param list<float|int|string> $t1 */
private static function t1ToString(array $t1, DateTimeImmutable $dti, bool $yearFound): string
{
if (count($t1) == 2) {
@@ -118,7 +127,7 @@ class DateValue
if (!Helpers::dateParseSucceeded($PHPDateArray)) {
// If original count was 1, we've already returned.
// 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, '- ');
$testVal2 = strtok('- ');
$testVal3 = strtok('- ') ?: $dti->format('Y');
@@ -160,9 +169,9 @@ class DateValue
$day = self::getInt($PHPDateArray, 'day');
$year = self::getInt($PHPDateArray, '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;
@@ -50,11 +50,9 @@ class Days
$days = ExcelError::VALUE();
$diff = $PHPStartDateObject->diff($PHPEndDateObject);
if (!is_bool($diff->days)) {
$days = $diff->days;
if ($diff->invert) {
$days = -$days;
}
$days = $diff->days;
if ($diff->invert) {
$days = -$days;
}
return $days;
@@ -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
{
@@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
use Throwable;
class Helpers
{
@@ -29,10 +30,10 @@ class Helpers
*
* @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)) {
$retval = SharedDateHelper::PHPToExcel($dateValue);
$retval = SharedDateHelper::PHPToExcel($dateValue, calendar: $calendar);
if (is_bool($retval)) {
throw new Exception(ExcelError::VALUE());
}
@@ -40,7 +41,7 @@ class Helpers
return $retval;
}
self::nullFalseTrueToNumber($dateValue, $allowBool);
self::nullFalseTrueToNumber($dateValue, $allowBool, $calendar);
if (!is_numeric($dateValue)) {
$saveReturnDateType = Functions::getReturnDateType();
Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
@@ -56,6 +57,12 @@ class Helpers
throw new Exception(ExcelError::NAN());
}
try {
SharedDateHelper::excelToDateTimeObject((float) $dateValue, calendar: $calendar);
} catch (Throwable) {
throw new Exception(ExcelError::NAN());
}
return (float) $dateValue;
}
@@ -80,10 +87,10 @@ class Helpers
*
* @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
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
$PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue, calendar: $calendar);
$oMonth = (int) $PHPDateObject->format('m');
$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
*/
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();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
@@ -154,58 +161,60 @@ class Helpers
$dateArray['day'],
$dateArray['hour'],
$dateArray['minute'],
$dateArray['second']
$dateArray['second'],
calendar: $calendar
);
if ($retType === Functions::RETURNDATE_EXCEL) {
return $noFrac ? floor($excelDateValue) : $excelDateValue;
}
// RETURNDATE_UNIX_TIMESTAMP)
return SharedDateHelper::excelToTimestamp($excelDateValue);
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
}
/**
* 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();
if ($retType === Functions::RETURNDATE_EXCEL) {
return $excelDateValue;
}
if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
return SharedDateHelper::excelToTimestamp($excelDateValue);
return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
}
// RETURNDATE_PHP_DATETIME_OBJECT
return SharedDateHelper::excelToDateTimeObject($excelDateValue);
return SharedDateHelper::excelToDateTimeObject($excelDateValue, calendar: $calendar);
}
/**
* 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();
if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
return $PHPDateObject;
}
if ($retType === Functions::RETURNDATE_EXCEL) {
return (float) SharedDateHelper::PHPToExcel($PHPDateObject);
return (float) SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
}
// RETURNDATE_UNIX_TIMESTAMP
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject);
$stamp = SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
$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) {
return 0;
}
if (SharedDateHelper::getExcelCalendar() === SharedDateHelper::CALENDAR_MAC_1904) {
$calendar ??= SharedDateHelper::getExcelCalendar();
if ($calendar === SharedDateHelper::CALENDAR_MAC_1904) {
return 0;
}
@@ -215,10 +224,10 @@ class Helpers
/**
* 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);
$nullVal = self::baseDate();
$nullVal = self::baseDate($calendar);
if ($number === null) {
$number = $nullVal;
} elseif ($allowBool && is_bool($number)) {
@@ -4,7 +4,9 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Exception;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
use Throwable;
class TimeParts
{
@@ -44,6 +46,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -85,6 +92,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -126,6 +138,11 @@ class TimeParts
}
// Execute function
try {
SharedDateHelper::excelToDateTimeObject($timeValue);
} catch (Throwable) {
return ExcelError::NAN();
}
$timeValue = fmod($timeValue, 1);
$timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
SharedDateHelper::roundMicroseconds($timeValue);
@@ -3,7 +3,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
use Composer\Pcre\Preg;
use Datetime;
use DateTime;
use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
use PhpOffice\PhpSpreadsheet\Calculation\Functions;
use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
@@ -45,12 +45,12 @@ class TimeValue
* Date information in time_text is ignored.
* 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
* If an array of numbers is passed as the argument, then the returned result will also be an array
* 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)) {
return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $timeValue);
@@ -88,7 +88,7 @@ class TimeValue
} elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
$retValue = (int) SharedDateHelper::excelToTimestamp($excelDateValue + 25569) - 3600;
} 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']);
}
}
@@ -78,8 +78,8 @@ class Logger
{
// Only write the debug log if logging is enabled
if ($this->writeDebugLog) {
$message = sprintf($message, ...$args); //* @phpstan-ignore-line
$cellReference = implode(' -> ', $this->cellStack->showStack());
$message = sprintf($message, ...$args); //* @phpstan-ignore argument.type (...$args could contain items that sprintf can't handle)
$cellReference = implode(' -> ', $this->cellStack->showStack()); //* @phpstan-ignore argument.type (showStack can return array<mixed> not array<string>)
if ($this->echoDebugLog) {
echo $cellReference,
($this->cellStack->count() > 0 ? ' => ' : ''),
@@ -204,8 +204,9 @@ final class StructuredReference implements Operand, Stringable
/** @var string $reference */
$reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
foreach ($this->columns as $columnId => $columnName) {
$columnName = str_replace("\u{a0}", ' ', $columnName); //* @phpstan-ignore-line
foreach ($this->columns as $columnId => $columnNamex) {
/** @var string $columnNamex */
$columnName = str_replace("\u{a0}", ' ', $columnNamex);
$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
{
$columnsSelected = false;
foreach ($this->columns as $columnId => $columnName) {
$columnName = str_replace("\u{a0}", ' ', $columnName ?? ''); //* @phpstan-ignore-line
foreach ($this->columns as $columnId => $columnNamex) {
/** @var ?string $columnNamex */
$columnName = str_replace("\u{a0}", ' ', $columnNamex ?? '');
$cellFrom = "{$columnId}{$startRow}";
$cellTo = "{$columnId}{$endRow}";
$cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
@@ -150,7 +150,7 @@ class ConvertDecimal extends ConvertBase
/**
* toOctal.
*
* Return an decimal value as octal.
* Return a decimal value as octal.
*
* Excel Function:
* DEC2OCT(x[,places])
@@ -18,7 +18,7 @@ class ErfC
* 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
* 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:
* ERFC(x)
@@ -83,7 +83,7 @@ class Interest
* =ISPMT(interest_rate, period, number_payments, pv)
*
* @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 $principleRemaining is the loan amount or present value of the payments
*/
@@ -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;
}
}
@@ -166,7 +166,7 @@ class NonPeriodic
$foundneg = false;
for ($i = 0; $i < $valCount; ++$i) {
$fld = $values[$i];
if (!is_numeric($fld)) { //* @phpstan-ignore-line
if (!is_numeric($fld)) {
return ExcelError::VALUE();
} elseif ($fld > 0) {
$foundpos = true;
@@ -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
* 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/2004/12/excel_formula_p.html
*
* @deprecated 5.5.0 No replacement.
*/
class FormulaParser
{
@@ -463,16 +466,8 @@ class FormulaParser
$tokenCount = count($tokens1);
for ($i = 0; $i < $tokenCount; ++$i) {
$token = $tokens1[$i];
if (isset($tokens1[$i - 1])) {
$previousToken = $tokens1[$i - 1];
} else {
$previousToken = null;
}
if (isset($tokens1[$i + 1])) {
$nextToken = $tokens1[$i + 1];
} else {
$nextToken = null;
}
$previousToken = $tokens1[$i - 1] ?? null;
$nextToken = $tokens1[$i + 1] ?? null;
if ($token->getTokenType() != FormulaToken::TOKEN_TYPE_WHITESPACE) {
$tokens2[] = $token;
@@ -518,11 +513,7 @@ class FormulaParser
$tokenCount = count($tokens2);
for ($i = 0; $i < $tokenCount; ++$i) {
$token = $tokens2[$i];
if (isset($tokens2[$i - 1])) {
$previousToken = $tokens2[$i - 1];
} else {
$previousToken = null;
}
$previousToken = $tokens2[$i - 1] ?? null;
if ($token->getTokenType() == FormulaToken::TOKEN_TYPE_OPERATORINFIX && $token->getValue() == '-') {
if ($i == 0) {
@@ -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
* 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/2004/12/excel_formula_p.html
*
* @deprecated 5.5.0 No replacement.
*/
class FormulaToken
{
@@ -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}>
*/
protected static array $phpSpreadsheetFunctions = [
protected static array $phpSpreadsheetFunctions = [ // @phpstan-ignore property.defaultValue (Phpstan is wrong)
'ABS' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
'functionCall' => [MathTrig\Absolute::class, 'evaluate'],
@@ -149,7 +149,7 @@ class FunctionArray extends CalculationBase
],
'BAHTTEXT' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [TextData\Thai::class, 'getBahtText'],
'argumentCount' => '1',
],
'BASE' => [
@@ -405,6 +405,7 @@ class FunctionArray extends CalculationBase
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
'argumentCount' => '1+',
'passCellReference' => true,
],
'CONFIDENCE' => [
'category' => Category::CATEGORY_STATISTICAL,
@@ -919,7 +920,7 @@ class FunctionArray extends CalculationBase
'FLOOR' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
'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' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -1284,8 +1285,9 @@ class FunctionArray extends CalculationBase
],
'INFO' => [
'category' => Category::CATEGORY_INFORMATION,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Information\Info::class, 'getInfo'],
'argumentCount' => '1',
'passCellReference' => true,
],
'INT' => [
'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -2335,17 +2337,17 @@ class FunctionArray extends CalculationBase
],
'T.DIST' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDist'],
'argumentCount' => '3',
],
'T.DIST.2T' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDot2T'],
'argumentCount' => '2',
],
'T.DIST.RT' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDotRT'],
'argumentCount' => '2',
],
'TEXT' => [
@@ -2425,12 +2427,12 @@ class FunctionArray extends CalculationBase
],
'T.INV' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotInv'],
'argumentCount' => '2',
],
'T.INV.2T' => [
'category' => Category::CATEGORY_STATISTICAL,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
'argumentCount' => '2',
],
'TODAY' => [
@@ -2495,12 +2497,12 @@ class FunctionArray extends CalculationBase
],
'UNICHAR' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\CharacterConvert::class, 'character'],
'functionCall' => [TextData\CharacterConvert::class, 'characterUnicode'],
'argumentCount' => '1',
],
'UNICODE' => [
'category' => Category::CATEGORY_TEXT_AND_DATA,
'functionCall' => [TextData\CharacterConvert::class, 'code'],
'functionCall' => [TextData\CharacterConvert::class, 'codeUnicode'],
'argumentCount' => '1',
],
'UNIQUE' => [
@@ -2577,6 +2579,7 @@ class FunctionArray extends CalculationBase
'category' => Category::CATEGORY_WEB,
'functionCall' => [Web\Service::class, 'webService'],
'argumentCount' => '1',
'passCellReference' => true,
],
'WEEKDAY' => [
'category' => Category::CATEGORY_DATE_AND_TIME,
@@ -2625,7 +2628,7 @@ class FunctionArray extends CalculationBase
],
'XLOOKUP' => [
'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
'functionCall' => [Functions::class, 'DUMMY'],
'functionCall' => [LookupRef\XLookup::class, 'lookup'],
'argumentCount' => '3-6',
],
'XNPV' => [
@@ -3,6 +3,7 @@
namespace PhpOffice\PhpSpreadsheet\Calculation;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -360,4 +361,47 @@ class Functions
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;
}
}
@@ -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(),
};
}
}
@@ -52,8 +52,10 @@ class Value
if (!empty($worksheet) && $cell->getWorksheet()->getParentOrThrow()->getSheetByName($worksheet) === null) {
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;
}
@@ -265,7 +267,7 @@ class Value
if (is_bool($value)) {
return (int) $value;
}
if (is_string($value) && substr($value, 0, 1) === '#') {
if (is_string($value) && str_starts_with($value, '#')) {
return $value;
}
@@ -50,7 +50,7 @@ class Conditional
{
$condition = ($condition === null) ? true : Functions::flattenSingleValue($condition);
if (ErrorValue::isError($condition)) {
if (ErrorValue::isError($condition, true)) {
return $condition;
}
@@ -18,7 +18,7 @@ class ChooseRowsEtc
*/
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[] */
@@ -58,7 +58,7 @@ class ExcelMatch
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)) {
$lookupValue = StringHelper::strToLower($lookupValue);
}
@@ -70,7 +70,7 @@ class ExcelMatch
};
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
@@ -58,7 +58,7 @@ class Filter
*/
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(
array_values($lookupArray),
@@ -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 = [];
foreach ($transpose as $array) {
$returnMatrix[] = Functions::flattenArray($array);
@@ -23,6 +23,13 @@ class Hyperlink
*/
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));
$displayName = ($displayName === null) ? '' : Functions::flattenSingleValue($displayName);
@@ -30,17 +37,16 @@ class Hyperlink
return ExcelError::REF();
}
if (is_object($displayName)) {
$displayName = $linkURL;
}
$displayName = StringHelper::convertToString($displayName);
$displayName = StringHelper::convertToString($displayName, false);
if (trim($displayName) === '') {
$displayName = $linkURL;
}
$cell->getHyperlink()
->setUrl($linkURL);
$cell->getHyperlink()->setTooltip($displayName);
$worksheet?->getCell($coordinate)
->getHyperlink()
->setUrl($linkURL)
->setTooltip($displayName)
->setDisplay('');
return $displayName;
}
@@ -106,14 +106,14 @@ class Indirect
*/
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);
}
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
if (ctype_digit($start) && $start <= 1048576) {
if (ctype_digit($start) && $start <= AddressRange::MAX_ROW) {
// Max 16,384 columns for Excel2007
$endColRef = ($worksheet !== null) ? $worksheet->getHighestDataColumn((int) $start) : AddressRange::MAX_COLUMN;
@@ -40,15 +40,15 @@ class Lookup
$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) {
$resultVector = array_pop($lookupVector);
$lookupVector = array_shift($lookupVector);
}
/** @var mixed[] $lookupVector */
/** @var mixed[] $resultVector */
/** @var array<int, mixed> $lookupVector */
/** @var array<int, mixed> $resultVector */
if ($lookupColumns !== 2) {
$lookupVector = self::verifyLookupValues($lookupVector, $resultVector);
}
@@ -57,8 +57,8 @@ class Lookup
}
/**
* @param mixed[] $lookupVector
* @param mixed[] $resultVector
* @param array<int, mixed> $lookupVector
* @param array<int, mixed> $resultVector
*
* @return mixed[]
*/
@@ -80,6 +80,7 @@ class Lookup
if (is_array($dataValue2)) {
$dataValue2 = array_shift($dataValue2);
}
/** @var int $key2 */
$value = [$key1 => $dataValue1, $key2 => $dataValue2];
}
unset($value);

Some files were not shown because too many files have changed in this diff Show More