fix(docsync): an idle WebDAV binding read as an emptied folder, and two ids nobody checked

The audit's worst find, reproduced against a live Nextcloud before and after the
fix: the WebDAV adapters answer "nothing changed here" by returning an EMPTY
listing plus `cursorUnchanged`, and nothing read that flag — `grep -rn
cursorUnchanged src/` found it set by four adapters and consulted by none. The
core therefore saw a folder that had lost everything. Run 1 and 2 look fine
because the run's own uploads move the ETag; from run 3 on, every run of an
idle binding ends `partial` with `mass_delete_guard` and six documents reported
missing, forever, until failure_count opens the circuit and the scheduler drops
the binding entirely. That is the ordinary case — a binding nobody is touching.
planReconcile now takes `remoteUnchanged` and plans only what happened in TREK.

Two ids were authorised by trip and never checked against it:

  - resolveConflict took an item id on trust, so the owner of any trip could
    decide a conflict belonging to somebody else's. Both are plain integers and
    nothing compared them.
  - The connection test merged the STORED credential into a probe of whatever
    baseUrl the caller named — a way to have TREK post another account's API
    token at a server of the caller's choosing. Stored secrets are now reused
    only for the address they were stored against.

The phone's connect form was wrong in three ways at once, each of which the
desktop had right: it read the address as `baseUrl` while the field is
`base_url` (so every connection it saved carried an empty address), it drew
`allow_insecure_tls` as a text box and always sent false, and it built label
keys as `docsync.provider${cap(label)}` when the label column already holds the
full suffix — `docsync.providerProviderApiKey`, rendered raw on screen. Those
rules are not markup, so they now live in `useConnectForm`, which both shells
use. Its test fixture invented the field shape and so agreed with the broken
sheet; it now mirrors document_provider_fields.

