, then this command again.');
}
- $delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null;
+ $settings = Repository::settingsAt($storageBackend, $keys);
+ $delay = $settings['delete_delay_days'] ?? null;
$delay = is_int($delay) ? $delay : null;
$this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey);
if ($old !== null) {
$this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]);
}
- return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay];
+ return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay, 'settingsUnreadable' => ($settings['unreadable'] ?? false) === true];
+ }
+
+ /** A passphrase change that missed this location (offline at the time) reaches it now. A failure is tried again at the next backup. */
+ public function catchUpWrappedKey(Target $target, Repository $repo): void {
+ if (!$this->keys->wrappedKeyPending($target->getId())) {
+ return;
+ }
+ try {
+ $repo->updateWrappedKey($this->keys->wrappedKey());
+ $this->keys->setWrappedKeyPending($target->getId(), false);
+ } catch (\Throwable) {
+ }
}
/** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */
@@ -402,6 +415,10 @@ final class TargetService {
}
$new = LocationSchedule::normalize($schedule);
$turnedOff = $new === '' && (string)$target->getSchedule() !== '';
+ if ($new === (string)$target->getSchedule() && $zone === $target->getScheduleZone()) {
+ // Saved again unchanged: a moment that already passed still gets its backup
+ return $target;
+ }
$target->setSchedule($new);
$target->setScheduleZone($zone);
$target->setScheduleSince(time());
diff --git a/src/components/LocationScheduleRow.vue b/src/components/LocationScheduleRow.vue
index 3c47a80..a0b8df8 100644
--- a/src/components/LocationScheduleRow.vue
+++ b/src/components/LocationScheduleRow.vue
@@ -6,7 +6,7 @@
{{ target.name }}
-
+
{{ t('ng_backup', 'Save') }}
@@ -19,7 +19,6 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
-import { toZone } from '../format.js'
const props = defineProps({ target: { type: Object, required: true } })
const emit = defineEmits(['changed', 'error'])
@@ -29,9 +28,11 @@ const kinds = [
{ id: 'daily', label: t('ng_backup', 'Once a day') },
{ id: 'hours', label: t('ng_backup', 'Every few hours') },
]
-// 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.
+// A schedule is shown and edited in the zone it was saved in, named next to the time: "every 6
+// hours from 02:00" runs until midnight of that zone, so moving it to another zone would change
+// how often it runs. A location without a schedule gets the administrator's own zone (the browser's).
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
+const zone = computed(() => props.target.schedule !== '' && props.target.scheduleZone ? props.target.scheduleZone : browserZone)
const kind = ref(kinds[0])
const time = ref('02:00')
const hours = ref('6')
@@ -39,7 +40,7 @@ const busy = ref(false)
const current = computed(() => {
const [at, every] = props.target.schedule.split('/')
- return at === '' ? '' : (props.target.scheduleZone !== browserZone ? toZone(at, props.target.scheduleZone, browserZone) : at) + (every ? '/' + every : '')
+ return at === '' ? '' : at + (every ? '/' + every : '')
})
const wanted = computed(() => {
if (kind.value.id === 'off') {
@@ -59,7 +60,7 @@ async function save() {
busy.value = true
emit('error', '')
try {
- await api.setTargetSchedule(props.target.id, wanted.value, browserZone)
+ await api.setTargetSchedule(props.target.id, wanted.value, zone.value)
emit('changed')
} catch (e) {
emit('error', e.message)
diff --git a/src/format.js b/src/format.js
index 5774270..add6404 100644
--- a/src/format.js
+++ b/src/format.js
@@ -26,30 +26,3 @@ 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
- * @param {Date} now the day to convert on (today)
- * @return {string}
- */
-export function toZone(hhmm, from, to, now = new Date()) {
- 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 offsetAt = (at) => {
- const seen = parts(from, new Date(at))
- return Date.UTC(+seen.year, +seen.month - 1, +seen.day, +seen.hour, +seen.minute) - at
- }
- const today = parts(from, now)
- const wall = Date.UTC(+today.year, +today.month - 1, +today.day, h, m)
- // the offset at the wall time itself: on a day the clocks change it differs from the one at
- // midnight, so it is looked up a second time at the moment found first
- const at = wall - offsetAt(wall - offsetAt(wall))
- const there = parts(to, new Date(at))
- return `${there.hour}:${there.minute}`
-}
diff --git a/tests/Unit/Repository/KeyFromLocationTest.php b/tests/Unit/Repository/KeyFromLocationTest.php
index 033560c..14cbcfc 100644
--- a/tests/Unit/Repository/KeyFromLocationTest.php
+++ b/tests/Unit/Repository/KeyFromLocationTest.php
@@ -51,7 +51,9 @@ class KeyFromLocationTest extends TestCase {
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
Repository::openWithKey($this->backend, $this->keys)->recordSettings(['delete_delay_days' => 1]);
$this->assertSame(30, Repository::settingsAt($this->backend, $this->keys)['delete_delay_days']);
- $this->assertSame([], Repository::settingsAt($this->backend, KeyRing::generate()));
+ $this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, KeyRing::generate()), 'another key: there, but not readable');
+ unlink($this->dir . '/settings');
+ $this->assertSame([], Repository::settingsAt($this->backend, $this->keys), 'none at all: a location from before 1.1.0');
}
public function testAlteredSettingsAreNotBelieved(): void {
@@ -59,7 +61,7 @@ class KeyFromLocationTest extends TestCase {
$raw = file_get_contents($file);
$raw[strlen($raw) - 1] = $raw[strlen($raw) - 1] ^ "\x01";
file_put_contents($file, $raw);
- $this->assertSame([], Repository::settingsAt($this->backend, $this->keys));
+ $this->assertSame(['unreadable' => true], Repository::settingsAt($this->backend, $this->keys));
}
public function testTheKitWritesALostConfigAgainAndTheBackupsOpen(): void {
diff --git a/tests/Unit/Service/LocationScheduleTest.php b/tests/Unit/Service/LocationScheduleTest.php
index 7a994a0..851caca 100644
--- a/tests/Unit/Service/LocationScheduleTest.php
+++ b/tests/Unit/Service/LocationScheduleTest.php
@@ -48,7 +48,10 @@ class LocationScheduleTest extends TestCase {
public function testAClockChangeInTheNightStillGivesOneMoment(): void {
$this->assertSame(self::at('2026-03-29 01:30 UTC'), S::lastMoment('02:30', self::BERLIN, self::at('2026-03-29 12:00 UTC')), '02:30 does not exist that night');
- $this->assertNotNull(S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC')));
+ $autumn = S::lastMoment('02:30', self::BERLIN, self::at('2026-10-25 12:00 UTC'));
+ $this->assertNotNull($autumn);
+ $local = (new \DateTimeImmutable('@' . $autumn))->setTimezone(new \DateTimeZone(self::BERLIN));
+ $this->assertSame('2026-10-25 02:30', $local->format('Y-m-d H:i'), '02:30 happens twice that night: one of them, on that day');
}
public function testChoosingATimeDoesNotStartABackup(): void {
diff --git a/tests/integration/recovery-scenarios.sh b/tests/integration/recovery-scenarios.sh
index 1083b7f..f6f2eae 100755
--- a/tests/integration/recovery-scenarios.sh
+++ b/tests/integration/recovery-scenarios.sh
@@ -19,6 +19,8 @@
# unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase
# of the old server; a wrong one keeps the unused key
# used-key a key that protected a location is never replaced, also after the location was removed
+# slot-catch-up a passphrase added while a location was unreachable reaches it at its next
+# backup; a new server then gets the key from it with that passphrase
set -euo pipefail
IMAGE="nextcloud:${1:-35-apache}"
APP=$(cd "$(dirname "$0")/../.." && pwd)
@@ -269,4 +271,31 @@ expect "$OUT" "never replaced" "used-key key:import"
[ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed"
ok "used-key: never replaced, by kit or passphrase, also after its location was removed"
+# slot-catch-up: a passphrase change that missed a location reaches it at the next backup
+start ngb-rs-new
+docker exec ngb-rs-new sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2 && rm -rf /mnt/shared/catchup && mkdir -p /mnt/shared/catchup && chown www-data /mnt/shared/catchup"
+run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass --label=Safe >/dev/null || fail "slot-catch-up: key init"
+CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
+run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm"
+# shellcheck disable=SC2046
+run_on ngb-rs-new backup:target:add offsite $(location catchup) >/dev/null || fail "slot-catch-up: target add"
+run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: first backup"
+BEFORE=$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)
+docker exec ngb-rs-new chmod 000 /mnt/shared/catchup/repo
+OUT=$(must_fail ngb-rs-new "slot-catch-up: slot add with the location unreachable" backup:key:slot:add --label=CTO --passphrase-file=/tmp/pass2)
+expect "$OUT" "tried again at their next backup" "slot-catch-up: says the change follows"
+docker exec ngb-rs-new chmod 755 /mnt/shared/catchup/repo
+[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" = "$BEFORE" ] || fail "slot-catch-up: the location changed while unreachable"
+CODE=$(run_on ngb-rs-new backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];')
+run_on ngb-rs-new backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "slot-catch-up: confirm the new kit"
+run_on ngb-rs-new backup:run offsite >/dev/null || fail "slot-catch-up: backup after the location came back"
+[ "$(docker exec ngb-rs-new md5sum /mnt/shared/catchup/repo/config | cut -d' ' -f1)" != "$BEFORE" ] || fail "slot-catch-up: the backup did not bring the new passphrase to the location"
+CATCHFP=$(key_of ngb-rs-new)
+start ngb-rs-other
+docker exec ngb-rs-other sh -c "printf '%s\n' 'catch up passphrase of the cto' > /tmp/pass2 && chmod 644 /tmp/pass2"
+# shellcheck disable=SC2046
+OUT=$(run_on ngb-rs-other backup:target:add offsite $(location catchup) --passphrase-file=/tmp/pass2 2>&1) || { printf '%s\n' "$OUT"; fail "slot-catch-up: a new server with the new passphrase"; }
+expect "$OUT" "fingerprint $CATCHFP" "slot-catch-up: the new server"
+ok "slot-catch-up: a passphrase that missed a location reaches it at the next backup and opens it on a new server"
+
echo "RECOVERY SCENARIOS PASS"
diff --git a/tests/integration/upgrade-from-release.sh b/tests/integration/upgrade-from-release.sh
index a441001..089aba0 100644
--- a/tests/integration/upgrade-from-release.sh
+++ b/tests/integration/upgrade-from-release.sh
@@ -77,8 +77,8 @@ VER=$(occ app:list --output=json | php -r '$a = json_decode(stream_get_contents(
[ -z "$NEWVER" ] || [ "$VER" = "$NEWVER" ] || fail "after the upgrade the app has version '$VER'"
ok "occ upgrade to $VER"
-LIST=$(occ backup:target:list)
-printf '%s\n' "$LIST" | grep offsite | grep -q "03:15" || { printf '%s\n' "$LIST"; fail "the location did not keep 03:15 as its schedule"; }
+SCHED=$(occ backup:target:schedule offsite)
+printf '%s\n' "$SCHED" | grep -q "^offsite: 03:15 " || { printf '%s\n' "$SCHED"; fail "the location did not keep 03:15 as its schedule"; }
occ backup:list offsite | grep -q "$OLDSNAP" || fail "the old restore point is not listed"
occ backup:verify offsite "$OLDSNAP" >/dev/null || fail "the old restore point does not verify"
occ backup:status >/dev/null || fail "backup:status"
@@ -91,9 +91,15 @@ ok "after the upgrade: schedule kept, old restore point listed, verified, a file
docker exec -u www-data $N sh -c 'head -c 2000000 /dev/urandom > data/admin/files/Docs/after.bin'
occ files:scan admin >/dev/null
occ backup:run offsite >/dev/null || fail "backup after the upgrade"
-[ "$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | sort -u | wc -l)" -ge 2 ] || fail "no new restore point after the upgrade"
+NEWSNAP=$(occ backup:list offsite | grep -o '[0-9a-f]\{32\}' | grep -v "$OLDSNAP" | head -1)
+[ -n "$NEWSNAP" ] || fail "no new restore point after the upgrade"
+SUM2=$(docker exec $N sha256sum /var/www/html/data/admin/files/Docs/after.bin | cut -d' ' -f1)
+docker exec $N sh -c 'rm -rf /tmp/rst && mkdir /tmp/rst && chown www-data /tmp/rst'
+occ backup:restore offsite "$NEWSNAP" data/admin/files/Docs/after.bin --to-directory=/tmp/rst >/dev/null || fail "restore from the new restore point"
+GOT=$(docker exec $N sh -c 'find /tmp/rst -name after.bin -exec sha256sum {} \;' | cut -d' ' -f1)
+[ "$GOT" = "$SUM2" ] || fail "the file restored from the new restore point differs"
docker exec $N test -f /mnt/shared/loc/repo/settings || fail "the location got no settings file"
OUT=$(occ backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) && fail "a used key from before the upgrade was offered for replacement"
printf '%s\n' "$OUT" | grep -q "never replaced" || { printf '%s\n' "$OUT"; fail "the refusal does not say the key is never replaced"; }
-ok "a new backup runs, the location got its settings, the key counts as used"
+ok "a new backup runs and restores, the location got its settings, the key counts as used"
echo "UPGRADE $OLDVER -> ${VER:-working tree} PASS"