Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@
// Health check endpoint.
['name' => 'health#index', 'url' => '/api/health', 'verb' => 'GET'],

// Signalering (deadline alerting) endpoints.
['name' => 'signalering_config#index', 'url' => '/api/signalering/config', 'verb' => 'GET'],
['name' => 'signalering_config#create', 'url' => '/api/signalering/config', 'verb' => 'POST'],
['name' => 'signalering_config#delete', 'url' => '/api/signalering/config/{zaaktypeId}', 'verb' => 'DELETE'],

// Deadline notification endpoints.
['name' => 'deadline_notification#getDeadlines', 'url' => '/api/cases/{caseId}/deadlines', 'verb' => 'GET'],
['name' => 'deadline_notification#notifyWebhook', 'url' => '/api/deadlines/notify', 'verb' => 'POST'],

// SPA catch-all — serves the Vue app for any frontend route (history mode)
['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']],
],
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use OCA\Procest\Dashboard\StalledCasesWidget;
use OCA\Procest\Dashboard\TaskRemindersWidget;
use OCA\Procest\Dashboard\StartCaseWidget;
use OCA\Procest\Dashboard\UpcomingDeadlinesWidget;
use OCA\Procest\Listener\DeepLinkRegistrationListener;
use OCA\Procest\Middleware\ZgwAuthMiddleware;
use OCP\AppFramework\App;
Expand Down Expand Up @@ -74,6 +75,7 @@ public function register(IRegistrationContext $context): void
$context->registerDashboardWidget(MyTasksWidget::class);
$context->registerDashboardWidget(OverdueCasesWidget::class);
$context->registerDashboardWidget(DeadlineAlertsWidget::class);
$context->registerDashboardWidget(UpcomingDeadlinesWidget::class);
$context->registerDashboardWidget(TaskRemindersWidget::class);
$context->registerDashboardWidget(StalledCasesWidget::class);
$context->registerDashboardWidget(StartCaseWidget::class);
Expand Down
246 changes: 246 additions & 0 deletions lib/Controller/DeadlineNotificationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
<?php

/**
* SPDX-License-Identifier: EUPL-1.2
* Copyright (C) 2026 Conduction B.V.
*/

declare(strict_types=1);

namespace OCA\Procest\Controller;

use OCA\Procest\AppInfo\Application;
use OCA\Procest\Service\SignaleringService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

/**
* API Controller for deadline notifications and notifications.
*
* Provides endpoints for retrieving deadline status and handling n8n webhook callbacks.
*
* @spec openspec/changes/signalering-widgets/tasks.md#T06
*/
class DeadlineNotificationController extends Controller
{
public function __construct(
IRequest $request,
private SignaleringService $signaleringService,
private IUserSession $userSession,
private ContainerInterface $container,
private LoggerInterface $logger,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Get deadline status for a specific case.
*
* Returns streeftermijn, fatale termijn, opschorting info, and overall status.
*
* @spec openspec/changes/signalering-widgets/tasks.md#T06
*
* @param string $caseId The case UUID
* @return JSONResponse
*/
public function getDeadlines(string $caseId): JSONResponse
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unfixed: read-only workspace; requires adding @NoAdminRequired docblock annotation] Rule: OWASP A01:2021 — Broken Access Control. Without @NoAdminRequired, Nextcloud's middleware restricts this endpoint to admin-only, blocking regular users from querying their own case deadline status. The method already performs proper per-user authorization (owner/group/admin check via IGroupManager). Fix: add @NoAdminRequired to the method docblock above getDeadlines().

{
try {
if (empty($caseId)) {
return new JSONResponse(['error' => 'caseId is required'], 400);
}

$user = $this->userSession->getUser();
if ($user === null) {
return new JSONResponse(['error' => 'Not authenticated'], 401);
}

// Check authorization: user must be able to access this case
$objectService = $this->getObjectService();
if ($objectService === null) {
return new JSONResponse(['error' => 'Service not available'], 503);
}

$register = $this->getRegisterValue();
$caseSchema = $this->getSchemaValue('case_schema');

if ($register === null || $caseSchema === null) {
return new JSONResponse(['error' => 'Configuration not available'], 503);
}

$case = $objectService->getObject((int) $register, (int) $caseSchema, $caseId);
if ($case === null) {
return new JSONResponse(['error' => 'Case not found'], 404);
}

$caseData = is_object($case) ? $case->jsonSerialize() : $case;

// Verify user is authorized to access this case
// Check if the user owns the case, is in the assigned group, or is an admin
$uid = $user->getUID();
$ownerId = $caseData['owner'] ?? $caseData['eigenaar'] ?? null;
$groupId = $caseData['group'] ?? $caseData['groep'] ?? null;

// Use IGroupManager to check admin and group membership
try {
$groupManager = $this->container->get('OCP\IGroupManager');
} catch (\Exception) {
$groupManager = null;
}

$isAuthorized = $uid === $ownerId
|| ($groupId !== null && $groupManager !== null && $groupManager->isInGroup($uid, $groupId))
|| ($groupManager !== null && $groupManager->isAdmin($uid));

if (!$isAuthorized) {
return new JSONResponse(['error' => 'Not authorized to access this case'], 403);
}

// Get the case type
$caseTypeSchema = $this->getSchemaValue('case_type_schema');
$caseTypeId = $caseData['caseType'] ?? $caseData['zaaktype'] ?? null;

if ($caseTypeId === null || $caseTypeSchema === null) {
return new JSONResponse(['error' => 'Case type not found'], 404);
}

$caseType = $objectService->getObject((int) $register, (int) $caseTypeSchema, $caseTypeId);
if ($caseType === null) {
return new JSONResponse(['error' => 'Case type not found'], 404);
}

$caseTypeData = is_object($caseType) ? $caseType->jsonSerialize() : $caseType;

// Calculate deadline status
$deadlineStatus = $this->signaleringService->calculateDeadlineStatus($caseData, $caseTypeData);

return new JSONResponse(
[
'caseId' => $caseId,
'zaaktypeId' => $caseTypeId,
'streeftermijn' => $deadlineStatus['streeftermijn'],
'fatalTermijn' => $deadlineStatus['fatalTermijn'],
'opschorting' => $deadlineStatus['opschorting'],
'overallStatus' => $deadlineStatus['overallStatus'],
]
);
} catch (\Exception $e) {
$this->logger->error(
'Procest: Error getting deadline status',
[
'caseId' => $caseId,
'exception' => $e->getMessage(),
]
);
return new JSONResponse(['error' => 'Error retrieving deadline status'], 500);
}//end try
}//end getDeadlines()

/**
* Handle webhook callback from n8n for notification delivery confirmation.
*
* This endpoint is called by n8n after it sends an email notification,
* allowing us to track notification state.
*
* @spec openspec/changes/signalering-widgets/tasks.md#T06
*
* @return JSONResponse
*/
public function notifyWebhook(): JSONResponse
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unfixed: read-only workspace; requires adding @publicpage, @NoCSRFRequired, @NoAdminRequired docblock annotations] Rule: OWASP A05:2021 — Security Misconfiguration / Nextcloud annotation contract. This endpoint is designed to receive POST callbacks from n8n (an external service with no Nextcloud session). Without @PublicPage, Nextcloud's auth middleware rejects all unauthenticated requests before they reach this method. Without @NoCSRFRequired, Nextcloud's CSRF middleware blocks POST requests from n8n (which cannot supply a CSRF token). The existing header-based auth check inside the method body is therefore never reached. Fix: add @NoAdminRequired, @NoCSRFRequired, and @PublicPage to this method's docblock, matching the pattern in NrcController and ZtcController. Note: also fix the presence-only auth check (see separate comment).

