Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc732e605 | ||
|
|
9d8a73c355 | ||
|
|
c78d24154a | ||
|
|
98d30cbc58 | ||
|
|
99a0cf59a8 | ||
|
|
5709c3134f | ||
|
|
60bafae8a0 | ||
|
|
90d798966b | ||
|
|
442cd1d6f6 |
+11
-4
@@ -49,19 +49,26 @@ APP_DOMAIN=
|
||||
; Include all domains that can access this application
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
; ============================================================================
|
||||
; REQUIRED FOR PRODUCTION -- READ BEFORE DEPLOYING -- TRUSTED_PROXIES
|
||||
; ============================================================================
|
||||
; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy.
|
||||
; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth
|
||||
; headers (Remote-User / Remote-Groups) and forwarded client IPs are only
|
||||
; trusted when REMOTE_ADDR is in this list.
|
||||
;
|
||||
; Leaving this EMPTY disables reverse-proxy verification entirely: the app then
|
||||
; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if
|
||||
; the PHP backend is reachable directly (bypassing the proxy), because a client
|
||||
; can then spoof those headers and log in as an admin. Only leave it empty when
|
||||
; network topology guarantees PHP is reachable solely via the trusted proxy.
|
||||
; trusts Remote-User / Remote-Groups headers from ANY source. If the PHP
|
||||
; backend is reachable directly -- a misconfigured firewall rule, a container
|
||||
; network accidentally exposing the port, SSRF from another internal service
|
||||
; -- ANYONE can set Remote-User: admin themselves and fully impersonate any
|
||||
; user, including an admin, with ZERO authentication. Only leave it empty when
|
||||
; network topology guarantees PHP is reachable solely via the trusted proxy
|
||||
; (e.g. local development), never in a real deployment.
|
||||
;
|
||||
; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27
|
||||
; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28
|
||||
; ============================================================================
|
||||
TRUSTED_PROXIES=
|
||||
|
||||
; Timezone (default: America/New_York)
|
||||
|
||||
@@ -447,6 +447,21 @@ APP_DOMAIN=your.domain.example
|
||||
TIMEZONE=America/New_York
|
||||
```
|
||||
|
||||
**⚠️ REQUIRED FOR PRODUCTION — `TRUSTED_PROXIES`:** This app trusts Authelia
|
||||
forward-auth headers (`Remote-User`, `Remote-Groups`, etc.) to identify who's
|
||||
logged in. `TRUSTED_PROXIES` restricts that trust to requests that actually
|
||||
came through your reverse proxy — **leaving it empty disables that check
|
||||
entirely**, and anyone who can reach the PHP backend directly (a
|
||||
misconfigured firewall rule, an exposed container port, SSRF from another
|
||||
internal service) can set `Remote-User: admin` themselves and fully
|
||||
impersonate any user with zero authentication. Set it to your reverse proxy's
|
||||
IP address(es) before deploying anywhere reachable beyond your own machine:
|
||||
```env
|
||||
TRUSTED_PROXIES=10.10.10.27
|
||||
```
|
||||
`GET /api/health.php` reports a `warning` on the `trusted_proxies` check if
|
||||
this is left empty, so it doesn't go unnoticed after deployment.
|
||||
|
||||
Matrix notification variables (all optional):
|
||||
```env
|
||||
# hookshot generic webhook URL — send events to Matrix room
|
||||
|
||||
+10
-3
@@ -177,9 +177,16 @@ try {
|
||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||
|
||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
||||
if (!empty($mentionedUsers)) {
|
||||
$mentionedUsernames = array_column($mentionedUsers, 'username');
|
||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
|
||||
// Only notify mentioned users who actually have access to this ticket;
|
||||
// otherwise a mention would DM them the ticket's title and comment text
|
||||
// even though canUserAccessTicket() would deny them the ticket itself.
|
||||
$accessibleMentionedUsers = array_filter(
|
||||
$mentionedUsers,
|
||||
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
|
||||
);
|
||||
if (!empty($accessibleMentionedUsers)) {
|
||||
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
|
||||
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||
if (!empty($mentionedMatrixIds)) {
|
||||
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||
|
||||
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||
$assigneeName,
|
||||
$assigneeMatrix,
|
||||
$changedByDisplay
|
||||
$changedByDisplay,
|
||||
$ticket['visibility'] ?? 'public'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'HEAD'], true)) {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ try {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// Verify CSRF token
|
||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
|
||||
@@ -49,7 +49,7 @@ try {
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,15 @@ try {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ob_end_clean();
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid CSRF token',
|
||||
'csrf_token' => CsrfMiddleware::getToken()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,21 @@ if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time'
|
||||
];
|
||||
}
|
||||
|
||||
// Check 8: TRUSTED_PROXIES configured. Empty disables enforceTrustedProxy()'s
|
||||
// allowlist entirely, meaning anything that can reach this app directly can
|
||||
// spoof the Authelia forward-auth Remote-* headers and impersonate any user,
|
||||
// including an admin. Not fatal (a fresh/dev install may not sit behind a
|
||||
// proxy yet), but should never go unnoticed on a real deployment.
|
||||
if (!empty($GLOBALS['config']['TRUSTED_PROXIES'] ?? [])) {
|
||||
$checks['trusted_proxies'] = ['status' => 'ok', 'message' => 'configured'];
|
||||
} else {
|
||||
$checks['trusted_proxies'] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'TRUSTED_PROXIES is empty — forward-auth headers are NOT verified; '
|
||||
. 'anything that can reach this app directly can impersonate any user'
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate response time
|
||||
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ try {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ try {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ try {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token', 'csrf_token' => CsrfMiddleware::getToken()]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,15 @@ try {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ob_end_clean();
|
||||
http_response_code(403);
|
||||
throw new Exception("Invalid CSRF token");
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid CSRF token',
|
||||
'csrf_token' => CsrfMiddleware::getToken()
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DEL
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) {
|
||||
$currentStatus,
|
||||
$newStatus,
|
||||
(string)$ticket['title'],
|
||||
$keyName
|
||||
$keyName,
|
||||
$ticket['visibility'] ?? 'public'
|
||||
);
|
||||
NotificationHelper::notifyWatchers(
|
||||
$conn,
|
||||
|
||||
@@ -267,7 +267,8 @@ try {
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$updateData['title'],
|
||||
$changedBy
|
||||
$changedBy,
|
||||
$currentTicket['visibility'] ?? 'public'
|
||||
);
|
||||
NotificationHelper::notifyWatchers(
|
||||
$this->conn,
|
||||
|
||||
@@ -155,7 +155,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
// Verify CSRF token
|
||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
ResponseHelper::error('Invalid CSRF token', 403, ['csrf_token' => CsrfMiddleware::getToken()]);
|
||||
}
|
||||
|
||||
// Get ticket ID
|
||||
|
||||
+17
-39
@@ -8,9 +8,10 @@ ini_set('display_errors', 0);
|
||||
require_once __DIR__ . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
if (!file_exists($envFile)) {
|
||||
// Early friendly JSON error if .env is missing, before config.php's own
|
||||
// (plain-text die()) handling would otherwise run — this is a JSON API
|
||||
// endpoint and must always respond with a JSON body.
|
||||
if (!file_exists(__DIR__ . '/.env')) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Configuration file not found'
|
||||
@@ -18,37 +19,17 @@ if (!file_exists($envFile)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||
if (!$envVars) {
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid configuration file'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
// Load application config so UrlHelper can resolve APP_DOMAIN, and so the
|
||||
// DB connection below (via Database::getConnection()) gets the same
|
||||
// charset/timezone sync as every other endpoint instead of a hand-rolled
|
||||
// second connection.
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/helpers/Database.php';
|
||||
|
||||
// Strip quotes from values if present (parse_ini_file may include them)
|
||||
foreach ($envVars as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
if (
|
||||
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||
) {
|
||||
$envVars[$key] = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Database connection with detailed error handling
|
||||
$conn = new mysqli(
|
||||
$envVars['DB_HOST'],
|
||||
$envVars['DB_USER'],
|
||||
$envVars['DB_PASS'],
|
||||
$envVars['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('create_ticket_api: DB connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
@@ -57,9 +38,6 @@ if ($conn->connect_error) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Load application config so UrlHelper can resolve APP_DOMAIN
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
// Authenticate via API key
|
||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||
@@ -349,7 +327,7 @@ if ($existing) {
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
Database::close();
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'ticket_id' => $existingId,
|
||||
@@ -386,7 +364,7 @@ if ($existing) {
|
||||
// Ticket reopened (Closed → Open) — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
|
||||
$conn->close();
|
||||
Database::close();
|
||||
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($existingId, [
|
||||
@@ -484,7 +462,7 @@ if ($inserted) {
|
||||
// New ticket created — refresh dashboard stats.
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
|
||||
$conn->close();
|
||||
Database::close();
|
||||
|
||||
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||
|
||||
@@ -40,20 +40,40 @@ class NotificationHelper
|
||||
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact a ticket title for the shared Matrix notify list when the
|
||||
* ticket isn't public, matching how sendCommentNotification() and
|
||||
* notifyWatchers() already redact comment/activity previews for the
|
||||
* same list.
|
||||
*/
|
||||
private static function redactedTitle(string $title, string $visibility): string
|
||||
{
|
||||
return $visibility === 'public' ? $title : '(restricted ticket — title hidden)';
|
||||
}
|
||||
|
||||
// ─── Public event methods ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* New ticket created (manual or automated/API).
|
||||
*
|
||||
* $ticketData['visibility'] ('public', 'internal', or 'confidential') is
|
||||
* used to redact the title sent to the shared MATRIX_NOTIFY_USERS list
|
||||
* for non-public tickets, same as sendCommentNotification()'s preview
|
||||
* redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon
|
||||
* Bearer-API paths) that never set a non-default visibility.
|
||||
*/
|
||||
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||
{
|
||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||
$visibility = $ticketData['visibility'] ?? 'public';
|
||||
$title = $ticketData['title'] ?? 'Untitled';
|
||||
|
||||
preg_match('/^\[([^\]]+)\]/', $title, $m);
|
||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||
|
||||
self::fire([
|
||||
'event' => 'ticket_created',
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticketData['title'] ?? 'Untitled',
|
||||
'title' => self::redactedTitle($title, $visibility),
|
||||
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||
'category' => $ticketData['category'] ?? 'General',
|
||||
'type' => $ticketData['type'] ?? 'Issue',
|
||||
@@ -73,13 +93,16 @@ class NotificationHelper
|
||||
* @param string $newStatus
|
||||
* @param string $ticketTitle
|
||||
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||
* @param string $visibility Ticket visibility; non-public titles are
|
||||
* redacted before being sent to the shared
|
||||
* notify list, same as sendTicketNotification().
|
||||
*/
|
||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
||||
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||
{
|
||||
self::fire([
|
||||
'event' => 'status_changed',
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticketTitle,
|
||||
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $newStatus,
|
||||
'changed_by' => $changedByDisplay,
|
||||
@@ -166,11 +189,12 @@ class NotificationHelper
|
||||
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||
* @param string $visibility Ticket visibility: 'public', 'internal', or
|
||||
* 'confidential'. notify_users includes the
|
||||
* shared list, which may contain users without
|
||||
* access to non-public tickets, so any comment
|
||||
* body preview in $extraData is redacted for
|
||||
* non-public tickets.
|
||||
* 'confidential'. The shared notify list may
|
||||
* contain users without access to non-public
|
||||
* tickets, so for those tickets it's excluded
|
||||
* entirely (only actual watchers are notified)
|
||||
* and both the title and any comment/body
|
||||
* preview in $extraData are redacted.
|
||||
*/
|
||||
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
|
||||
{
|
||||
@@ -230,13 +254,17 @@ class NotificationHelper
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the global notify list duplicates and build payload
|
||||
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
||||
// The shared notify list may include users without access to
|
||||
// non-public tickets, so only mix it in for public tickets — for
|
||||
// internal/confidential tickets, notify actual watchers only.
|
||||
$allNotify = $visibility === 'public'
|
||||
? array_unique(array_merge($matrixIds, self::notifyUsers()))
|
||||
: $matrixIds;
|
||||
|
||||
$payload = array_merge($extraData, [
|
||||
'event' => $event,
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticketTitle,
|
||||
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||
'url' => UrlHelper::ticketUrl($ticketId),
|
||||
'notify_users' => array_values($allNotify),
|
||||
]);
|
||||
@@ -252,8 +280,14 @@ class NotificationHelper
|
||||
* @param string|null $assigneeName Display name of new assignee
|
||||
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||
* @param string|null $changedByDisplay
|
||||
* @param string $visibility Ticket visibility; non-public titles are
|
||||
* redacted before being sent to the shared
|
||||
* notify list, same as sendTicketNotification().
|
||||
* The assignee is DMed directly regardless,
|
||||
* since they now have standing access to the
|
||||
* ticket by virtue of being assigned to it.
|
||||
*/
|
||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
||||
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void
|
||||
{
|
||||
$notifyUsers = self::notifyUsers();
|
||||
// Also notify the assignee directly if we know their Matrix ID
|
||||
@@ -267,7 +301,7 @@ class NotificationHelper
|
||||
self::fire([
|
||||
'event' => 'assigned',
|
||||
'ticket_id' => $ticketId,
|
||||
'title' => $ticketTitle,
|
||||
'title' => self::redactedTitle($ticketTitle, $visibility),
|
||||
'assignee' => $assigneeName,
|
||||
'changed_by' => $changedByDisplay,
|
||||
'url' => UrlHelper::ticketUrl($ticketId),
|
||||
|
||||
@@ -5,6 +5,7 @@ require_once 'config/config.php';
|
||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
require_once 'helpers/Database.php';
|
||||
|
||||
// Apply security headers early
|
||||
SecurityHeadersMiddleware::apply();
|
||||
@@ -17,15 +18,12 @@ $requestPath = strtok($request, '?');
|
||||
|
||||
// Create database connection for non-API routes
|
||||
if (!str_starts_with($requestPath, '/api/')) {
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Connection failed: " . $conn->connect_error);
|
||||
try {
|
||||
$conn = Database::getConnection();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('index.php: database connection failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
die('Sorry, something went wrong. Please try again shortly.');
|
||||
}
|
||||
|
||||
// Authenticate user via Authelia forward auth
|
||||
@@ -444,5 +442,5 @@ switch (true) {
|
||||
|
||||
// Close database connection if it was opened
|
||||
if (isset($conn)) {
|
||||
$conn->close();
|
||||
Database::close();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class CommentModel
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||
$sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
$types = str_repeat('s', count($usernames));
|
||||
|
||||
Reference in New Issue
Block a user