Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: ${{ !matrix.intl && ':intl' || '' }}
extensions: pdo_mysql${{ !matrix.intl && ', :intl' || '' }}

- uses: actions/checkout@v7

Expand Down
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
FROM docker.io/library/php:8.4-apache

COPY --from=ghcr.io/mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions exif gd zip pdo_mysql intl
RUN install-php-extensions exif gd zip pdo_mysql pdo_sqlite intl imap

RUN a2enmod rewrite

# Container health check (Apache on :80)
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD php -r "exit(@fsockopen('127.0.0.1', 80) ? 0 : 1);"

COPY --chown=33:33 . /var/www/html/
RUN echo 'upload_max_filesize = 1024M' > /usr/local/etc/php/conf.d/limits.ini
RUN printf 'upload_max_filesize = 1024M\npost_max_size = 1100M\n' > /usr/local/etc/php/conf.d/limits.ini
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
11 changes: 8 additions & 3 deletions app/controller/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,11 @@ public function user_save(\Base $f3): void
throw new \Exception("Please enter a name.");
}

if (preg_match("/#?[0-9a-f]{3,6}/i", (string) $f3->get("POST.task_color")) === false) {
if (preg_match("/^#?(?:[0-9a-f]{3}|[0-9a-f]{6})\\z/i", (string) $f3->get("POST.task_color")) !== 1) {
throw new \Exception("Please enter a valid hex color.");
}

if (preg_match("/[0-9a-z_-]+/i", (string) $f3->get("POST.username")) === false) {
if (preg_match("/^[0-9a-z_-]+\\z/i", (string) $f3->get("POST.username")) !== 1) {
throw new \Exception("Usernames can only contain letters, numbers, hyphens, and underscores.");
}

Expand Down Expand Up @@ -593,7 +593,12 @@ public function group_ajax(\Base $f3): void
$this->_printJson(["changed" => 1]);
break;
case "change_task_color":
$group->task_color = ltrim((string) $f3->get("POST.value"), '#');
$color = ltrim((string) $f3->get("POST.value"), '#');
if (!ctype_xdigit($color) || (strlen($color) !== 3 && strlen($color) !== 6)) {
$f3->error(400);
return;
}
$group->task_color = $color;
$group->save();
$this->_printJson(["changed" => 1]);
break;
Expand Down
2 changes: 1 addition & 1 deletion app/controller/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public function registerpost($f3): void
$errors[] = "Name is required";
}

if (preg_match("/^[0-9a-z]{4,}$/i", (string) $f3->get("POST.register-username")) === false) {
if (preg_match("/^[0-9a-z]{4,}$/i", (string) $f3->get("POST.register-username")) !== 1) {
$errors[] = "Usernames must be at least 4 characters and can only contain letters and numbers.";
}

Expand Down
81 changes: 70 additions & 11 deletions app/helper/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,82 @@ public function checkDatabaseVersion(): string|bool
}

/**
* Install latest core database updates
* Install a core database update
* @return bool TRUE if the update was applied, FALSE on failure
*/
public function updateDatabase(string $version): void
public function updateDatabase(string $version): bool
{
$f3 = \Base::instance();
if (file_exists("db/{$version}.sql")) {
$update_db = file_get_contents("db/{$version}.sql");
$db = $f3->get("db.instance");
foreach (explode(";", $update_db) as $stmt) {
$db->exec($stmt);
if (!file_exists("db/{$version}.sql")) {
$f3->set("error", " Database file not found for version: {$version}");
return false;
}

$update_db = file_get_contents("db/{$version}.sql");
$db = $f3->get("db.instance");
try {
// Note: MySQL implicitly commits around DDL statements, so a
// failed migration there can't be rolled back. The version row
// is only updated by the last statement, so a failure leaves
// the database at the previous version and surfaces an error.
// Already-applied DDL (e.g. an added column) stays in place,
// but replaying it on retry is tolerated (see
// isAlreadyAppliedSchemaError()), so no manual cleanup is
// needed before retrying. SQLite migrations are fully atomic.
$db->begin();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
foreach (explode(";", (string) $update_db) as $stmt) {
if (trim($stmt) !== "") {
try {
$db->exec($stmt);
} catch (\Throwable $e) {
if (!$this->isAlreadyAppliedSchemaError($db->driver(), $e)) {
throw $e;
}
}
}
}
if ($db->pdo()->inTransaction()) {
$db->commit();
}
} catch (\Throwable $e) {
try {
if ($db->pdo()->inTransaction()) {
$db->rollback();
}
} catch (\Throwable) {
// Keep the original migration error.
}
$f3->set("error", " Database update to version {$version} failed: " . $e->getMessage());
return false;
Comment thread
Alanaktion marked this conversation as resolved.
}

\Cache::instance()->reset();
$f3->set("success", " Database updated to version: {$version}");
} else {
$f3->set("error", " Database file not found for version: {$version}");
\Cache::instance()->reset();
$f3->set("success", " Database updated to version: {$version}");
return true;
}

/**
* Check whether a migration statement failure only means the statement
* was already applied by an earlier partial run.
*
* MySQL implicitly commits DDL, so when a migration fails partway
* through, the statements that already ran stay applied; retrying the
* migration replays them, and each replayed statement fails. A replayed
* statement fails with a "duplicate"/"already exists" schema error,
* which is safe to skip because the desired end state is already in
* place. Anything else — including data-dependent "Duplicate entry"
* errors — is a real failure and must still abort the migration.
* @param string $driver PDO driver name ("mysql", "sqlite", ...)
*/
protected function isAlreadyAppliedSchemaError(string $driver, \Throwable $e): bool
{
if ($driver !== "mysql") {
return false;
}
return (bool) preg_match(
"/\\bduplicate\\s+(column|key|index|foreign key constraint)\\s+name\\b|\\balready exists\\b/i",
$e->getMessage()
);
}

/**
Expand Down
17 changes: 12 additions & 5 deletions app/helper/view.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,18 @@ protected function _parseEmoticons(string $str): string
*/
protected function _parseTextile(string $str, bool $escape = true): string
{
error_reporting(E_ALL ^ E_DEPRECATED); // Temporarily ignore deprecations because this lib is incompatible
$tex = new \Netcarver\Textile\Parser('html5');
$tex->setDimensionlessImages(true);
$tex->setRestricted($escape);
return $tex->parse($str);
// Temporarily ignore deprecations because this lib is incompatible,
// restoring the previous level afterwards so deprecations elsewhere
// in the request are still reported.
$level = error_reporting(E_ALL ^ E_DEPRECATED);
try {
$tex = new \Netcarver\Textile\Parser('html5');
$tex->setDimensionlessImages(true);
$tex->setRestricted($escape);
return $tex->parse($str);
} finally {
error_reporting($level);
}
}

/**
Expand Down
11 changes: 9 additions & 2 deletions app/model/issue.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,15 @@ public static function create(array $data, bool $notify = true): static
}

if (!empty($data["due_date"])) {
if (preg_match("/\\d{4}(-\\d{2}){2}/", (string) $data["due_date"]) === false) {
$data["due_date"] = date("Y-m-d", strtotime((string) $data["due_date"]));
$dueDate = (string) $data["due_date"];
$date = \DateTime::createFromFormat("Y-m-d", $dueDate);
if ($date === false || $date->format("Y-m-d") !== $dueDate) {
// Not already a valid Y-m-d date, try to parse it
$parsed = strtotime($dueDate);
if ($parsed === false) {
throw new \Exception("Please enter a valid due date.");
}
$data["due_date"] = date("Y-m-d", $parsed);
}

if (empty($data["sprint_id"]) && !empty($data['due_date_sprint'])) {
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
}
},
"require-dev": {
"phpstan/phpstan": "^2.2.14",
"squizlabs/php_codesniffer": "^3.13.6",
"rector/rector": "^2.7.0",
"phpunit/phpunit": "^10.5.66"
Expand Down
8 changes: 4 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions cron/base.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,27 @@
]);

// Load local configuration
$f3->mset(require_once(__DIR__ . '/config.php'));
$config_file = $homedir . "config.php";
if (!is_file($config_file) && is_file(__DIR__ . "/config.php")) {
// Fall back to legacy cron-specific config location
$config_file = __DIR__ . "/config.php";
}
$f3->mset(require_once($config_file));

// Connect to database
$f3->set("db.instance", new \Helper\SQL(
"mysql:host=" . $f3->get("db.host") . ";port=3306;dbname=" . $f3->get("db.name"),
$f3->get("db.user"),
$f3->get("db.pass")
));
if ($f3->get("db.engine") == "sqlite") {
$db_name = $f3->get("db.name");
if ($db_name !== ":memory:" && substr($db_name, 0, 1) !== "/") {
$db_name = $homedir . $db_name;
}
$f3->set("db.instance", new \Helper\SQL("sqlite:" . $db_name));
} else {
$f3->set("db.instance", new \Helper\SQL(
"mysql:host=" . $f3->get("db.host") . ";port=" . ($f3->get("db.port") ?: 3306) . ";dbname=" . $f3->get("db.name"),
$f3->get("db.user"),
$f3->get("db.pass")
));
}

// Load database-backed config
\Model\Config::loadAll();
30 changes: 25 additions & 5 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"CACHE" => true,
"AUTOLOAD" => "app/;lib/vendor/",
"JAR.samesite" => "Lax",
"JAR.secure" => $f3->get("SCHEME") == "https",
"PACKAGE" => "Phproject",
"TZ" => "UTC",
"microtime" => microtime(true),
Expand Down Expand Up @@ -82,17 +83,36 @@
"mysql:host=" . $f3->get("db.host") . ";port=" . $f3->get("db.port") . ";dbname=" . $f3->get("db.name"),
$f3->get("db.user"),
$f3->get("db.pass"),
[\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4;']
// Pdo\Mysql::ATTR_INIT_COMMAND exists on PHP 8.4+; PDO::MYSQL_ATTR_INIT_COMMAND is deprecated since 8.5
[(\PHP_VERSION_ID >= 80400 ? \Pdo\Mysql::ATTR_INIT_COMMAND : \PDO::MYSQL_ATTR_INIT_COMMAND) => 'SET NAMES utf8mb4;']
));
}

// Load final configuration
\Model\Config::loadAll();

// Ensure database is up to date
$version = \Helper\Security::instance()->checkDatabaseVersion();
if ($version !== true) {
\Helper\Security::instance()->updateDatabase($version);
// Ensure database is up to date, applying all pending migrations in one request.
// Serialized with a non-blocking file lock so concurrent requests can't run
// the same migration twice; a request that can't acquire the lock skips this,
// since another request is already migrating.
$lock = fopen($f3->get("TEMP") . "migrations.lock", "c");
if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
Comment thread
Alanaktion marked this conversation as resolved.
try {
$security = \Helper\Security::instance();
$migrations = 0;
while (($version = $security->checkDatabaseVersion()) !== true && $migrations < 100) {
if (!$security->updateDatabase($version)) {
break;
}
$f3->set("version", $version);
$migrations++;
}
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
} elseif ($lock) {
fclose($lock);
}

// Set up CSRF protection
Expand Down
2 changes: 1 addition & 1 deletion nginx-example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ server {
include fastcgi_params;
}

client_max_body_size 64M;
client_max_body_size 1100M; # must be >= PHP post_max_size (see Dockerfile)
}
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ parameters:
- tests
excludePaths:
- app/plugin/*
scanFiles:
- phpstan/scan/pdo_mysql.php
ignoreErrors:
- '#Unsafe usage of new static\(\).#'
- '#Access to an undefined property Model\\Custom::.*#'
17 changes: 17 additions & 0 deletions phpstan/scan/pdo_mysql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

// Static-analysis shim for the pdo_mysql extension's driver-specific PDO subclass.
//
// Pdo\Mysql only exists on PHP 8.4+ with pdo_mysql loaded, but the codebase
// references Pdo\Mysql::ATTR_INIT_COMMAND behind a PHP_VERSION_ID guard to
// stay compatible with PHP 8.1-8.3 (where PDO::MYSQL_ATTR_INIT_COMMAND is
// not deprecated). PHPStan scans this file (see scanFiles in phpstan.neon)
// so it can resolve the class when analyzing on older PHP versions or
// without the pdo_mysql extension installed.

namespace Pdo;

class Mysql extends \PDO
{
public const ATTR_INIT_COMMAND = 1002;
}
Loading