diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index b76ff56f5..8d6744daf 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -29,6 +29,7 @@ use OCP\AppFramework\Http\Template\PublicTemplateResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\ICommentsManager; use OCP\IL10N; use OCP\IRequest; @@ -57,6 +58,7 @@ public function __construct( private readonly IUrlGenerator $urlGenerator, private readonly IUserManager $userManager, private readonly IUserSession $userSession, + private readonly ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); } @@ -76,6 +78,7 @@ public function index(?string $hash = null, ?int $submissionId = null): Template $this->insertHeaderOnIos(); $this->initialState->provideInitialState('maxStringLengths', Constants::MAX_STRING_LENGTHS); $this->initialState->provideInitialState('appConfig', $this->configService->getAppConfig()); + $this->initialState->provideInitialState('serverTime', $this->timeFactory->getTime()); if (isset($hash)) { try { @@ -228,6 +231,7 @@ protected function createPublicSubmitView(Form $form, string $hash): TemplateRes $this->initialState->provideInitialState('isLoggedIn', $this->userSession->isLoggedIn()); $this->initialState->provideInitialState('shareHash', $hash); $this->initialState->provideInitialState('maxStringLengths', Constants::MAX_STRING_LENGTHS); + $this->initialState->provideInitialState('serverTime', $this->timeFactory->getTime()); return $this->provideTemplate(self::TEMPLATE_MAIN, $form, ['id-app-navigation' => null]); } diff --git a/src/components/AppNavigationForm.vue b/src/components/AppNavigationForm.vue index 9e83edc02..7d8711fab 100644 --- a/src/components/AppNavigationForm.vue +++ b/src/components/AppNavigationForm.vue @@ -126,6 +126,7 @@ import FormsIcon from '../../img/forms-dark.svg?raw' import { FormState } from '../models/Constants.ts' import { PERMISSION_TYPES } from '../models/Permissions.ts' import logger from '../utils/Logger.ts' +import { getCurrentServerTime } from '../utils/ServerTime.ts' type NavigationTarget = 'submit' | 'formRoot' @@ -212,7 +213,9 @@ export default defineComponent({ * Check if form is expired */ isExpired(): boolean { - return Boolean(this.form.expires && moment().unix() > this.form.expires) + return Boolean( + this.form.expires && getCurrentServerTime() > this.form.expires, + ) }, /** diff --git a/src/components/SidebarTabs/SettingsSidebarTab.vue b/src/components/SidebarTabs/SettingsSidebarTab.vue index b16e2f85d..a7e3427ef 100644 --- a/src/components/SidebarTabs/SettingsSidebarTab.vue +++ b/src/components/SidebarTabs/SettingsSidebarTab.vue @@ -294,6 +294,7 @@ import TransferOwnership from './TransferOwnership.vue' import svgLockOpen from '../../../img/lock_open.svg?raw' import { useShareTypes } from '../../composables/useShareTypes.ts' import { FormState } from '../../models/Constants.ts' +import { getCurrentServerTime } from '../../utils/ServerTime.ts' const formsAppName = 'forms' @@ -466,7 +467,7 @@ export default defineComponent({ }, isExpired(): boolean { - return this.form.expires && moment().unix() > this.form.expires + return this.form.expires && getCurrentServerTime() > this.form.expires }, expirationDate(): Date { diff --git a/src/utils/ServerTime.ts b/src/utils/ServerTime.ts new file mode 100644 index 000000000..4f5f0f35f --- /dev/null +++ b/src/utils/ServerTime.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { loadState } from '@nextcloud/initial-state' + +const formsAppName = 'forms' +const serverTimeAtLoad = loadState( + formsAppName, + 'serverTime', + Math.floor(Date.now() / 1000), +) as number +const monotonicTimeAtLoad = performance.now() + +/** + * Get the current Unix timestamp relative to the server clock. + * + * A monotonic timer keeps the value independent of client clock changes after + * the page has loaded. + */ +export function getCurrentServerTime(): number { + const elapsedSeconds = (performance.now() - monotonicTimeAtLoad) / 1000 + return Math.floor(serverTimeAtLoad + elapsedSeconds) +} diff --git a/src/views/Create.vue b/src/views/Create.vue index 93d005977..2ff51f3e2 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -246,6 +246,7 @@ import answerTypes from '../models/AnswerTypes.ts' import { FormState, INPUT_DEBOUNCE_MS } from '../models/Constants.ts' import logger from '../utils/Logger.ts' import OcsResponse2Data from '../utils/OcsResponse2Data.ts' +import { getCurrentServerTime } from '../utils/ServerTime.ts' import SetWindowTitle from '../utils/SetWindowTitle.ts' const formsAppName = 'forms' @@ -351,7 +352,9 @@ export default defineComponent({ * Check if form is expired */ isExpired(): boolean { - return this.form.expires > 0 && moment().unix() > this.form.expires + return ( + this.form.expires > 0 && getCurrentServerTime() > this.form.expires + ) }, /** diff --git a/src/views/Submit.vue b/src/views/Submit.vue index e5b3f60a0..aa1d94f4b 100644 --- a/src/views/Submit.vue +++ b/src/views/Submit.vue @@ -255,6 +255,7 @@ import { import { PERMISSION_TYPES } from '../models/Permissions.ts' import logger from '../utils/Logger.ts' import OcsResponse2Data from '../utils/OcsResponse2Data.ts' +import { getCurrentServerTime } from '../utils/ServerTime.ts' import SetWindowTitle from '../utils/SetWindowTitle.ts' const formsAppName = 'forms' @@ -461,7 +462,9 @@ export default defineComponent({ * Check if form is expired */ isExpired(): boolean { - return this.form.expires > 0 && moment().unix() > this.form.expires + return ( + this.form.expires > 0 && getCurrentServerTime() > this.form.expires + ) }, isArchived(): boolean { diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index cd845cbc7..9ade16afb 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -18,6 +18,7 @@ use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\ICommentsManager; use OCP\IL10N; use OCP\IRequest; @@ -43,6 +44,7 @@ class PageControllerTest extends TestCase { private IURLGenerator|MockObject $urlGenerator; private IUserManager|MockObject $userManager; private IUserSession|MockObject $userSession; + private ITimeFactory|MockObject $timeFactory; public function setUp(): void { parent::setUp(); @@ -60,6 +62,7 @@ public function setUp(): void { $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->userManager = $this->createMock(IUserManager::class); $this->userSession = $this->createMock(IUserSession::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); $this->pageController = new PageController( 'forms', @@ -75,10 +78,27 @@ public function setUp(): void { $this->l10n, $this->urlGenerator, $this->userManager, - $this->userSession + $this->userSession, + $this->timeFactory ); } + public function testIndexProvidesServerTime(): void { + $providedState = []; + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn(1234567890); + $this->initialState->expects($this->exactly(3)) + ->method('provideInitialState') + ->willReturnCallback(function (string $key, mixed $value) use (&$providedState): void { + $providedState[$key] = $value; + }); + + $this->pageController->index(); + + $this->assertSame(1234567890, $providedState['serverTime']); + } + public function testSetEmbeddedCSP() { /** @var MockObject */ $response = $this->createMock(TemplateResponse::class);