Also: the scheduler read its interval once at bootstrap while this file's own
comment promised a per-tick read, so changing it did nothing until a restart;
and dueLinks ordered by a column that a successful run clears, which left every
binding tied and SQLite answering in insertion order — on a database with 170
bindings the same twenty ran on every tick and nothing created after them was
ever synced automatically.
This commit is contained in:
Maurice
2026-09-19 00:07:33 +02:00
parent 1430056d24
commit 4fa06c3483
12 changed files with 608 additions and 122 deletions
@@ -3,6 +3,7 @@ import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react'
import Modal from '../../shared/Modal'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync'
@@ -30,35 +31,13 @@ export default function DocSyncConnectModal({
const existing = sync.connectionFor(provider.id)
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const [values, setValues] = useState<Record<string, string>>({})
const [insecure, setInsecure] = useState<boolean | null>(null)
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
// Null until touched, so a connection that loads after the dialog opened is
// still reflected. Seeding the state at mount showed a stored "allow
// self-signed" as off and wrote that back on the next save — a security
// switch silently turning itself off is the worst version of this bug.
const insecureOn = insecure ?? existing?.allowInsecureTls ?? false
const baseUrl = values.base_url ?? existing?.baseUrl ?? ''
// The address is a column of its own, not a settings entry, so it has to be
// read from there — otherwise the field sits empty while the form submits the
// stored value behind it.
const shown = (key: string) =>
values[key] ?? (key === 'base_url' ? existing?.baseUrl : existing?.settings?.[key]) ?? ''
const required = provider.fields.filter(f => f.required)
const canSubmit = required.every(f =>
f.field_key === 'base_url'
? baseUrl.trim().length > 0
: f.secret
? (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key]
: shown(f.field_key).trim().length > 0,
)
const test = async () => setVerdict(await sync.testConnection(provider.id, baseUrl, values, insecureOn))
const test = async () => setVerdict(await form.probe())
const save = async () => {
if (await sync.saveConnection(provider.id, baseUrl, values, insecureOn)) onConnected(provider.id)
if (await form.save()) onConnected(provider.id)
}
return (
@@ -79,7 +58,7 @@ export default function DocSyncConnectModal({
<button
type="button"
onClick={() => void test()}
disabled={!canSubmit || sync.busy === 'test'}
disabled={!form.complete || sync.busy === 'test'}
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.test')}
@@ -87,7 +66,7 @@ export default function DocSyncConnectModal({
<button
type="button"
onClick={() => void save()}
disabled={!canSubmit || sync.busy === 'save'}
disabled={!form.complete || sync.busy === 'save'}
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
@@ -100,20 +79,18 @@ export default function DocSyncConnectModal({
<div className="space-y-4">
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{provider.fields.filter(f => f.field_key !== 'allow_insecure_tls').map(f => (
{form.fields.map(f => (
<Field
key={f.field_key}
label={t(`docsync.${f.label}`)}
hint={f.hint ? t(`docsync.${f.hint}`) : undefined}
label={t(form.labelKey(f))}
hint={form.hintKey(f) ? t(form.hintKey(f) as string) : undefined}
required={f.required}
>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={f.secret ? (values[f.field_key] ?? '') : shown(f.field_key)}
// A stored secret never comes back, so an empty field with a dotted
// placeholder means "the one already saved" rather than "blank".
placeholder={f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || ''}
onChange={e => setValues({ ...values, [f.field_key]: e.target.value })}
value={form.valueOf(f.field_key)}
placeholder={form.placeholderOf(f)}
onChange={e => form.setValue(f.field_key, e.target.value)}
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2"
/>
</Field>
@@ -127,7 +104,7 @@ export default function DocSyncConnectModal({
</span>
</span>
<span className="shrink-0 pt-0.5">
<ToggleSwitch on={insecureOn} onToggle={() => setInsecure(!insecureOn)} />
<ToggleSwitch on={form.insecureTls} onToggle={() => form.setInsecureTls(!form.insecureTls)} />
</span>
</label>
</div>
@@ -0,0 +1,109 @@
import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
/**
* The credential form's rules, for both shells.
*
* Which field carries the address, which one must not be drawn as a text box,
* how a label becomes a translation key, what counts as filled in when a secret
* is already stored: every one of those is a rule, and every one of them was
* wrong in the phone sheet while being right on the desktop. The phone read the
* address under `baseUrl` while the server names the field `base_url`, so every
* connection it saved carried an empty address; it drew `allow_insecure_tls` as
* a text input and always sent false; and it built label keys a different way,
* so the labels rendered as raw keys.
*
* None of that is markup, so none of it belongs in a component.
*/
/** The field that holds the address rather than a credential. */
export const BASE_URL_FIELD = 'base_url'
/** Rendered as a switch beside the fields, never as one of them. */
export const INSECURE_TLS_FIELD = 'allow_insecure_tls'
export interface ConnectForm {
/** The fields to draw, in order, minus the ones with their own control. */
fields: DocSyncProvider['fields']
/** Translation key for a field's label, and for its hint where it has one. */
labelKey: (field: DocSyncProvider['fields'][number]) => string
hintKey: (field: DocSyncProvider['fields'][number]) => string | undefined
/** What to show in a field: the typed value, else what is stored. */
valueOf: (fieldKey: string) => string
/** A stored secret never comes back, so its box shows dots instead. */
placeholderOf: (field: DocSyncProvider['fields'][number]) => string
setValue: (fieldKey: string, value: string) => void
baseUrl: string
credentials: Record<string, string>
insecureTls: boolean
setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */
complete: boolean
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
save: () => Promise<boolean>
}
export function useConnectForm(
provider: DocSyncProvider,
existing: DocSyncConnection | null,
sync: ReturnType<typeof useDocSync>,
): ConnectForm {
const [values, setValues] = useState<Record<string, string>>({})
// Null until touched, so a connection that arrives after the form opened is
// still reflected — seeding from `existing` at mount showed a stored "allow
// self-signed" as off and wrote that back on the next save.
const [insecure, setInsecure] = useState<boolean | null>(null)
const insecureTls = insecure ?? existing?.allowInsecureTls ?? false
/**
* What a box shows.
*
* A secret shows only what was typed into it this time, never what is stored:
* a stored credential must not travel back to a browser, and a value that
* ended up in `settings` by mistake must not become the one place it leaks.
* The address lives in its own column rather than in settings, so it is read
* from there; everything else comes out of settings.
*/
const secretKeys = new Set(provider.fields.filter(f => f.secret).map(f => f.field_key))
const valueOf = (key: string) => {
if (secretKeys.has(key)) return values[key] ?? ''
return values[key] ?? (key === BASE_URL_FIELD ? existing?.baseUrl : existing?.settings?.[key]) ?? ''
}
const baseUrl = valueOf(BASE_URL_FIELD)
// The address stays in here as well as in its own argument: the server walks
// the provider's fields over `credentials` and would otherwise store no
// `base_url` setting. It special-cases the key when checking completeness, so
// dropping it changes nothing it needs — but it is an established request
// shape and this is not the change to alter it in.
const credentials = Object.fromEntries(
Object.entries(values).filter(([k]) => k !== INSECURE_TLS_FIELD),
)
const fields = provider.fields.filter(f => f.field_key !== INSECURE_TLS_FIELD)
const complete = fields.every(f => {
if (!f.required) return true
if (f.secret) return (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key]
return valueOf(f.field_key).trim().length > 0
})
return {
fields,
// The label and hint columns store a key suffix, not text.
labelKey: f => `docsync.${f.label}`,
hintKey: f => (f.hint ? `docsync.${f.hint}` : undefined),
valueOf,
placeholderOf: f =>
f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || '',
setValue: (key, value) => setValues(v => ({ ...v, [key]: value })),
baseUrl,
credentials,
insecureTls,
setInsecureTls: setInsecure,
complete,
probe: () => sync.testConnection(provider.id, baseUrl, credentials, insecureTls),
save: () => sync.saveConnection(provider.id, baseUrl, credentials, insecureTls),
}
}