From 67cc350b8c79ad1c14d3fd72c33d42ebff395f14 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Thu, 22 Oct 2026 16:38:54 +0200 Subject: [PATCH] Daily backup time in the administrator's time zone (#19); a time saved before is converted once so it shows as it ran --- CHANGELOG.md | 8 ++ lib/BackgroundJob/BackupJob.php | 11 +-- lib/Controller/AdminApiController.php | 16 ++-- lib/Service/Schedule.php | 108 ++++++++++++++++++++++++++ src/api.js | 2 +- src/components/Backups.vue | 16 ++-- src/format.js | 21 +++++ tests/Unit/Service/ScheduleTest.php | 71 +++++++++++++++++ 8 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 lib/Service/Schedule.php create mode 100644 tests/Unit/Service/ScheduleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b80a9..1d37547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ instead carried by the app description, the git tag, the release, and this file. ## [Unreleased] ### Fixed +- The daily backup time was taken as UTC (Nextcloud runs PHP in UTC): 03:00 ran at 05:00 in + Berlin (#19). The time is now saved with the administrator's time zone and shown with it. A time + saved before is converted once to the zone of the server or an administrator, so it shows as it + effectively ran (02:30 UTC becomes 04:30 Europe/Berlin in summer) and nothing moves. +- A full restore over SMB stopped after 17 hours with "Decryption failed": a read over a long + line handed out wrong bytes (the backup itself was fine, the same files restored identically + afterwards). A blob that does not decrypt as first read is now read once more on a request of + its own, and only a second failure stops the restore. - A full restore onto MySQL/MariaDB stopped with "Incorrect string value" when it created a table (of an app that is not installed here) whose rows hold emoji or other 4-byte characters: the table got 3-byte utf8. Tables the restore creates now get this installation's character set and diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php index 4171fe4..76dd5fc 100644 --- a/lib/BackgroundJob/BackupJob.php +++ b/lib/BackgroundJob/BackupJob.php @@ -32,6 +32,7 @@ final class BackupJob extends TimedJob { private RunMapper $runs, private IAppConfig $appConfig, private LoggerInterface $logger, + private \OCA\NgBackup\Service\Schedule $schedule, ) { parent::__construct($time); // Well below the usual 5-minute cron interval: the last run is stamped a moment after cron @@ -101,14 +102,10 @@ final class BackupJob extends TimedJob { $this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due); } - /** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */ + /** Daily schedule: "HH:MM" in the schedule's time zone (see Schedule); empty = no schedule. */ private function startScheduled(): void { - $at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''); - if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) { - return; - } - $todayAt = (int)strtotime(date('Y-m-d') . ' ' . $at); - if (time() < $todayAt) { + $todayAt = $this->schedule->todayAt(); + if ($todayAt === null || time() < $todayAt) { return; } foreach ($this->targets->list() as $t) { diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 1fec3bb..886e415 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -47,6 +47,7 @@ final class AdminApiController extends Controller { private \OCA\NgBackup\Service\AlertService $alerts, private \OCA\NgBackup\Service\KeySlotService $slots, private \OCA\NgBackup\Service\PruneService $prune, + private \OCA\NgBackup\Service\Schedule $schedule, ) { parent::__construct(Application::APP_ID, $request); } @@ -72,7 +73,8 @@ final class AdminApiController extends Controller { 'runs' => $runs, 'olderRuns' => $allRuns ? 0 : $this->runs->countAll() - count($shown), 'recentDays' => \OCA\NgBackup\Command\Status::RECENT_DAYS, - 'schedule' => $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', ''), + 'schedule' => $this->schedule->get()['time'], + 'scheduleTimezone' => $this->schedule->get()['timezone'], ]); } @@ -240,12 +242,14 @@ final class AdminApiController extends Controller { return new JSONResponse($this->runJson($run)); } - public function setSchedule(string $time): JSONResponse { - if ($time !== '' && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) { - return new JSONResponse(['error' => 'Use HH:MM or empty'], Http::STATUS_BAD_REQUEST); + /** @param string $timezone the administrator's zone (the browser's); empty: keep the current one */ + public function setSchedule(string $time, string $timezone = ''): JSONResponse { + try { + $this->schedule->set($time, $timezone !== '' ? $timezone : $this->schedule->get()['timezone']); + } catch (\InvalidArgumentException $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } - $this->appConfig->setValueString(Application::APP_ID, 'schedule_time', $time); - return new JSONResponse(['schedule' => $time]); + return new JSONResponse(['schedule' => $time, 'scheduleTimezone' => $this->schedule->get()['timezone']]); } public function snapshots(int $targetId): JSONResponse { diff --git a/lib/Service/Schedule.php b/lib/Service/Schedule.php new file mode 100644 index 0000000..4547061 --- /dev/null +++ b/lib/Service/Schedule.php @@ -0,0 +1,108 @@ +appConfig->getValueString(Application::APP_ID, self::TIME_KEY, ''); + $zone = $this->appConfig->getValueString(Application::APP_ID, self::ZONE_KEY, ''); + if ($zone === '' || !self::validZone($zone)) { + $zone = $this->guessZone(); + if ($time !== '' && self::validTime($time)) { + $time = self::convert($time, 'UTC', $zone); + $this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time); + } + $this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone); + } + return ['time' => $time, 'timezone' => $zone]; + } + + /** @throws \InvalidArgumentException for a malformed time or an unknown zone */ + public function set(string $time, string $zone): void { + if ($time !== '' && !self::validTime($time)) { + throw new \InvalidArgumentException('Use HH:MM or empty'); + } + if (!self::validZone($zone)) { + throw new \InvalidArgumentException('Unknown time zone: ' . $zone); + } + $this->appConfig->setValueString(Application::APP_ID, self::ZONE_KEY, $zone); + $this->appConfig->setValueString(Application::APP_ID, self::TIME_KEY, $time); + } + + /** Today's scheduled moment as a Unix time, in the schedule's zone; null without a schedule. */ + public function todayAt(?int $now = null): ?int { + ['time' => $time, 'timezone' => $zone] = $this->get(); + if ($time === '' || !self::validTime($time)) { + return null; + } + $tz = new \DateTimeZone($zone); + $day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone($tz)->format('Y-m-d'); + return (new \DateTimeImmutable($day . ' ' . $time, $tz))->getTimestamp(); + } + + /** + * "HH:MM" in zone $from as "HH:MM" in zone $to, at today's offsets. + * + * @param non-empty-string $from + * @param non-empty-string $to + */ + public static function convert(string $time, string $from, string $to, ?int $now = null): string { + $day = (new \DateTimeImmutable('@' . ($now ?? time())))->setTimezone(new \DateTimeZone($from))->format('Y-m-d'); + return (new \DateTimeImmutable($day . ' ' . $time, new \DateTimeZone($from)))->setTimezone(new \DateTimeZone($to))->format('H:i'); + } + + public static function validTime(string $time): bool { + return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) === 1; + } + + /** @psalm-assert-if-true non-empty-string $zone */ + public static function validZone(string $zone): bool { + return in_array($zone, \DateTimeZone::listIdentifiers(), true); + } + + /** + * The server's default_timezone when set, else the first administrator's, else UTC. + * + * @return non-empty-string + */ + private function guessZone(): string { + $zone = $this->config->getSystemValueString('default_timezone', ''); + if ($zone !== '' && $zone !== 'UTC' && self::validZone($zone)) { + return $zone; + } + foreach ($this->groups->get('admin')?->getUsers() ?? [] as $user) { + $zone = $this->config->getUserValue($user->getUID(), 'core', 'timezone', ''); + if ($zone !== '' && self::validZone($zone)) { + return $zone; + } + } + return 'UTC'; + } +} diff --git a/src/api.js b/src/api.js index fad46dd..aa29792 100644 --- a/src/api.js +++ b/src/api.js @@ -33,7 +33,7 @@ export default { testTarget: (id) => call('post', `/targets/${id}/test`), setTargetLimit: (id, maxGb) => call('put', `/targets/${id}/limit`, { maxGb }), startBackup: (targetId, label) => call('post', '/backups', { targetId, label }), - setSchedule: (time) => call('put', '/schedule', { time }), + setSchedule: (time, timezone) => call('put', '/schedule', { time, timezone }), snapshots: (targetId) => call('get', `/targets/${targetId}/snapshots`), browse: (targetId, snapshotId, path) => call('get', `/targets/${targetId}/snapshots/${snapshotId}/browse`, { path }), startRestore: (targetId, snapshotId, path, mode) => call('post', '/restores', { targetId, snapshotId, path, mode }), diff --git a/src/components/Backups.vue b/src/components/Backups.vue index bb500de..82ac031 100644 --- a/src/components/Backups.vue +++ b/src/components/Backups.vue @@ -6,8 +6,8 @@
- - {{ t('ng_backup', 'Save') }} + + {{ t('ng_backup', 'Save') }}
@@ -79,15 +79,19 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcTextField from '@nextcloud/vue/components/NcTextField' import api from '../api.js' -import { formatDateTime, formatDate, formatSize } from '../format.js' +import { formatDateTime, formatDate, formatSize, toZone } from '../format.js' import { confirmPassword } from '@nextcloud/password-confirmation' import { reactive } from 'vue' const props = defineProps({ status: { type: Object, required: true }, allRuns: { type: Boolean, default: false } }) const emit = defineEmits(['changed', 'show-all-runs']) -const schedule = ref(props.status.schedule) +// The time is saved in the administrator's own zone (the browser's); a schedule saved in another +// zone is shown in this one, so it reads as the moment it runs here. +const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' +const shown = (s) => (s && props.status.scheduleTimezone && props.status.scheduleTimezone !== browserZone) ? toZone(s, props.status.scheduleTimezone, browserZone) : s +const schedule = ref(shown(props.status.schedule)) const error = ref('') -watch(() => props.status.schedule, (s) => { schedule.value = s }) +watch(() => props.status.schedule, (s) => { schedule.value = shown(s) }) const trash = reactive({}) async function loadTrash() { @@ -131,7 +135,7 @@ async function start(target) { async function saveSchedule() { error.value = '' try { - await api.setSchedule(schedule.value.trim()) + await api.setSchedule(schedule.value.trim(), browserZone) emit('changed') } catch (e) { error.value = e.message diff --git a/src/format.js b/src/format.js index add6404..c7496c8 100644 --- a/src/format.js +++ b/src/format.js @@ -26,3 +26,24 @@ export function formatSize(bytes) { } return i === 0 ? `${b} B` : `${b.toFixed(1)} ${units[i]}` } + +/** + * A time of day "HH:MM" in zone `from`, as "HH:MM" in zone `to`, at today's offsets. + * + * @param {string} hhmm the time + * @param {string} from IANA zone it is in + * @param {string} to IANA zone to show it in + * @return {string} + */ +export function toZone(hhmm, from, to) { + const [h, m] = hhmm.split(':').map(Number) + const parts = (zone, date) => Object.fromEntries(new Intl.DateTimeFormat('en-GB', { + timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }).formatToParts(date).map((p) => [p.type, p.value])) + const today = parts(from, new Date()) + const guess = Date.UTC(+today.year, +today.month - 1, +today.day, h, m) + const seen = parts(from, new Date(guess)) + const offset = Date.UTC(+seen.year, +seen.month - 1, +seen.day, +seen.hour, +seen.minute) - guess + const there = parts(to, new Date(guess - offset)) + return `${there.hour}:${there.minute}` +} diff --git a/tests/Unit/Service/ScheduleTest.php b/tests/Unit/Service/ScheduleTest.php new file mode 100644 index 0000000..db8458d --- /dev/null +++ b/tests/Unit/Service/ScheduleTest.php @@ -0,0 +1,71 @@ +createMock(IAppConfig::class); + $app->method('getValueString')->willReturnCallback(fn (string $a, string $k, string $d = '') => $this->values[$k] ?? $d); + $app->method('setValueString')->willReturnCallback(function (string $a, string $k, string $v): bool { + $this->values[$k] = $v; + return true; + }); + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->willReturn($serverZone); + $config->method('getUserValue')->willReturn($adminZone); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + $group = $this->createMock(IGroup::class); + $group->method('getUsers')->willReturn([$user]); + $groups = $this->createMock(IGroupManager::class); + $groups->method('get')->willReturn($group); + return new Schedule($app, $config, $groups); + } + + public function testATimeSavedWithoutAZoneShowsAsItRanInTheAdministratorsZone(): void { + $this->values = [Schedule::TIME_KEY => '02:30']; + + $s = $this->schedule(adminZone: 'Europe/Berlin')->get(); + + // it ran at 02:30 UTC: in summer that is 04:30 in Berlin, in winter 03:30 + $summer = Schedule::convert('02:30', 'UTC', 'Europe/Berlin', strtotime('2026-07-01 12:00 UTC')); + $this->assertSame('04:30', $summer); + $this->assertSame('Europe/Berlin', $s['timezone']); + $this->assertSame(Schedule::convert('02:30', 'UTC', 'Europe/Berlin'), $s['time']); + } + + public function testTheServersDefaultZoneComesFirst(): void { + $this->values = [Schedule::TIME_KEY => '01:00']; + $this->assertSame('America/Toronto', $this->schedule('America/Toronto', 'Europe/Berlin')->get()['timezone']); + } + + public function testWithoutAnyZoneItStaysUtc(): void { + $this->values = [Schedule::TIME_KEY => '02:30']; + $this->assertSame(['time' => '02:30', 'timezone' => 'UTC'], $this->schedule()->get()); + } + + public function testTodayAtIsTheMomentInTheSchedulesZone(): void { + $this->values = [Schedule::TIME_KEY => '03:00', Schedule::ZONE_KEY => 'Europe/Berlin']; + $at = $this->schedule()->todayAt(strtotime('2026-10-22 12:00 UTC')); + $this->assertSame(strtotime('2026-10-22 01:00 UTC'), $at, '03:00 in Berlin (summer time) is 01:00 UTC'); + } + + public function testAnUnknownZoneOrABadTimeIsRefused(): void { + $s = $this->schedule(); + $this->expectException(\InvalidArgumentException::class); + $s->set('03:00', 'Mars/Olympus'); + } +}