{
try {
// Verify webhook authentication
// Check for Authorization header with Bearer token or webhook secret
$authHeader = $this->request->getHeader('Authorization');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[unfixed: requires architectural fix — implement shared secret validation] Rule: OWASP A07:2021 — Identification and Authentication Failures / CWE-287. The webhook auth check only verifies that the Authorization or X-Webhook-Secret header is non-empty — it does NOT compare the value against a configured shared secret. Any caller who supplies Authorization: x or X-Webhook-Secret: x bypasses authentication. Fix: read the configured secret from SettingsService (e.g. getConfigValue('n8n_signalering_webhook_secret')), then compare using hash_equals($configuredSecret, $incomingSecret) to prevent timing attacks. Return 401 if values do not match.

if (empty($authHeader)) {
// Also accept webhook token in X-Webhook-Secret header for n8n compatibility
$webhookSecret = $this->request->getHeader('X-Webhook-Secret');
if (empty($webhookSecret)) {
$this->logger->warning('Procest: Webhook received without authentication');
return new JSONResponse(['error' => 'Unauthorized'], 401);
}
}

// Get webhook payload from POST parameters
$type = $this->request->getParam('type');
$caseId = $this->request->getParam('caseId');
$status = $this->request->getParam('status');

if (empty($type) || empty($caseId)) {
return new JSONResponse(['error' => 'Missing required fields'], 400);
}

// Log the notification event
$this->logger->info(
'Procest: Notification webhook received',
[
'type' => $type,
'caseId' => $caseId,
'status' => $status ?? 'unknown',
]
);

// Here you could update the case notification history,
// but for now we just acknowledge the webhook
return new JSONResponse(['success' => true]);
} catch (\Exception $e) {
$this->logger->error(
'Procest: Error processing notification webhook',
[
'exception' => $e->getMessage(),
]
);
return new JSONResponse(['error' => 'Error processing webhook'], 500);
}//end try
}//end notifyWebhook()

/**
* Get ObjectService from OpenRegister app.
*
* @return ?\OCA\OpenRegister\Service\ObjectService
*/
private function getObjectService(): ?\OCA\OpenRegister\Service\ObjectService
{
try {
return $this->container->get('OCA\OpenRegister\Service\ObjectService');
} catch (\Exception $e) {
$this->logger->error('Procest: Could not get ObjectService', ['exception' => $e->getMessage()]);
return null;
}
}//end getObjectService()

/**
* Get the configured register ID.
*
* @return ?string
*/
private function getRegisterValue(): ?string
{
try {
$settingsService = $this->container->get('OCA\Procest\Service\SettingsService');
return $settingsService->getConfigValue('register');
} catch (\Exception) {
return null;
}
}//end getRegisterValue()

/**
* Get a schema ID by config key.
*
* @param string $configKey The settings key
* @return ?string
*/
private function getSchemaValue(string $configKey): ?string
{
try {
$settingsService = $this->container->get('OCA\Procest\Service\SettingsService');
return $settingsService->getConfigValue($configKey);
} catch (\Exception) {
return null;
}
}//end getSchemaValue()
}//end class
Loading