Files
api/services/nginx/app/routes/notificationsRoute.php
T
Jeppe B ab6c3ba5b6 Fix route permission instance calls (#344)
## Root cause

`route_t::hasPermission()` and `requirePermission()` are instance
methods. Route code was invoking them with `self::`; the new XL Vask
hall-scope helper made that call from a genuinely static context,
causing PHP to throw:

`Non-static method routes\\xlvaskUsageLogsRoute::hasPermission() cannot
be called statically`

## Changes

- Invoke route permission methods through `$this` across all 273
executable legacy calls in 45 route classes.
- Make `xlvaskUsageLogsRoute::allowedHallIdsForUser()` an instance
helper and update all 13 callers.
- Preserve the existing all-scope and own-scope hall selection rules.
- Add a token-aware regression test that rejects executable
`self::hasPermission()` and `self::requirePermission()` calls, while
ignoring comments.
- Add focused XL Vask tests for global scanner hall scope and
group-limited own scope.
- Update affected route contract assertions to the instance-call form.

## Verification

- PHP lint: all 53 changed PHP files
- Focused PHPStan: changed XL Vask route and both new regression tests —
clean
- Focused regression slice: 58 passed, 748 assertions
- Full local unit suite: 1,300 passed, 9,442 assertions (1 unrelated
existing warning, 1 environment skip)
- Full local API suite: 285 passed, 11,704 assertions
- Exact-SHA GitHub Tests workflow: all 7 jobs passed (unit, API,
integration, legacy, edge gateway, and supporting checks)
- Independent exact-SHA QA gate: PASS, no findings
- Independent exact-SHA security gate: PASS, no findings
- Independent exact-SHA reviewer gate: PASS, no findings
- Remote comparison: exactly one commit ahead of
`40b104abed7723a7d1b7028190ecda0e7aeef829`; all 53 remote blob hashes
matched the reviewed worktree

## Delivery state

Draft only for human review. No merge or deployment is included. Qodana
is skipped while the PR remains draft and is therefore not represented
as a passed gate.
2026-08-04 16:04:41 +02:00

178 lines
8.4 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use objects\logs_o;
use objects\notifications_o;
use traits\route_t;
class notificationsRoute
{
use route_t;
public function run(): void
{
$this->get('/notifications', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('list_notifications');
// Check if the user has permission to list all notifications
if ($this->hasPermission('list_all_notifications')) {
$this->requirePermission('list_all_notifications');
} else {
$this->requirePermission('list_own_notifications');
}
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, $user->id, 'LIST_OWN_NOTIFICATIONS', 'User accessed the list of notifications');
$notifications = new notifications_o();
// Return the list of notifications
$response->success(
$notifications
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'type',
'user_id',
'data',
'created_at',
'deleted_at',
])
->listObjectsWithPaginationIfSet(
function ($notification) use ($notifications, $user) {
$tmp_notification = [
'id' => (int)$notification['id'],
'type' => (string)$notification['type'],
'user_id' => (int)$notification['user_id'],
'data' => $notification['data'] ? $notifications->decodeData($notification['data']) : null,
'created_at' => (string)$notification['created_at'],
'deleted_at' => $notification['deleted_at'] ? (string)$notification['deleted_at'] : null,
];
return $tmp_notification;
},
$notifications->forceRestrictFilters(
[
// This makes sure that the user can only see their own notifications
'user_id' => $user->id,
]
)
)
);
} else {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, 0, 'LIST_OWN_NOTIFICATIONS', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'list_notifications' => 'List notifications, provided the user has either list_all_notifications, or list_own_notifications permission',
'list_own_notifications' => 'List all notifications for the logged in user',
'list_all_notifications' => 'List all notifications for all users (superuser only)',
]
);
$this->post('/notifications', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('add_notification');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the post data
$data = [];
// Check if the required fields are set
self::requireParameters(['type', 'user_id']);
self::requireType((string)self::getParameter('type'), self::TYPE_STRING());
self::requireType((int)self::getParameter('user_id'), self::TYPE_INT());
// If the data is set, check if it is an array
if (self::isParametersSet(['data'])) {
self::requireTypeIn(
(array)self::getParameter('data'),
[
self::TYPE_ARRAY(),
self::TYPE_NULL()
]
);
// Check if the data is an array
if (self::getParameter('data') !== null) {
// JSON decode the data
$data = json_decode(self::getParameter('data'), true);
}
}
// Check if the user_id is set
// Add the notification
(new notifications_o())->add(
(string)self::getParameter('type'),
(int)self::getParameter('user_id'),
(array)$data
);
// Log the incident
(new logs_o())->add('notifications', 'global', 1, $user->id, 'ADD_NOTIFICATION', 'User added a notification');
// Return the list of departments
$response->success(['message' => 'Notification added successfully']);
} else {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, 0, 'ADD_NOTIFICATION', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'add_notification' => 'Add a notification'
]
);
$this->delete('/notifications', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('delete_own_notifications');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Require the parameters
self::requireParameters(['id']);
self::requireType((int)self::getParameter('id'), self::TYPE_INT());
// Get the department category object
$notification = (new notifications_o())->select(self::getParameter('id'));
// Validate the department category object
if (!$notification->exists()) {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User tried to delete a notification that does not exist');
// Return an error
$response->error('Notification does not exist', 400);
}
// Check if the user is the owner of the notification
if ((int)$notification->user_id->value() !== (int)$user->id) {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User tried to delete a notification that does not belong to them');
// Return an error
$response->error('You do not have permission to delete this notification', 403);
}
// Delete the department category
$notification->delete();
// Log the incident
(new logs_o())->add('notifications', 'global', 1, $user->id, 'DELETE_OWN_NOTIFICATIONS', 'User deleted a notification');
// Return the list of departments
$response->success(['message' => 'Notification deleted successfully']);
} else {
// Log the incident
(new logs_o())->add('notifications', 'global', 1, 0, 'DELETE_OWN_NOTIFICATIONS', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'delete_own_notifications' => 'Delete a notification'
]
);
}
}