From 8a45a88c9bc2b5e4bf05bf51c1cd83b158175972 Mon Sep 17 00:00:00 2001 From: Jack Polgar Date: Fri, 21 Nov 2025 23:37:23 +1100 Subject: [PATCH 1/5] Set version to 3.9-alpha.1 --- src/Traq/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Traq/version.php b/src/Traq/version.php index 6a5b53ea..e6a5db39 100644 --- a/src/Traq/version.php +++ b/src/Traq/version.php @@ -22,7 +22,7 @@ */ // Version -define("TRAQ_VER", "3.9.0"); // Pretty obvious... +define("TRAQ_VER", "3.9.0-alpha.1"); // Pretty obvious... // Version code // 1.2.3 -> 10203 From 813c0090c23da350eaf0b542eaacd8e79caae744 Mon Sep 17 00:00:00 2001 From: Jack Polgar Date: Tue, 20 Oct 2026 23:38:01 +1100 Subject: [PATCH 2/5] Check for existing locale file to avoid errors and broken translations --- src/Traq/Locale.php | 43 ++++++++--------------- src/Traq/Locale/enus.php | 3 +- src/Traq/Middleware/UserMiddleware.php | 4 +-- src/Traq/Models/User.php | 5 +++ src/Traq/common.php | 47 ++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/Traq/Locale.php b/src/Traq/Locale.php index e4a4372e..f7a3ae4e 100644 --- a/src/Traq/Locale.php +++ b/src/Traq/Locale.php @@ -64,42 +64,29 @@ public function __construct() */ public static function load($locale) { - $paths = [ - DOCROOT . "/src/Traq/Locale", - DATADIR . "/locale", - ]; - - $path = null; - foreach ($paths as $path) { - if (file_exists($path . "/{$locale}.php")) { - $path = $path . "/{$locale}.php"; - break; - } + $file_path = locale_file($locale); + if (!$file_path) { + return false; } - // Check if the file exists.. - if ($path) { - $class = "\\Traq\Locale\\{$locale}"; + $class = "\\Traq\Locale\\{$locale}"; - // Make sure the class isn't loaded already - if (!class_exists($class)) { - require $path; - } + // Make sure the class isn't loaded already + if (!class_exists($class)) { + require $file_path; + } - $localization = new $class(); + $localization = new $class(); - // Load plugin translation files - foreach (Load::$search_paths as $path) { - $locale_file = "{$path}/locale/{$locale}.php"; - if (file_exists($locale_file)) { - $localization->add(include($locale_file)); - } + // Load plugin translation files + foreach (Load::$search_paths as $path) { + $locale_file = "{$path}/locale/{$locale}.php"; + if (file_exists($locale_file)) { + $localization->add(include($locale_file)); } - - return $localization; } - return false; + return $localization; } /** diff --git a/src/Traq/Locale/enus.php b/src/Traq/Locale/enus.php index 1cdc8243..8e7e745c 100644 --- a/src/Traq/Locale/enus.php +++ b/src/Traq/Locale/enus.php @@ -579,7 +579,8 @@ public static function locale() 'doesnt_exist' => "User doesn't exist", 'already_a_project_member' => "User is already a project member", 'password_same' => "Your new password cannot be the same as your current password", - 'username_too_long' => "Username cannot be longer than 25 characters" + 'username_too_long' => "Username cannot be longer than 25 characters", + 'locale_invalid' => "You must choose a valid language" ), // Traq Settings errors diff --git a/src/Traq/Middleware/UserMiddleware.php b/src/Traq/Middleware/UserMiddleware.php index 8c5a1229..5d76c3bb 100644 --- a/src/Traq/Middleware/UserMiddleware.php +++ b/src/Traq/Middleware/UserMiddleware.php @@ -90,8 +90,8 @@ private function loadUser(): void // If a user was found, load their language if ($this->user) { - // Load user's locale - if ($this->user->locale != '') { + // Load the user's locale, or keep the default when that file is missing. + if (locale_exists($this->user->locale)) { $user_locale = Locale::load($this->user->locale); if ($user_locale) { $locale = $user_locale; diff --git a/src/Traq/Models/User.php b/src/Traq/Models/User.php index 4e3dbe60..13e075fd 100644 --- a/src/Traq/Models/User.php +++ b/src/Traq/Models/User.php @@ -342,6 +342,11 @@ public function is_valid() $this->errors['email'] = l('errors.users.email_in_use'); } + // Profile and admin user saves both come through here. + if (!empty($this->_data['locale']) && !locale_exists($this->_data['locale'])) { + $this->errors['locale'] = l('errors.users.locale_invalid'); + } + // Check if we're valid or not... if (count($this->errors) > 0) { $this->errors = $this->errors; diff --git a/src/Traq/common.php b/src/Traq/common.php index f875a70f..f4723fa6 100644 --- a/src/Traq/common.php +++ b/src/Traq/common.php @@ -85,6 +85,53 @@ function ldate() return call_user_func_array(array($locale, 'date'), func_get_args()); } +/** + * Path to a locale file, or false. + * The code must be a single filename, then it is matched against files that + * actually exist. "../index" never becomes part of the path. + * + * @param mixed $locale + * + * @return string|false + */ +function locale_file($locale) +{ + if (!is_string($locale) || $locale === '' || basename($locale) !== $locale) { + return false; + } + + $directories = [DOCROOT . '/src/Traq/Locale']; + if (defined('DATADIR')) { + $directories[] = DATADIR . '/locale'; + } + + foreach ($directories as $directory) { + if (!is_dir($directory)) { + continue; + } + + foreach (scandir($directory) as $file) { + if (strcasecmp($file, $locale . '.php') === 0) { + return $directory . '/' . $file; + } + } + } + + return false; +} + +/** + * Whether $locale names a file in a locale directory. + * + * @param mixed $locale + * + * @return bool + */ +function locale_exists($locale) +{ + return locale_file($locale) !== false; +} + /** * Returns a list of available localisations formatted * for the Form::select() helper. From 35291581c3599a7821ee38366aa244d48e89b9d8 Mon Sep 17 00:00:00 2001 From: Jack Polgar Date: Tue, 20 Oct 2026 23:46:29 +1100 Subject: [PATCH 3/5] Fix up ticket history edit/deletion --- .../Controllers/TicketHistoryController.php | 103 +++++++++++++----- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/src/Traq/Controllers/TicketHistoryController.php b/src/Traq/Controllers/TicketHistoryController.php index 54662a92..c28f79a1 100644 --- a/src/Traq/Controllers/TicketHistoryController.php +++ b/src/Traq/Controllers/TicketHistoryController.php @@ -24,7 +24,9 @@ namespace Traq\Controllers; use Avalon\Http\Request; -use Avalon\Output\View; +use Avalon\Http\Response; +use Traq\Models\Ticket; +use Traq\Models\TicketHistory; /** * Ticket History controller @@ -36,24 +38,20 @@ */ class TicketHistoryController extends AppController { - // Before filters - public $before = [ - // 'edit' => array('_check_permission'), - 'delete' => ['_check_permission'] - ]; - /** - * Edit ticket update + * Edit ticket update. * - * @param integer $id + * The history id is only valid for the ticket number in this project. */ - public function edit(int $id) + public function edit(int $ticket_id, int $id): Response { - // Get the ticket update - $history = \Traq\Models\TicketHistory::find($id); + $history = $this->historyForTicket($ticket_id, $id); + if ($history instanceof Response) { + return $history; + } - if ($history->user_id !== $this->user->id && !$this->user->permission($this->project->id, "edit_ticket_history")) { - // oh noes! display the no permission page. + $isAuthor = (int) $history->user_id === (int) $this->user->id; + if (!$isAuthor && !$this->user->permission((int) $history->ticket->project_id, 'edit_ticket_history')) { return $this->renderNoPermission(); } @@ -68,39 +66,86 @@ public function edit(int $id) } } - // View::set('history', $history); - return $this->render('ticket_history/edit.overlay.phtml', [ 'history' => $history ]); } /** - * Delete ticket update + * Delete ticket update. * - * @param integer $id + * The history id is only valid for the ticket number in this project. */ - public function delete(int $id) + public function delete(int $ticket_id, int $id): Response { - // Get the ticket update - $history = \Traq\Models\TicketHistory::find($id); + $history = $this->historyForTicket($ticket_id, $id); + if ($history instanceof Response) { + return $history; + } + + if (!$this->user->permission((int) $history->ticket->project_id, 'delete_ticket_history')) { + return $this->renderNoPermission(); + } - // Delete the update $history->delete(); return $this->redirectTo($history->ticket->href()); } /** - * Used to check the permission for the requested action. + * History ids are shared across the instance. Resolve the ticket number + * inside this project first, then load the history row only for that ticket. + */ + private function historyForTicket(int $ticketNumber, int $historyId): TicketHistory|Response + { + if (!$this->project) { + return $this->show404(); + } + + $ticket = Ticket::select() + ->where('ticket_id', $ticketNumber) + ->where('project_id', (int) $this->project->id) + ->exec() + ->fetch(); + + if (!$ticket || (int) $ticket->project_id !== (int) $this->project->id) { + return $this->show404(); + } + + if (!$this->canViewTicket($ticket)) { + return $this->renderNoPermission(); + } + + $history = TicketHistory::select() + ->where('id', $historyId) + ->where('ticket_id', (int) $ticket->id) + ->exec() + ->fetch(); + + if (!$history || (int) $history->ticket_id !== (int) $ticket->id) { + return $this->show404(); + } + + return $history; + } + + /** + * Same visibility rule as the ticket page, including private tickets. */ - public function _check_permission($action) + private function canViewTicket(Ticket $ticket): bool { - // Check if the user has permission - if (!$this->user->permission($this->project->id, "{$action}_ticket_history")) { - // oh noes! display the no permission page. - $this->show_no_permission(); - return false; + if (!$ticket->is_private) { + return true; + } + + if ((int) $this->user->id === (int) $ticket->user_id) { + return true; } + + if ($this->user->group->is_admin) { + return true; + } + + return $this->project->is_member($this->user); } } From ee783e402440e961da7b57f8e5e643fd4efb5dd3 Mon Sep 17 00:00:00 2001 From: Jack Polgar Date: Tue, 20 Oct 2026 23:46:37 +1100 Subject: [PATCH 4/5] Update gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index e493c3aa..dfdcdebb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ +# Ignore all vendor/ directory except for Traq +!vendor/traq +!vendor/bootstrap.php vendor/ +vendor/traq/config # Ignore config files data/config/config.php From ffd6e765b3f3b9f1385998b7ca368042419653df Mon Sep 17 00:00:00 2001 From: Jack Polgar Date: Wed, 21 Oct 2026 00:00:43 +1100 Subject: [PATCH 5/5] Update GitHub workflows --- .github/workflows/build-tag.yml | 5 ++++- .github/workflows/ci-build.yml | 4 +++- pnpm-workspace.yaml | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 9cca2f33..ecd72186 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -26,7 +26,9 @@ jobs: # Node stuff - name: Install pnpm - run: npm install -g pnpm + uses: pnpm/action-setup@v4 + with: + version: 12 - name: Install node dependencies run: pnpm i @@ -54,6 +56,7 @@ jobs: rm ./package.json rm ./phpstan.neon rm ./pnpm-lock.yaml + rm ./pnpm-workspace.yaml rm ./postcss.config.js rm ./tailwind.config.js rm ./tsconfig.json diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 778c243c..1df2f962 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -30,7 +30,9 @@ jobs: # Node stuff - name: Install pnpm - run: npm install -g pnpm + uses: pnpm/action-setup@v4 + with: + version: 12 - name: Install node dependencies run: pnpm install diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..0f3d9de8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +# esbuild and @parcel/watcher ship native binaries via install scripts. +# pnpm ignores those scripts unless they are listed here. +allowBuilds: + "@parcel/watcher": true + esbuild: true