feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology

A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
This commit is contained in:
Maurice
2026-09-18 18:45:28 +02:00
parent 902dbbf84c
commit 2d7cb124b3
98 changed files with 20063 additions and 12 deletions
+30
View File
@@ -1358,6 +1358,36 @@ export const filesApi = {
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
/**
* Document sync: one provider connection per trip, shared by every member.
*
* Trip-scoped rather than user-scoped on purpose — a per-user connection would
* make a document's visibility depend on whose credentials fetched it, which is
* the opposite of what a shared trip needs.
*/
export const docsyncApi = {
providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data),
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data),
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data),
createScope: (tripId: number | string, connectionId: number, name: string) =>
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { connectionId, name }).then(r => r.data),
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data),
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data),
syncNow: (tripId: number | string, linkId: number, full = false) =>
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data),
items: (tripId: number | string, state?: string) =>
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
+39 -2
View File
@@ -41,6 +41,10 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
// Document providers reuse the Synology mark for the NAS and fall back to a
// generic lucide icon otherwise, rather than shipping four more brand SVGs
// for rows that are 14px tall.
synologydrive: SynologyIcon,
}
interface Addon {
@@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
if (addon.id === 'journey') await loadAddons()
// Same for Documents: switching it off disables every document provider in
// the database, and the response carries only the addon itself.
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
@@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return addon.type === 'photo_provider'
}
const isDocumentProviderAddon = (addon: Addon) => {
return addon.type === 'document_provider'
}
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const documentProviderAddons = addons.filter(isDocumentProviderAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
@@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
if (loading) {
return (
@@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// Document providers are the Documents tile's shelf, the way photo providers
// are Journey's. Unlike those they carry no credential form here: a document
// connection belongs to a trip, not to a user, so it is entered in the trip's
// file manager. The admin decides only whether a provider may be offered.
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
return documentProviderOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
title={provider.label}
description={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
)
})
}
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
@@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
+11 -1
View File
@@ -7,12 +7,13 @@ import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView'
import DocSyncPanel from './docsync/DocSyncPanel'
export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
return (
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
<div className="relative flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */}
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
@@ -24,6 +25,15 @@ export default function FileManager(props: FileManagerProps) {
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Document sync, as an overlay rather than a route: it is configuration
for the documents on this screen, and sending someone to settings to
reach it loses the context that makes the folder choice obvious. */}
{S.showDocSync && (
<div className="absolute inset-0 z-20 overflow-y-auto bg-surface p-4">
<DocSyncPanel tripId={props.tripId} isOwner={S.isTripOwner} onClose={() => S.setShowDocSync(false)} />
</div>
)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -1,8 +1,8 @@
import { Star, Trash2 } from 'lucide-react'
import { FolderSync, Star, Trash2 } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
export function FileManagerToolbar(S: FileManagerState) {
const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync } = S
return (
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
@@ -63,12 +63,24 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
{/* Opens the sync panel. Placed next to the trash rather than in a menu
because the question it answers, "where do these documents live",
belongs on the same screen as the documents. */}
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
}}>
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
</button>
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
flexShrink: 0,
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -0,0 +1,343 @@
import { useState } from 'react'
import { AlertTriangle, Check, Cloud, FolderSync, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useDocSync, type DocSyncProvider, type DocSyncScope } from './useDocSync'
/**
* The trip's document sync, configured where the documents are.
*
* It lives in the file manager rather than in settings because this is the one
* screen where someone has the context "these documents belong in that folder".
* Only the trip owner can change it — the credential usually reaches the
* owner's whole archive — but every member can see where their documents go,
* which is the minimum a shared folder owes the people sharing it.
*/
export default function DocSyncPanel({ tripId, isOwner, onClose }: { tripId: number | string; isOwner: boolean; onClose: () => void }) {
const { t } = useTranslation()
const sync = useDocSync(tripId, true)
const [openProvider, setOpenProvider] = useState<string | null>(null)
if (sync.loading) {
return (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
)
}
if (sync.providers.length === 0) {
return (
<div className="rounded-xl border border-edge bg-surface p-5 text-center">
<Cloud size={22} className="mx-auto text-content-faint" />
<p className="mt-2 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mt-1 text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-subtitle font-semibold text-content">{t('docsync.title')}</h3>
<p className="mt-0.5 text-caption text-content-muted">{t('docsync.subtitle')}</p>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-content-muted hover:bg-surface-hover" aria-label={t('common.close')}>
<X size={16} />
</button>
</div>
{sync.links.map(link => {
const provider = sync.providers.find(p => p.id === link.providerId)
return (
<div key={link.id} className="rounded-xl border border-edge bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<FolderSync size={16} className="text-accent" />
<span className="truncate text-body font-medium text-content">{provider?.name || link.providerId}</span>
<StateBadge state={link.lastSyncState} t={t} />
</div>
<p className="mt-1 truncate text-caption text-content-muted">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={sync.busy === `sync-${link.id}`}
onClick={() => void sync.syncNow(link.id)}
className="flex items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50"
>
{sync.busy === `sync-${link.id}`
? <Loader2 size={13} className="animate-spin" />
: <RefreshCw size={13} />}
{t('docsync.syncNow')}
</button>
{isOwner && (
<button
type="button"
onClick={() => void sync.removeLink(link.id)}
className="rounded-lg border border-edge p-1.5 text-content-muted hover:bg-surface-hover"
aria-label={t('docsync.unlink')}
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
{link.lastSyncError && (
<p className="mt-2 flex items-center gap-1.5 text-caption text-danger">
<AlertTriangle size={13} />
{t(`docsync.error.${link.lastSyncError}`)}
</p>
)}
{isOwner && (
<div className="mt-3 grid gap-3 border-t border-edge pt-3 sm:grid-cols-2">
<label className="text-caption text-content-muted">
{t('docsync.direction')}
<select
value={link.direction}
onChange={e => void sync.updateLink(link.id, { direction: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-body text-content"
>
<option value="both">{t('docsync.directionBoth')}</option>
<option value="pull">{t('docsync.directionPull')}</option>
<option value="push">{t('docsync.directionPush')}</option>
</select>
</label>
<label className="text-caption text-content-muted">
{t('docsync.deletePolicy')}
<select
value={link.deletePolicy}
onChange={e => void sync.updateLink(link.id, { deletePolicy: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-body text-content"
>
<option value="unlink">{t('docsync.deleteUnlink')}</option>
<option value="trash">{t('docsync.deleteTrash')}</option>
</select>
</label>
<div className="flex items-center justify-between sm:col-span-2">
<span className="text-caption text-content-muted">{t('docsync.syncEnabled')}</span>
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</div>
{/* Shown only where TREK could not subscribe itself: Papra's
webhook API is closed to API keys and Nextcloud's needs admin
rights, so the user pastes this in by hand. Polling carries
the binding either way. */}
{link.webhookUrl && (
<div className="sm:col-span-2">
<p className="text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<code className="mt-1 block overflow-x-auto rounded-lg bg-surface-subtle px-2 py-1.5 text-caption text-content">
{link.webhookUrl}
</code>
</div>
)}
</div>
)}
</div>
)
})}
{Object.keys(sync.itemCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(sync.itemCounts).map(([state, n]) => (
<span key={state} className="rounded-full border border-edge px-2.5 py-1 text-caption text-content-muted">
{t(`docsync.state.${state}`)}: {n}
</span>
))}
</div>
)}
{isOwner && (
<div className="space-y-2">
<p className="text-caption font-medium text-content-muted">{t('docsync.addProvider')}</p>
{sync.providers.map(p => (
<ProviderRow
key={p.id}
provider={p}
tripId={tripId}
sync={sync}
open={openProvider === p.id}
onOpen={() => setOpenProvider(openProvider === p.id ? null : p.id)}
/>
))}
</div>
)}
</div>
)
}
function StateBadge({ state, t }: { state: string; t: (k: string) => string }) {
const tone =
state === 'ok' ? 'text-success'
: state === 'never' ? 'text-content-faint'
: state === 'partial' ? 'text-warning'
: 'text-danger'
return <span className={`text-caption ${tone}`}>{t(`docsync.linkState.${state}`)}</span>
}
/** One provider: credentials, a probe, then a folder to bind the trip to. */
function ProviderRow({
provider, tripId, sync, open, onOpen,
}: {
provider: DocSyncProvider
tripId: number | string
sync: ReturnType<typeof useDocSync>
open: boolean
onOpen: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const [values, setValues] = useState<Record<string, string>>({})
const [insecure, setInsecure] = useState(existing?.allowInsecureTls ?? false)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [newScopeName, setNewScopeName] = useState('')
const baseUrl = values.base_url ?? existing?.baseUrl ?? ''
const field = (key: string) => values[key] ?? existing?.settings?.[key] ?? ''
const runTest = async () => {
const res = await sync.testConnection(provider.id, baseUrl, values, insecure)
setVerdict(res)
}
const save = async () => {
const ok = await sync.saveConnection(provider.id, baseUrl, values, insecure)
if (ok) setVerdict({ connected: true })
}
const loadScopes = async () => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
const res = await sync.loadScopes(conn.id)
setScopes(res.scopes)
}
const bind = async (scope: DocSyncScope) => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
await sync.createLink({
connectionId: conn.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setScopes(null)
}
return (
<div className="rounded-xl border border-edge bg-surface">
<button type="button" onClick={onOpen} className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left">
<span className="flex items-center gap-2">
<Cloud size={15} className="text-content-muted" />
<span className="text-body text-content">{provider.name}</span>
{existing && <Check size={13} className="text-success" />}
</span>
<Plus size={15} className={`text-content-faint transition-transform ${open ? 'rotate-45' : ''}`} />
</button>
{open && (
<div className="space-y-3 border-t border-edge px-4 py-3">
{provider.fields.map(f => (
<label key={f.field_key} className="block text-caption text-content-muted">
{t(`docsync.${f.label}`)}{f.required && ' *'}
{f.input_type === 'checkbox' ? (
<div className="mt-1">
<ToggleSwitch on={insecure} onToggle={() => setInsecure(!insecure)} />
</div>
) : (
<input
type={f.input_type === 'password' ? 'password' : 'text'}
// A stored secret is never sent back, so the field stays empty
// and an empty field means "keep what is stored".
placeholder={f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || ''}
value={f.secret ? (values[f.field_key] ?? '') : field(f.field_key)}
onChange={e => setValues({ ...values, [f.field_key]: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-body text-content"
/>
)}
{f.hint && <span className="mt-0.5 block text-caption text-content-faint">{t(`docsync.${f.hint}`)}</span>}
</label>
))}
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => void runTest()} disabled={sync.busy === 'test'}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50">
{sync.busy === 'test' ? <Loader2 size={13} className="animate-spin" /> : t('docsync.test')}
</button>
<button type="button" onClick={() => void save()} disabled={sync.busy === 'save'}
className="rounded-lg bg-accent px-3 py-1.5 text-caption text-accent-contrast disabled:opacity-50">
{t('common.save')}
</button>
{existing && (
<button type="button" onClick={() => void loadScopes()}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover">
{t('docsync.chooseFolder')}
</button>
)}
{verdict && (
<span className={`text-caption ${verdict.connected ? 'text-success' : 'text-danger'}`}>
{verdict.connected
? t('docsync.connected') + (verdict.account ? ` (${verdict.account})` : '')
: t(`docsync.error.${verdict.error || 'unknown'}`)}
</span>
)}
</div>
{scopes && (
<div className="space-y-2 rounded-lg border border-edge bg-surface-subtle p-3">
<p className="text-caption text-content-muted">{t('docsync.chooseFolderHint')}</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{scopes.map(s => (
<button key={s.scopeKey} type="button" onClick={() => void bind(s)}
className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-caption text-content hover:bg-surface-hover">
{s.label}
{s.remoteRootPath && <span className="ml-2 text-content-faint">{s.remoteRootPath}</span>}
</button>
))}
{scopes.length === 0 && <p className="text-caption text-content-faint">{t('docsync.noFolders')}</p>}
</div>
<div className="flex gap-2">
<input
value={newScopeName}
onChange={e => setNewScopeName(e.target.value)}
placeholder={t('docsync.newFolderPlaceholder')}
className="flex-1 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content"
/>
<button
type="button"
disabled={!newScopeName.trim() || sync.busy === 'scope'}
onClick={async () => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
const created = await sync.createScope(conn.id, newScopeName.trim())
setNewScopeName('')
await bind(created)
}}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.createFolder')}
</button>
</div>
</div>
)}
{sync.error && <p className="text-caption text-danger">{t(`docsync.error.${sync.error}`)}</p>}
</div>
)}
</div>
)
}
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { docsyncApi } from '../../../api/client'
/**
* All document-sync state and actions, in one hook.
*
* Deliberately a hook rather than logic inside the panel: the phone shell is a
* separate component tree (client/src/mobile/screens/trip/...), and duplicating
* this across both would land on SonarCloud's 3% duplication budget the way the
* photo provider section already does. `useDawarichConnection` states the same
* reason for the same problem.
*/
export interface DocSyncField {
field_key: string
label: string
input_type: string
placeholder: string | null
hint: string | null
required: boolean
secret: boolean
}
export interface DocSyncProvider {
id: string
name: string
description: string | null
icon: string
available: boolean
fields: DocSyncField[]
}
export interface DocSyncConnection {
id: number
providerId: string
baseUrl: string
settings: Record<string, string>
secrets: Record<string, string>
allowInsecureTls: boolean
lastProbeState: string
lastProbeError: string | null
}
export interface DocSyncScope {
scopeKey: string
label: string
remoteRootId: string | null
remoteRootPath: string | null
}
export interface DocSyncLink {
id: number
connectionId: number
providerId: string
scopeKey: string
remoteLabel: string
remoteRootPath: string | null
direction: 'both' | 'pull' | 'push'
deletePolicy: 'unlink' | 'trash'
conflictPolicy: string
syncEnabled: boolean
lastSyncAt: string | null
lastSyncState: string
lastSyncError: string | null
webhookUrl: string | null
}
export function useDocSync(tripId: number | string, enabled: boolean) {
const [providers, setProviders] = useState<DocSyncProvider[]>([])
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (!enabled) return
setLoading(true)
try {
const [p, c, l, s] = await Promise.all([
docsyncApi.providers(tripId),
docsyncApi.listConnections(tripId),
docsyncApi.listLinks(tripId),
docsyncApi.status(tripId),
])
setProviders(p as DocSyncProvider[])
setConnections(c as DocSyncConnection[])
setLinks(l as DocSyncLink[])
setItemCounts(((s as { items?: Record<string, number> }).items) || {})
setError(null)
} catch {
// A failure here means the addon is off or the user lost access, both of
// which the panel renders as "nothing to configure" rather than an alarm.
setProviders([])
} finally {
setLoading(false)
}
}, [tripId, enabled])
useEffect(() => { void load() }, [load])
const connectionFor = useCallback(
(providerId: string) => connections.find(c => c.providerId === providerId) || null,
[connections],
)
const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('save')
setError(null)
try {
await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls })
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object — the route
* answers 200 even for an unreachable instance, because a form showing a typo
* needs a field to render, not an exception.
*/
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) {
return { connected: false, error: readError(e) }
} finally {
setBusy(null)
}
}, [tripId])
const loadScopes = useCallback(async (connectionId: number, q?: string) => {
const res = await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
return res
}, [tripId])
const createScope = useCallback(async (connectionId: number, name: string) => {
setBusy('scope')
try {
return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope
} finally {
setBusy(null)
}
}, [tripId])
const createLink = useCallback(async (payload: Record<string, unknown>) => {
setBusy('link')
setError(null)
try {
await docsyncApi.createLink(tripId, payload)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
const updateLink = useCallback(async (linkId: number, patch: Record<string, unknown>) => {
setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l)))
try {
await docsyncApi.updateLink(tripId, linkId, patch)
} finally {
await load()
}
}, [tripId, load])
const removeLink = useCallback(async (linkId: number) => {
setBusy('link')
try {
await docsyncApi.deleteLink(tripId, linkId)
await load()
} finally {
setBusy(null)
}
}, [tripId, load])
const syncNow = useCallback(async (linkId: number, full = false) => {
setBusy(`sync-${linkId}`)
try {
const res = await docsyncApi.syncNow(tripId, linkId, full)
await load()
return res as { state: string; pulled: number; pushed: number; conflicts: number; missing: number }
} finally {
setBusy(null)
}
}, [tripId, load])
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
await docsyncApi.resolve(tripId, itemId, keep)
await load()
}, [tripId, load])
/** Providers the admin switched on that also have a working adapter. */
const usableProviders = useMemo(() => providers.filter(p => p.available), [providers])
return {
providers: usableProviders,
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
}
function readError(e: unknown): string {
const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response
return res?.data?.error || res?.data?.message || 'unknown'
}
@@ -6,6 +6,7 @@ import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
@@ -30,6 +31,7 @@ export interface FileManagerProps {
*/
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [showDocSync, setShowDocSync] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
@@ -38,6 +40,17 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const currentUser = useAuthStore((s) => s.user)
/**
* Only the trip owner may change a sync binding: the credential it stores
* usually reaches that person's entire document archive, so letting any
* member repoint it would share a folder the owner never chose to share.
* Instance admins are included because they already override every other
* permission check in the client.
*/
const isTripOwner = !!currentUser && (
currentUser.role === 'admin' || Number(trip?.user_id) === Number(currentUser.id)
)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -210,6 +223,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, mediaFiles, openFile,
showDocSync, setShowDocSync, isTripOwner,
}
}
+1
View File
@@ -42,6 +42,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
ADDON_IDS.COLLAB,
ADDON_IDS.COLLECTIONS,
ADDON_IDS.DAWARICH,
ADDON_IDS.DOCUMENTS,
ADDON_IDS.JOURNEY,
ADDON_IDS.PACKING,
ADDON_IDS.ROADTRIP,
+152
View File
@@ -0,0 +1,152 @@
import type Database from 'better-sqlite3';
/**
* The five document providers and the fields their connection form asks for.
*
* Shared by the migration that introduces the tables and by `seeds.ts`, so a
* fresh install and an upgraded one end up with the same rows. Both call sites
* use INSERT OR IGNORE, which is what lets an operator rename or re-sort a row
* without a later boot undoing it.
*
* `label` and `hint` are i18n key suffixes, never display text — the client
* resolves them as `docsync.<label>`, the same way the photo provider form
* resolves `memories.<label>`.
*
* Nextcloud and OpenCloud are separate rows although one WebDAV adapter serves
* both: the fields differ (a Nextcloud login name and app password against a
* folder, an OpenCloud username and app token against a space), and an operator
* scanning the addon shelf should find the product they actually run.
*/
interface ProviderRow {
id: string;
name: string;
description: string;
icon: string;
sort_order: number;
}
interface ProviderFieldRow {
provider_id: string;
field_key: string;
label: string;
input_type: 'text' | 'url' | 'password' | 'checkbox';
placeholder: string | null;
hint: string | null;
required: 0 | 1;
secret: 0 | 1;
sort_order: number;
}
const PROVIDERS: ProviderRow[] = [
{
id: 'paperless',
name: 'Paperless-ngx',
description: 'Two-way document sync with a Paperless-ngx instance, scoped by tag',
icon: 'FileText',
sort_order: 0,
},
{
id: 'papra',
name: 'Papra',
description: 'Two-way document sync with a Papra organisation, scoped by tag',
icon: 'FileText',
sort_order: 1,
},
{
id: 'nextcloud',
name: 'Nextcloud',
description: 'Two-way document sync with a Nextcloud folder over WebDAV',
icon: 'Cloud',
sort_order: 2,
},
{
id: 'opencloud',
name: 'OpenCloud',
description: 'Two-way document sync with an OpenCloud space over WebDAV',
icon: 'Cloud',
sort_order: 3,
},
{
id: 'synologydrive',
name: 'Synology Drive',
description: 'Two-way document sync with a folder on a Synology NAS',
icon: 'HardDrive',
sort_order: 4,
},
];
/**
* `allow_insecure_tls` defaults to 0 on every provider — unlike the Synology
* photo provider, where skipping verification is the stored default. A LAN
* instance with a self-signed certificate is common enough to need the switch
* and not common enough to justify shipping it on.
*/
const FIELDS: ProviderFieldRow[] = [
// Paperless-ngx: token auth, tag scope.
{ provider_id: 'paperless', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'paperless', field_key: 'api_token', label: 'providerApiToken', input_type: 'password', placeholder: 'API token', hint: 'hintPaperlessToken', required: 1, secret: 1, sort_order: 1 },
{ provider_id: 'paperless', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 2 },
// Papra: bearer key, and an organisation the key must already belong to.
{ provider_id: 'papra', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://papra.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'papra', field_key: 'api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'ppapi_…', hint: 'hintPapraKey', required: 1, secret: 1, sort_order: 1 },
{ provider_id: 'papra', field_key: 'organization_id', label: 'providerOrganization', input_type: 'text', placeholder: 'org_…', hint: 'hintPapraOrg', required: 1, secret: 0, sort_order: 2 },
{ provider_id: 'papra', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 },
// Nextcloud: an app password, never the account password — the latter breaks
// the moment the account turns on 2FA or logs in through OIDC.
{ provider_id: 'nextcloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'nextcloud', field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'nextcloud', field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'nextcloud', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 3 },
{ provider_id: 'nextcloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 4 },
// OpenCloud: auth-app token, spaces instead of folders.
{ provider_id: 'opencloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://opencloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'opencloud', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: null, required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'opencloud', field_key: 'app_token', label: 'providerAppToken', input_type: 'password', placeholder: 'app token', hint: 'hintOpenCloudToken', required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'opencloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 },
// Synology: DSM has no app tokens at all, so this is a real account. The OTP
// is used once to obtain a device token, which is what gets stored.
{ provider_id: 'synologydrive', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: 1, secret: 0, sort_order: 0 },
{ provider_id: 'synologydrive', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintSynologyUser', required: 1, secret: 0, sort_order: 1 },
{ provider_id: 'synologydrive', field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: 1, secret: 1, sort_order: 2 },
{ provider_id: 'synologydrive', field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: 0, secret: 1, sort_order: 3 },
{ provider_id: 'synologydrive', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/trek', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 4 },
{ provider_id: 'synologydrive', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 5 },
];
export function seedDocumentProviders(db: Database.Database): void {
const insertProvider = db.prepare(
'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)',
);
for (const p of PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order);
const insertField = db.prepare(
`INSERT OR IGNORE INTO document_provider_fields
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const f of FIELDS) {
// settings_key and payload_key both carry the field key: every value lands
// in one JSON column here, but the generic client form reads those two and
// would need a branch if they were null.
insertField.run(
f.provider_id,
f.field_key,
f.label,
f.input_type,
f.placeholder,
f.hint,
f.required,
f.secret,
f.secret === 1 ? null : f.field_key,
f.field_key,
f.sort_order,
);
}
}
export const DOCUMENT_PROVIDER_SEED_IDS = PROVIDERS.map((p) => p.id);
+176
View File
@@ -1,6 +1,7 @@
import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared';
import { readEnv } from '../app-config';
import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto';
import { seedDocumentProviders } from './document-provider-seed';
import Database from 'better-sqlite3';
import fs from 'fs';
@@ -4949,6 +4950,181 @@ function runMigrations(db: Database.Database): void {
'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)',
);
},
/*
* Document providers, part 1 of 3: the registry (#214).
*
* Deliberately its own pair of tables rather than a `kind` column on
* `photo_providers`. The client filters on `type === 'photo_provider'`, the
* Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no
* WHERE clause, and migrations are append-only — reusing those tables would
* change the behaviour of three existing paths, which is exactly what the
* no-breaking-changes rule forbids. The column set is copied verbatim so
* the same generic settings form renders both kinds without a special case,
* including `settings_key`/`payload_key`, which are redundant here (every
* value lands in one JSON column) but keep the client branch-free.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'FileText',
enabled INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS document_provider_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
settings_key TEXT,
payload_key TEXT,
sort_order INTEGER DEFAULT 0,
UNIQUE(provider_id, field_key)
);
`);
seedDocumentProviders(db);
},
/*
* Document providers, part 2 of 3: the connection, and the trip binding.
*
* The connection carries a `trip_id`, and that is the one place this design
* departs from every integration already in the repo. Immich, Synology
* Photos, AirTrail and Dawarich all hang off a user, and even
* `trip_album_links` carries a `user_id`; photos become visible to the rest
* of a trip only through an opt-in `shared` flag. None of that can satisfy
* "everyone on the trip sees the same documents" — it would make a
* document's visibility depend on whose credentials fetched it. So the trip
* admin binds the trip once, the server talks to the provider under that
* single identity, and TREK's own membership decides who sees what.
*
* `owner_user_id` stays separate from `trip_id` so the credential holder is
* always explicit: when that person leaves the trip the binding goes to
* `orphaned` rather than silently continuing to use an ex-member's token.
*
* Secrets live in one encrypted JSON blob instead of per-provider columns:
* a sixth provider then needs no migration, and the key rotation in
* scripts/migrate-encryption.ts stays one line instead of a field list that
* someone will forget — and a forgotten column does not survive a rotation.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
base_url TEXT NOT NULL,
secrets TEXT,
settings TEXT NOT NULL DEFAULT '{}',
allow_insecure_tls INTEGER NOT NULL DEFAULT 0,
capabilities TEXT,
last_probe_at TEXT,
last_probe_state TEXT NOT NULL DEFAULT 'never',
last_probe_error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, provider_id)
);
CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id);
CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id);
CREATE TABLE IF NOT EXISTS trip_document_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE,
provider_id TEXT NOT NULL,
remote_scope_key TEXT NOT NULL,
remote_root_id TEXT,
remote_root_path TEXT,
remote_label TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT 'both',
delete_policy TEXT NOT NULL DEFAULT 'unlink',
conflict_policy TEXT NOT NULL DEFAULT 'manual',
sync_enabled INTEGER NOT NULL DEFAULT 1,
webhook_token TEXT,
webhook_secret TEXT,
webhook_subscription_id TEXT,
remote_cursor TEXT,
last_sync_at TEXT,
last_sync_state TEXT NOT NULL DEFAULT 'never',
last_sync_error TEXT,
failure_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, connection_id, remote_scope_key)
);
CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token
ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL;
`);
},
/*
* Document providers, part 3 of 3: the pairing and its sync state.
*
* `content_sha256` and `pushed_sha256` are two columns on purpose. The
* first is the bytes both sides last agreed on, the second is what TREK
* itself last uploaded. Collapsing them into one is precisely the mistake
* that builds an echo loop: a webhook fires for TREK's own write, the core
* cannot tell it from a stranger's edit, and the file bounces.
*
* `remote_missing_at` records that something vanished upstream instead of
* acting on it — the rule Dawarich already follows with `source_missing_at`.
* An unmounted share answers with an empty listing, and reading that as
* "everything was deleted" would empty a trip.
*
* `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is
* permanently deleted in TREK, so the next run does not cheerfully download
* it again.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_sync_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL,
trek_doc_uid TEXT NOT NULL,
remote_id TEXT,
remote_name TEXT,
remote_version TEXT,
remote_size INTEGER,
remote_modified_at TEXT,
content_sha256 TEXT,
pushed_sha256 TEXT,
state TEXT NOT NULL DEFAULT 'pending',
error_code TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
remote_missing_at TEXT,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
synced_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote
ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file
ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid
ON document_sync_items(link_id, trek_doc_uid);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256);
CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at);
`);
},
];
if (currentVersion < migrations.length) {
+8
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { readEnv } from '../app-config';
import { seedDocumentProviders } from './document-provider-seed';
// bcrypt cost factor for the seeded admin password — kept in sync with authService.
const BCRYPT_COST = 12;
@@ -164,6 +165,13 @@ function seedAddons(db: Database.Database): void {
for (const f of providerFields) {
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
}
// Document providers live in their own pair of tables (see the migration
// for why they are not a `kind` column on photo_providers). Seeded from the
// same helper the migration uses, so a fresh install and an upgraded one
// agree.
seedDocumentProviders(db);
console.log('Default addons seeded');
} catch (err: unknown) {
console.error('Error seeding addons:', err instanceof Error ? err.message : err);
+50 -2
View File
@@ -598,6 +598,28 @@ export class AdminService {
fieldsByProvider.set(field.provider_id, arr);
}
// Document providers are the Documents addon's shelf rows, the same way
// photo providers are Journey's. Hidden on a managed instance for the same
// reason: Paperless, Papra, Nextcloud, OpenCloud and a Synology NAS are all
// servers the admin runs at home, and a hosted TREK has no route to one.
//
// They carry no `config` routes, unlike photo providers: the credentials do
// not belong to a user here but to a trip, so they are entered in the trip
// rather than in settings. The admin decides only whether a provider may be
// offered at all.
const docProviders = this.db.all<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>(`
SELECT id, name, description, icon, enabled, sort_order
FROM document_providers
ORDER BY sort_order, id
`).filter(() => !readEnv().managed.enabled);
return [
...addons.map((a) => ({
...a,
@@ -628,6 +650,17 @@ export class AdminService {
})),
sort_order: p.sort_order,
})),
...docProviders.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
type: 'document_provider',
icon: p.icon,
enabled: !!p.enabled,
config: {},
fields: [],
sort_order: p.sort_order,
})),
];
}
@@ -635,7 +668,8 @@ export class AdminService {
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
const docProvider = this.db.get<ProviderRow>('SELECT * FROM document_providers WHERE id = ?', id);
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
// The whole addon, not just its config: on a centrally administered install
// the operator owns the endpoint, the model and the per-document cost, so
@@ -652,6 +686,13 @@ export class AdminService {
return { error: 'Enable the Journey addon first', status: 409 };
}
// Same rule one shelf down: a document provider only exists to serve the
// file manager, so switching one on under a disabled Documents addon would
// advertise a sync nothing can reach.
if (docProvider && data.enabled === true && !this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) {
return { error: 'Enable the Documents addon first', status: 409 };
}
this.db.transaction(() => {
if (addon) {
if (data.enabled !== undefined) {
@@ -660,6 +701,10 @@ export class AdminService {
// resurface the moment journey returns, which nobody switched on.
if (id === ADDON_IDS.JOURNEY && !data.enabled)
this.db.run('UPDATE photo_providers SET enabled = 0');
// Documents off takes its providers with it, for the reason above: a
// row left enabled would resurface the moment the addon returns.
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
this.db.run('UPDATE document_providers SET enabled = 0');
}
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
@@ -670,9 +715,12 @@ export class AdminService {
: data.config;
this.db.run('UPDATE addons SET config = ? WHERE id = ?', JSON.stringify(configToStore), id);
}
} else {
} else if (provider) {
if (data.enabled !== undefined)
this.db.run('UPDATE photo_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
} else {
if (data.enabled !== undefined)
this.db.run('UPDATE document_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
}
});
+2 -1
View File
@@ -45,6 +45,7 @@ import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
import { DawarichModule } from './integrations/dawarich.module';
import { DocSyncModule } from './doc-sync/doc-sync.module';
import { JourneyModule } from './journey/journey.module';
import { CollectionsModule } from './collections/collections.module';
import { ShareModule } from './share/share.module';
@@ -81,7 +82,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
@@ -107,6 +107,11 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
'DiscoveryController.protectedResource',
'DiscoveryController.wellKnownFallback',
'DiscoveryController.wellKnownRoot',
// A document provider cannot hold a TREK session. The per-binding token in
// the path is the credential, a shared secret is checked on top where the
// provider can send one, and the handler's only effect is to schedule a sync
// run — it never reads the request body as data.
'DocSyncWebhookController.nudge',
'FeaturesController.features',
// The container/uptime probe.
'FeaturesController.health',
@@ -0,0 +1,440 @@
import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import {
DOCSYNC_SECRET_MASK,
type DocsyncConnectionInput,
type DocsyncLinkInput,
type DocsyncScopeOption,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DocumentProviderRegistry } from './document-provider.registry';
import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider';
import { docFailed } from './document-provider';
import { decryptSecrets, encryptSecrets, maskSecrets, mergeSecrets } from './doc-sync-secrets';
/**
* Connections and trip bindings — everything a human configures, as opposed to
* what the reconciler does with it.
*
* The connection carries a `trip_id`, and that is the one place this design
* leaves the pattern every other integration in the repo follows. Immich,
* Synology Photos, AirTrail and Dawarich all store credentials per user, and
* `trip_album_links` carries a `user_id` so that photos are shared only through
* an explicit flag. None of that can deliver "everyone on the trip sees the
* same documents": visibility would depend on whose credentials fetched a file,
* and a member without their own Paperless account would see nothing.
*
* So the trip admin binds the trip once, the server talks to the provider under
* that single identity, and TREK's own membership decides who sees what. The
* provider never learns that TREK has members. `owner_user_id` stays explicit
* beside `trip_id` precisely so that the credential holder is nameable: when
* they leave the trip the binding goes to `orphaned` rather than quietly
* carrying on with an ex-member's token.
*/
export interface ConnectionRow {
id: number;
trip_id: number;
provider_id: string;
owner_user_id: number;
base_url: string;
secrets: string | null;
settings: string;
allow_insecure_tls: number;
capabilities: string | null;
last_probe_at: string | null;
last_probe_state: string;
last_probe_error: string | null;
}
export interface LinkRow {
id: number;
trip_id: number;
connection_id: number;
provider_id: string;
remote_scope_key: string;
remote_root_id: string | null;
remote_root_path: string | null;
remote_label: string;
direction: string;
delete_policy: string;
conflict_policy: string;
sync_enabled: number;
webhook_token: string | null;
webhook_secret: string | null;
webhook_subscription_id: string | null;
remote_cursor: string | null;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
failure_count: number;
next_attempt_at: string | null;
}
interface ProviderFieldRow {
field_key: string;
secret: number;
required: number;
}
@Injectable()
export class DocSyncConfigService {
constructor(
private readonly db: DatabaseService,
private readonly registry: DocumentProviderRegistry,
) {}
// ── Provider metadata ──────────────────────────────────────────────────────
/** Only providers the instance admin switched on may be configured. */
enabledProviderIds(): string[] {
return this.db.connection
.prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all()
.map((r) => (r as { id: string }).id);
}
providerFields(providerId: string): ProviderFieldRow[] {
return this.db.connection
.prepare('SELECT field_key, secret, required FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order')
.all(providerId) as ProviderFieldRow[];
}
private secretKeys(providerId: string): string[] {
return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key);
}
// ── Connections ────────────────────────────────────────────────────────────
getConnection(id: number): ConnectionRow | undefined {
return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined;
}
listConnections(tripId: number): ConnectionRow[] {
return this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id')
.all(tripId) as ConnectionRow[];
}
/**
* Turn a stored row into what an adapter needs. Secrets are decrypted here
* and nowhere else, so there is one place to audit and one place that could
* leak them into a log.
*/
toRef(row: ConnectionRow): DocumentConnectionRef {
let settings: Record<string, string> = {};
try {
const parsed: unknown = JSON.parse(row.settings || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') settings[k] = v;
}
}
} catch {
settings = {};
}
return {
connectionId: row.id,
ownerId: row.owner_user_id,
baseUrl: row.base_url,
secrets: decryptSecrets(row.secrets),
settings,
allowInsecureTls: row.allow_insecure_tls === 1,
};
}
/** What a client may see: no secret values, only whether each one is set. */
publicConnection(row: ConnectionRow): Record<string, unknown> {
const ref = this.toRef(row);
return {
id: row.id,
tripId: row.trip_id,
providerId: row.provider_id,
ownerUserId: row.owner_user_id,
baseUrl: row.base_url,
settings: ref.settings,
secrets: maskSecrets(ref.secrets, this.secretKeys(row.provider_id)),
allowInsecureTls: row.allow_insecure_tls === 1,
capabilities: row.capabilities ? safeParse(row.capabilities) : null,
lastProbeAt: row.last_probe_at,
lastProbeState: row.last_probe_state,
lastProbeError: row.last_probe_error,
};
}
/**
* Validate a base URL before it is stored.
*
* A private address is stored WITH a warning rather than refused, because
* self-hosting is the normal case here — a Paperless on 192.168.x is the
* point of the feature, not an attack. That is the same call airtrail.service
* and dawarich.service already made. What is refused is an address that the
* SSRF guard rejects for a reason other than being private.
*/
async validateBaseUrl(raw: string): Promise<DocResult<{ url: string; isPrivate: boolean }>> {
const trimmed = raw.trim().replace(/\/+$/, '');
if (!trimmed) return { success: false, error: { code: 'unreachable', detail: 'empty url' } };
const verdict = await checkSsrf(trimmed);
if (!verdict.allowed && !verdict.isPrivate) {
return { success: false, error: { code: 'ssrf_blocked', detail: verdict.error } };
}
return { success: true, data: { url: trimmed, isPrivate: verdict.isPrivate } };
}
/**
* Create or update the trip's connection for one provider.
*
* Secrets that arrive blank or masked keep their stored value, so a client
* that renders the form from a GET never has to hold the real credential.
*/
async upsertConnection(
tripId: number,
userId: number,
input: DocsyncConnectionInput,
): Promise<DocResult<ConnectionRow>> {
const urlCheck = await this.validateBaseUrl(input.baseUrl);
if (docFailed(urlCheck)) return { success: false, error: urlCheck.error };
const fields = this.providerFields(input.providerId);
const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key);
const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key);
const existing = this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?')
.get(tripId, input.providerId) as ConnectionRow | undefined;
const storedSecrets = existing ? decryptSecrets(existing.secrets) : {};
const nextSecrets = mergeSecrets(storedSecrets, input.credentials, secretKeys);
const settings: Record<string, string> = {};
for (const key of plainKeys) {
if (key === 'allow_insecure_tls') continue;
const submitted = input.credentials[key];
if (submitted !== undefined) settings[key] = submitted;
else if (existing) {
const prev = this.toRef(existing).settings[key];
if (prev !== undefined) settings[key] = prev;
}
}
for (const f of fields) {
if (f.required !== 1) continue;
const present = f.secret === 1 ? !!nextSecrets[f.field_key] : !!settings[f.field_key] || f.field_key === 'base_url';
if (!present) {
return { success: false, error: { code: 'unauthorized', detail: `missing required field ${f.field_key}` } };
}
}
const encrypted = encryptSecrets(nextSecrets);
const settingsJson = JSON.stringify(settings);
const insecure = input.allowInsecureTls ? 1 : 0;
const row = this.db.transaction(() => {
if (existing) {
this.db.connection
.prepare(
`UPDATE document_connections
SET base_url = ?, secrets = ?, settings = ?, allow_insecure_tls = ?,
owner_user_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(urlCheck.data.url, encrypted, settingsJson, insecure, userId, existing.id);
return this.getConnection(existing.id) as ConnectionRow;
}
const info = this.db.connection
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings, allow_insecure_tls)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure);
return this.getConnection(Number(info.lastInsertRowid)) as ConnectionRow;
});
return { success: true, data: row };
}
recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): void {
this.db.connection
.prepare(
`UPDATE document_connections
SET last_probe_at = CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?,
capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(state, error, capabilities ? JSON.stringify(capabilities) : null, connectionId);
}
/**
* Deleting a connection leaves every document in place. It unbinds, nothing
* more — the same promise Dawarich's disconnect makes, and the only version
* of this action that is safe to offer without a confirmation dialog.
*/
deleteConnection(id: number): void {
this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
listLinks(tripId: number): LinkRow[] {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id')
.all(tripId) as LinkRow[];
}
getLink(id: number): LinkRow | undefined {
return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined;
}
getLinkByToken(token: string): LinkRow | undefined {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?')
.get(token) as LinkRow | undefined;
}
toScopeRef(link: LinkRow): DocumentScopeRef {
return {
linkId: link.id,
tripId: link.trip_id,
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
cursor: link.remote_cursor,
};
}
createLink(tripId: number, userId: number, input: DocsyncLinkInput, scope: DocsyncScopeOption | null): DocResult<LinkRow> {
const conn = this.getConnection(input.connectionId);
if (!conn || conn.trip_id !== tripId) {
return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } };
}
const existing = this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?')
.get(tripId, input.connectionId, input.scopeKey) as LinkRow | undefined;
if (existing) return { success: true, data: existing };
// A per-link token, so revoking one binding cannot be used to poke another,
// and so a leaked webhook URL identifies exactly one trip.
const token = crypto.randomBytes(24).toString('base64url');
const secret = crypto.randomBytes(24).toString('base64url');
const info = this.db.connection
.prepare(
`INSERT INTO trip_document_links
(trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path,
remote_label, direction, delete_policy, conflict_policy, sync_enabled,
webhook_token, webhook_secret, created_by, next_attempt_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
)
.run(
tripId,
input.connectionId,
conn.provider_id,
input.scopeKey,
scope?.remoteRootId ?? input.remoteRootId ?? null,
scope?.remoteRootPath ?? input.remoteRootPath ?? null,
scope?.label ?? input.remoteLabel ?? '',
input.direction,
input.deletePolicy,
input.conflictPolicy,
input.syncEnabled ? 1 : 0,
token,
String(encryptSecrets({ webhook: secret })),
userId,
);
return { success: true, data: this.getLink(Number(info.lastInsertRowid)) as LinkRow };
}
updateLink(id: number, patch: Partial<DocsyncLinkInput>): LinkRow | undefined {
const sets: string[] = [];
const values: unknown[] = [];
if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); }
if (patch.deletePolicy !== undefined) { sets.push('delete_policy = ?'); values.push(patch.deletePolicy); }
if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); }
if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); }
if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); }
if (sets.length === 0) return this.getLink(id);
sets.push('updated_at = CURRENT_TIMESTAMP');
this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
return this.getLink(id);
}
/**
* Unbinding keeps both copies and drops the pairing rows. It never deletes a
* document on either side — a user who wants that does it deliberately, in
* the system that holds the file.
*/
deleteLink(id: number): void {
this.db.transaction(() => {
this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id);
this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id);
});
}
webhookSecret(link: LinkRow): string {
return decryptSecrets(link.webhook_secret).webhook ?? '';
}
/** The masked view a client gets, with the webhook URL it may need to paste. */
publicLink(link: LinkRow, webhookBaseUrl: string | null): Record<string, unknown> {
return {
id: link.id,
tripId: link.trip_id,
connectionId: link.connection_id,
providerId: link.provider_id,
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
remoteLabel: link.remote_label,
direction: link.direction,
deletePolicy: link.delete_policy,
conflictPolicy: link.conflict_policy,
syncEnabled: link.sync_enabled === 1,
lastSyncAt: link.last_sync_at,
lastSyncState: link.last_sync_state,
lastSyncError: link.last_sync_error,
failureCount: link.failure_count,
// Shown so a user can paste it into a provider that will not let TREK
// subscribe on its own (Papra, and Nextcloud without admin rights).
webhookUrl: webhookBaseUrl && link.webhook_token
? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}`
: null,
webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null,
};
}
/**
* Bindings whose credential owner is no longer on the trip.
*
* Checked on every run rather than hooked to member removal, because a
* membership can also disappear through a trip transfer or a direct DB edit,
* and a binding that keeps using an ex-member's token is the kind of thing
* nobody notices until it is a complaint.
*/
markOrphanedLinks(): number {
const info = this.db.connection
.prepare(
`UPDATE trip_document_links
SET last_sync_state = 'orphaned', sync_enabled = 0, updated_at = CURRENT_TIMESTAMP
WHERE sync_enabled = 1
AND last_sync_state != 'orphaned'
AND connection_id IN (
SELECT c.id FROM document_connections c
WHERE c.owner_user_id NOT IN (
SELECT tm.user_id FROM trip_members tm WHERE tm.trip_id = c.trip_id
UNION SELECT t.user_id FROM trips t WHERE t.id = c.trip_id
)
)`,
)
.run();
return info.changes;
}
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
@@ -0,0 +1,75 @@
import { DOCSYNC_SECRET_MASK } from '@trek/shared';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
* Credential handling for document connections.
*
* All secret fields of a connection live in ONE encrypted JSON column rather
* than a column per provider. A sixth provider then needs no migration, and the
* key rotation in scripts/migrate-encryption.ts stays a single line instead of
* a field list somebody will extend and forget — a forgotten column does not
* survive a rotation, and the failure only shows up months later as a
* connection that cannot authenticate any more.
*/
export type SecretMap = Record<string, string>;
/** Encrypt the whole secret map for storage. Empty maps store as NULL. */
export function encryptSecrets(secrets: SecretMap): string | null {
const entries = Object.entries(secrets).filter(([, v]) => typeof v === 'string' && v.length > 0);
if (entries.length === 0) return null;
return String(maybe_encrypt_api_key(JSON.stringify(Object.fromEntries(entries))));
}
/**
* Decrypt for use. A value that cannot be decrypted returns an empty map rather
* than throwing: the caller then reports `unauthorized` and the user re-enters
* the credential, which is the recoverable outcome. That is the same choice
* synology.service.ts makes for a stale SID — a decryption failure after an
* ENCRYPTION_KEY rotation must not turn into a boot-time crash.
*/
export function decryptSecrets(stored: string | null | undefined): SecretMap {
if (!stored) return {};
const plain = decrypt_api_key(stored);
if (typeof plain !== 'string' || plain.length === 0) return {};
try {
const parsed: unknown = JSON.parse(plain);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const out: SecretMap = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
return {};
}
}
/**
* Merge submitted form values over the stored ones.
*
* A secret field that arrives blank, absent, or as the mask keeps its stored
* value. That rule is what lets the client render a connection form without
* ever holding the secret it was given back — the same contract the photo
* provider form and Dawarich already use, so the generic component needs no
* special case here.
*/
export function mergeSecrets(stored: SecretMap, submitted: Record<string, string>, secretKeys: readonly string[]): SecretMap {
const next: SecretMap = { ...stored };
for (const key of secretKeys) {
const value = submitted[key];
if (value === undefined) continue;
if (value === '' || value === DOCSYNC_SECRET_MASK) continue;
next[key] = value;
}
return next;
}
/** What a client is allowed to see: whether a secret is set, never its value. */
export function maskSecrets(secrets: SecretMap, secretKeys: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const key of secretKeys) {
if (secrets[key]) out[key] = DOCSYNC_SECRET_MASK;
}
return out;
}
@@ -0,0 +1,84 @@
import { Controller, HttpCode, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
/**
* `/api/docsync/webhook/:token` — the one endpoint a provider calls.
*
* Deliberately the thinnest thing in the domain. It answers 200 and schedules a
* run; it never reads the body as truth. Every provider here has a different
* payload, none of them signs it in a way all five share, Paperless gives its
* webhook five seconds before it retries, and Nextcloud's payload carries a
* path that is different for every member of a share. Treating any of that as
* data would mean trusting an unauthenticated stranger's description of what
* changed. So the webhook means exactly one thing: look now.
*
* `@Public` because a provider cannot hold a TREK session. The token in the URL
* is the authentication, one per binding, so a leaked URL can only ever nudge
* the one trip it belongs to — and nudging is all it can do. Where the provider
* supports it, a shared secret is checked as well.
*/
@Controller('api/docsync/webhook')
export class DocSyncWebhookController {
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
) {}
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
const secret = this.config.webhookSecret(link);
if (secret && !this.secretMatches(req, secret)) return { received: true };
// Fire and forget. Paperless allows five seconds before it counts the call
// as failed and retries, and a sync run takes longer than that whenever
// there is anything to do.
void this.sync.syncLink(link);
return { received: true };
}
/**
* Accept either a plain shared-secret header (Paperless workflows, Nextcloud
* `authMethod: header`) or Papra's standard-webhooks HMAC. Compared in
* constant time, and a mismatch is silently ignored rather than reported, so
* the endpoint tells a prober nothing either way.
*/
private secretMatches(req: Request, secret: string): boolean {
const header = req.get('x-trek-docsync-secret');
if (header && timingSafeEqualStr(header, secret)) return true;
const sig = req.get('webhook-signature');
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
if (sig && id && ts) {
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
const body = raw ? raw.toString('utf8') : JSON.stringify(req.body ?? {});
const expected = crypto
.createHmac('sha256', Buffer.from(secret))
.update(`${id}.${ts}.${body}`)
.digest('base64');
for (const part of sig.split(' ')) {
const value = part.startsWith('v1,') ? part.slice(3) : part;
if (timingSafeEqualStr(value, expected)) return true;
}
}
return false;
}
}
function timingSafeEqualStr(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
@@ -0,0 +1,80 @@
/**
* Tuning constants for the document sync core.
*
* Kept out of the service so the pure planner can be unit-tested against them
* without a container, and so a reviewer can see every limit in one place
* instead of hunting for magic numbers.
*/
/** Per-request ceiling for provider calls that return metadata. */
export const PROVIDER_TIMEOUT_MS = 15_000;
/** Transfers get longer, because a 40 MB PDF over a home upstream is not fast. */
export const PROVIDER_TRANSFER_TIMEOUT_MS = 120_000;
/** Metadata responses are JSON; nothing legitimate here is megabytes. */
export const PROVIDER_JSON_MAX_BYTES = 8 * 1024 * 1024;
/**
* Page cap per listing run, mirroring the reason dawarich.client.ts states: a
* provider that keeps answering `next` must not hold a worker forever. Hitting
* it sets `truncated`, which the core surfaces rather than silently treating a
* partial listing as the whole truth.
*/
export const PROVIDER_MAX_PAGES = 40;
/**
* The share of a link's known documents that may disappear in one run before
* the run refuses to act on any of it.
*
* An unmounted Synology share, a revoked Nextcloud share and an expired
* Paperless token all produce the same thing: a listing far shorter than the
* last one. Without this guard the reconciler would read that as a mass
* deletion. Below the threshold, vanished documents are recorded as
* `remote_missing` and wait for a human either way — this only decides whether
* the run trusts the listing enough to record anything at all.
*/
export const MASS_DELETE_RATIO = 0.4;
/** Below this count the ratio is meaningless, so the guard only counts. */
export const MASS_DELETE_MIN_ITEMS = 5;
/**
* Backoff for a failing link, in seconds, indexed by consecutive failures.
* Flat 30 minutes after the sixth, because a provider that has been down for
* three hours is a configuration problem, not a blip, and hammering it earns a
* DSM auto-block.
*/
export const LINK_BACKOFF_SECONDS = [60, 300, 900, 1800, 3600, 7200] as const;
/** Per-document retry curve, shorter: usually a transient upload failure. */
export const ITEM_BACKOFF_SECONDS = [30, 120, 600, 3600] as const;
/** After this many attempts a document stops retrying and waits for a human. */
export const ITEM_MAX_ATTEMPTS = 6;
/**
* Consecutive link failures after which the circuit opens and the job stops
* scheduling it at all until someone presses "sync now".
*/
export const LINK_CIRCUIT_OPEN_AFTER = 10;
/** Default poll interval. Clamped, overridable per instance in app_settings. */
export const DEFAULT_POLL_INTERVAL_SECONDS = 300;
export const MIN_POLL_INTERVAL_SECONDS = 60;
export const MAX_POLL_INTERVAL_SECONDS = 3600;
/**
* How long a webhook may accelerate a link before the poll takes over again.
* A webhook only ever means "look now" — this keeps a link whose subscription
* silently died from going stale forever.
*/
export const WEBHOOK_NUDGE_DEBOUNCE_SECONDS = 5;
/** Documents pulled or pushed per run, so one huge trip cannot starve others. */
export const MAX_TRANSFERS_PER_RUN = 25;
/** app_settings keys, read per tick so an admin toggle needs no restart. */
export const SETTING_POLL_INTERVAL = 'docsync_poll_interval_seconds';
export const SETTING_SYNC_ENABLED = 'docsync_sync_enabled';
export const SETTING_MAX_FILE_BYTES = 'docsync_max_file_bytes';
@@ -0,0 +1,376 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { DatabaseService } from '../database/database.service';
import { docFailed } from './document-provider';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import { DocumentProviderRegistry } from './document-provider.registry';
import {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
DocsyncLinkDto,
DocsyncLinkUpdateDto,
DocsyncResolveConflictDto,
DocsyncScopeCreateDto,
DocsyncSyncNowDto,
} from './doc-sync.dto';
/**
* `/api/trips/:tripId/docsync` — a trip's document provider binding.
*
* Trip-scoped rather than user-scoped, and that is the whole point of the
* feature: one connection per trip, which every member reads through. The
* routes therefore sit under the trip, not under `/api/integrations`.
*
* Two shapes of answer, the split Dawarich already established: connection
* routes (`test`, `status`) answer 200 even when the provider is unreachable,
* with the reason in the body, because a form showing someone their own typo
* needs a field to render rather than an exception. Everything else answers the
* status the failure deserves.
*
* Only the trip owner may change a binding. It hands TREK a credential that
* usually reaches the owner's entire document archive, so widening this to
* every member would let any member point the trip at a folder the owner never
* meant to share. Reading the status is open to all members — they need to know
* where their documents are going.
*/
@Controller('api/trips/:tripId/docsync')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.DOCUMENTS, 'Documents')
export class DocSyncController {
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly registry: DocumentProviderRegistry,
private readonly db: DatabaseService,
) {}
/**
* Trip ownership, checked here rather than through @RequirePermission: the
* permission table has no "manage integrations" action and inventing one
* would grant it to every trip_member by default, which is the opposite of
* what a credential this broad needs.
*/
private assertOwner(tripId: string, user: User): void {
if (user.role === 'admin') return;
const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) throw new HttpException('Trip not found', 404);
if (Number(trip.user_id) !== Number(user.id)) {
throw new HttpException('Only the trip owner can change document sync', 403);
}
}
// ── Providers and status ───────────────────────────────────────────────────
/** Which providers this instance has switched on, with their form fields. */
@Get('providers')
providers() {
const rows = this.db.connection
.prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
return rows.map((p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
}));
}
@Get('status')
status(@Param('tripId') tripId: string) {
return this.sync.status(Number(tripId));
}
// ── Connections ────────────────────────────────────────────────────────────
@Get('connections')
listConnections(@Param('tripId') tripId: string) {
return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c));
}
@Put('connections')
@HttpCode(200)
async upsertConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionDto,
) {
this.assertOwner(tripId, user);
if (!this.config.enabledProviderIds().includes(body.providerId)) {
throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400);
}
const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
return this.config.publicConnection(res.data);
}
/**
* Probe form values. Always 200 — the result is in the body, because this is
* a form field, not a failure of the request. The photo providers pin the
* same contract with a test their e2e suite marks CRITICAL.
*/
@Post('connections/test')
@HttpCode(200)
async testConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionTestDto,
) {
this.assertOwner(tripId, user);
const provider = this.registry.get(body.providerId);
if (!provider) return { connected: false, error: 'unknown_provider' };
const urlCheck = await this.config.validateBaseUrl(body.baseUrl);
if (docFailed(urlCheck)) return { connected: false, error: urlCheck.error.code };
// Probing with the values on screen, merged over whatever is stored, so a
// user testing an unchanged connection does not have to retype the secret.
const existing = this.config
.listConnections(Number(tripId))
.find((c) => c.provider_id === body.providerId);
const stored = existing ? this.config.toRef(existing) : null;
const fields = this.config.providerFields(body.providerId);
const secrets: Record<string, string> = { ...(stored?.secrets ?? {}) };
const settings: Record<string, string> = { ...(stored?.settings ?? {}) };
for (const f of fields) {
const submitted = body.credentials[f.field_key];
if (submitted === undefined || submitted === '') continue;
if (f.secret === 1) secrets[f.field_key] = submitted;
else settings[f.field_key] = submitted;
}
const res = await provider.probe({
connectionId: existing?.id ?? 0,
ownerId: Number(user.id),
baseUrl: urlCheck.data.url,
secrets,
settings,
allowInsecureTls: body.allowInsecureTls,
});
if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail };
if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
return { connected: true, account: res.data.account, capabilities: res.data.capabilities };
}
@Delete('connections/:connectionId')
@HttpCode(200)
deleteConnection(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
) {
this.assertOwner(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
this.config.deleteConnection(conn.id);
return { success: true };
}
// ── Scopes ─────────────────────────────────────────────────────────────────
/** Folders, tags or spaces the trip could be bound to. */
@Get('connections/:connectionId/scopes')
async listScopes(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Query('q') q?: string,
) {
this.assertOwner(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.listScopes(this.config.toRef(conn), q);
if (docFailed(res)) return { scopes: [], error: res.error.code };
return { scopes: res.data };
}
@Post('connections/:connectionId/scopes')
async createScope(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Body() body: DocsyncScopeCreateDto,
) {
this.assertOwner(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.createScope(this.config.toRef(conn), body.name);
if (docFailed(res)) throw new HttpException(res.error.code, 400);
return res.data;
}
// ── Bindings ───────────────────────────────────────────────────────────────
@Get('links')
listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
const base = publicOrigin(req);
return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base));
}
@Post('links')
@HttpCode(200)
async createLink(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkDto,
@Req() req: Request,
) {
this.assertOwner(tripId, user);
const res = this.config.createLink(Number(tripId), Number(user.id), body, null);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
// Subscribe where the provider lets TREK do it itself. A failure here is
// not a failure of the binding — polling still carries it — so it is logged
// into the link state rather than thrown at the user.
const conn = this.config.getConnection(res.data.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.registerWebhook) {
const base = publicOrigin(req);
if (base) {
const hook = await provider.registerWebhook(
this.config.toRef(conn),
this.config.toScopeRef(res.data),
`${base}/api/docsync/webhook/${res.data.webhook_token}`,
this.config.webhookSecret(res.data),
);
if (hook.success) {
this.db.connection
.prepare('UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?')
.run(hook.data.subscriptionId, res.data.id);
}
}
}
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req));
}
@Patch('links/:linkId')
@HttpCode(200)
updateLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkUpdateDto,
@Req() req: Request,
) {
this.assertOwner(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const updated = this.config.updateLink(link.id, body);
return this.config.publicLink(updated ?? link, publicOrigin(req));
}
@Delete('links/:linkId')
@HttpCode(200)
async deleteLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
) {
this.assertOwner(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const conn = this.config.getConnection(link.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) {
await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id);
}
this.config.deleteLink(link.id);
// Unbinding keeps both copies. Nothing is deleted anywhere.
return { success: true, documentsKept: true };
}
@Post('links/:linkId/sync')
@HttpCode(200)
async syncNow(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Body() body: DocsyncSyncNowDto,
) {
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
return this.sync.syncLink(link, { full: body.full });
}
// ── Documents and conflicts ────────────────────────────────────────────────
@Get('items')
items(@Param('tripId') tripId: string, @Query('state') state?: string) {
const rows = state
? this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId), state)
: this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId));
return rows;
}
@Post('items/:itemId/resolve')
@HttpCode(200)
async resolve(
@Param('tripId') tripId: string,
@Param('itemId') itemId: string,
@CurrentUser() user: User,
@Body() body: DocsyncResolveConflictDto,
) {
this.assertOwner(tripId, user);
const ok = await this.sync.resolveConflict(Number(itemId), body.keep);
if (!ok) throw new HttpException('Item is not in conflict', 400);
return { success: true };
}
}
/**
* The origin a provider has to call back on.
*
* Derived from the request rather than from configuration because a self-hosted
* TREK is reached under whatever name its operator chose, and asking them to
* maintain a second copy of it would get it wrong. Returns null behind a proxy
* that strips the host, in which case the webhook is simply not offered and
* polling carries the binding.
*/
function publicOrigin(req: Request): string | null {
const host = req.get('x-forwarded-host') || req.get('host');
if (!host) return null;
const proto = (req.get('x-forwarded-proto') || req.protocol || 'http').split(',')[0].trim();
return `${proto}://${host}`;
}
+24
View File
@@ -0,0 +1,24 @@
import { createZodDto } from 'nestjs-zod';
import {
docsyncConnectionInputSchema,
docsyncConnectionTestSchema,
docsyncLinkInputSchema,
docsyncLinkUpdateSchema,
docsyncResolveConflictSchema,
docsyncScopeCreateSchema,
docsyncSyncNowSchema,
} from '@trek/shared';
/**
* Zod DTOs for every mutating route in this domain.
*
* The boot-time ratchet in common/validate-body-contracts.ts refuses to start
* if a mutation takes an unwrapped body, so these are not optional decoration.
*/
export class DocsyncConnectionDto extends createZodDto(docsyncConnectionInputSchema) {}
export class DocsyncConnectionTestDto extends createZodDto(docsyncConnectionTestSchema) {}
export class DocsyncLinkDto extends createZodDto(docsyncLinkInputSchema) {}
export class DocsyncLinkUpdateDto extends createZodDto(docsyncLinkUpdateSchema) {}
export class DocsyncScopeCreateDto extends createZodDto(docsyncScopeCreateSchema) {}
export class DocsyncResolveConflictDto extends createZodDto(docsyncResolveConflictSchema) {}
export class DocsyncSyncNowDto extends createZodDto(docsyncSyncNowSchema) {}
@@ -0,0 +1,321 @@
import crypto from 'crypto';
import path from 'path';
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
import { MASS_DELETE_MIN_ITEMS, MASS_DELETE_RATIO } from './doc-sync.constants';
import type { RemoteDocument } from './document-provider';
/**
* The pure half of the sync core.
*
* `planReconcile` takes a snapshot of both sides and returns the actions to
* take. It touches no database, no HTTP and no container, which is what lets
* the hard cases — conflict, echo, mass deletion, rename, a document that
* exists on neither side any more — be tested exhaustively as plain data. The
* service does nothing but execute the plan.
*/
// ── Identity and fingerprints ────────────────────────────────────────────────
/**
* A stable fingerprint over an explicit field order.
*
* The order is written out rather than derived from the object for the reason
* dawarich.helpers.ts gives: a hash over `Object.keys` changes when someone
* adds a field or a provider reorders its JSON, and a changed hash means
* "changed upstream" to every later run. Explicit means a reviewer can see what
* participates.
*/
export function snapshotHash(parts: readonly (string | number | null | undefined)[]): string {
return crypto.createHash('sha256').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex');
}
/** Per-document anchor, written into provider metadata where there is room. */
export function newTrekDocUid(): string {
return crypto.randomUUID();
}
/**
* Names arriving from a provider are hostile input: they can carry path
* separators, control characters, leading dots and NTFS-illegal characters, and
* they end up both in a DB column and in a Content-Disposition header.
*
* The extension is preserved deliberately — it is what TREK's download route
* derives the Content-Type from, and what the blocklist check reads.
*/
export function sanitizeIncomingName(raw: string): string {
// Split on separators by hand rather than through path.basename: the win32
// flavour reads `a:` as a drive letter and silently drops it, so a perfectly
// ordinary `a:b.pdf` from a Linux provider would arrive as `b.pdf`. Only
// slashes are directory separators as far as this is concerned; the colon is
// dealt with below, as an illegal character.
const base = (raw || '').split(/[\\/]/).pop()?.trim() ?? '';
const cleaned = base
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/^\.+/, '')
.slice(0, 200);
return cleaned || 'document';
}
/**
* The blocklist is enforced on incoming provider documents for exactly the same
* reason it is enforced on uploads: TREK serves downloads inline with an
* extension-derived Content-Type, so an .svg or .html from a Nextcloud folder
* would be a stored XSS. A rejected document becomes a visible `rejected_type`
* row rather than a silent skip, so the person who put it there can find out.
*/
export function isBlockedName(name: string): boolean {
const ext = path.extname(name).toLowerCase();
return !!ext && BLOCKED_EXTENSIONS.includes(ext);
}
/**
* The operator's own allowlist, applied to incoming provider documents.
*
* Same rule as an upload: `*` admits anything the blocklist above still lets
* through, and an empty extension is refused rather than waved past, because a
* file with no extension is served with a Content-Type TREK had to guess.
*/
export function isAllowedByOperator(name: string, allowedCsv: string): boolean {
const ext = path.extname(name).toLowerCase().replace(/^\./, '');
if (!ext) return false;
if (allowedCsv.trim() === '*') return true;
return allowedCsv
.split(',')
.map((e) => e.trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean)
.includes(ext);
}
// ── The plan ─────────────────────────────────────────────────────────────────
/** What the core knows about one pairing before the run. */
export interface SyncItemState {
id: number;
fileId: number | null;
trekDocUid: string;
remoteId: string | null;
remoteVersion: string | null;
/** The name the provider had at the last agreed state — the rename arbiter. */
remoteName: string | null;
/** The bytes both sides last agreed on. */
contentSha256: string | null;
/** What TREK itself last uploaded — the echo guard. */
pushedSha256: string | null;
state: string;
attempts: number;
remoteMissingAt: string | null;
}
/** What TREK currently holds for this trip. */
export interface LocalDocument {
fileId: number;
name: string;
size: number;
mimeType: string | null;
sha256: string | null;
deletedAt: string | null;
}
export type PlanAction =
/** Bytes only exist upstream: download and create a trip_files row. */
| { kind: 'pull'; remote: RemoteDocument; itemId: number | null }
/** Bytes only exist in TREK: upload. */
| { kind: 'push'; local: LocalDocument; itemId: number | null; remoteId: string | null }
/** Upstream moved on while TREK did not: replace local bytes. */
| { kind: 'pull_update'; remote: RemoteDocument; itemId: number }
/** TREK moved on while upstream did not: push a new revision. */
| { kind: 'push_update'; local: LocalDocument; itemId: number; remoteId: string }
/** A name changed on one side only. */
| { kind: 'rename_remote'; itemId: number; remoteId: string; name: string }
| { kind: 'rename_local'; itemId: number; fileId: number; name: string }
/** Both sides changed since the agreed state. */
| { kind: 'conflict'; itemId: number; remote: RemoteDocument | null; local: LocalDocument | null }
/** Present upstream last run, absent now. Recorded, never acted on. */
| { kind: 'mark_remote_missing'; itemId: number }
/** Deleted in TREK; what happens upstream is the link's delete policy. */
| { kind: 'local_deleted'; itemId: number; remoteId: string }
/** Nothing to do but the row should stop looking stale. */
| { kind: 'touch'; itemId: number; remote: RemoteDocument | null };
export interface ReconcilePlan {
actions: PlanAction[];
/** True when the listing looked like a mass deletion and was not trusted. */
massDeleteGuardTripped: boolean;
/** Vanished upstream but below the guard threshold. */
missingCount: number;
}
export interface ReconcileInput {
items: readonly SyncItemState[];
remote: readonly RemoteDocument[];
local: readonly LocalDocument[];
direction: 'both' | 'pull' | 'push';
/** A truncated listing must never be read as "the rest was deleted". */
remoteTruncated: boolean;
/** Providers without stable ids need the rename heuristic. */
stableRemoteIds: boolean;
}
/**
* Decide what has to happen, from a snapshot of both sides.
*
* The three-way comparison is the whole point: `contentSha256` is the state
* both sides last agreed on, so "changed here" and "changed there" are separate
* questions and a change on one side alone is never a conflict. Collapsing that
* into a two-way comparison is what makes naive sync engines either lose edits
* or ping-pong forever.
*/
export function planReconcile(input: ReconcileInput): ReconcilePlan {
const { items, remote, local, direction, remoteTruncated, stableRemoteIds } = input;
const actions: PlanAction[] = [];
const remoteById = new Map<string, RemoteDocument>();
for (const r of remote) if (!r.isDeleted) remoteById.set(r.remoteId, r);
const localById = new Map<number, LocalDocument>();
for (const l of local) localById.set(l.fileId, l);
const itemsByRemote = new Map<string, SyncItemState>();
const itemsByFile = new Map<number, SyncItemState>();
for (const it of items) {
if (it.remoteId) itemsByRemote.set(it.remoteId, it);
if (it.fileId !== null) itemsByFile.set(it.fileId, it);
}
// A listing that lost most of what it had last time is far more likely to be
// a broken mount, a revoked token or a moved folder than a real mass delete.
const known = items.filter((i) => i.remoteId && i.state === 'synced');
const vanished = known.filter((i) => !remoteById.has(i.remoteId as string));
const guardTripped =
!remoteTruncated &&
known.length >= MASS_DELETE_MIN_ITEMS &&
vanished.length / known.length > MASS_DELETE_RATIO;
// ── Pairs that exist on both sides ────────────────────────────────────────
for (const it of items) {
if (!it.remoteId) continue;
const r = remoteById.get(it.remoteId);
if (!r) continue;
const l = it.fileId !== null ? localById.get(it.fileId) : undefined;
const remoteChanged = it.remoteVersion !== null && r.remoteVersion !== it.remoteVersion;
// The echo guard: bytes TREK itself pushed come back as a change on the
// provider side. They are only TREK's own write if the hash still matches
// what was pushed, so this compares content, never timestamps — a time
// window would misfire on every clock skew.
const isEcho =
remoteChanged &&
r.contentHash !== null &&
it.pushedSha256 !== null &&
r.contentHash === it.pushedSha256;
const localChanged = !!l && l.sha256 !== null && it.contentSha256 !== null && l.sha256 !== it.contentSha256;
if (l?.deletedAt) {
actions.push({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId });
continue;
}
if (remoteChanged && !isEcho && localChanged) {
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l ?? null });
continue;
}
if (remoteChanged && !isEcho) {
if (direction === 'push') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'pull_update', remote: r, itemId: it.id });
continue;
}
if (localChanged && l) {
if (direction === 'pull') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'push_update', local: l, itemId: it.id, remoteId: it.remoteId });
continue;
}
// Same bytes, different name. Which side renamed is decided by comparing
// each against the name they last agreed on — not by direction. Reading
// `both` as "TREK always wins" silently renamed the provider's copy back
// every time somebody tidied up a folder, which is the opposite of a
// two-way sync.
if (l && r.name !== l.name) {
const providerRenamed = it.remoteName !== null && r.name !== it.remoteName;
const localRenamed = it.remoteName !== null && l.name !== it.remoteName;
if (providerRenamed && !localRenamed && direction !== 'push') {
actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
continue;
}
if (localRenamed && !providerRenamed && direction !== 'pull') {
actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
continue;
}
if (providerRenamed && localRenamed) {
// Both sides renamed. Nothing here can pick the right one, and picking
// wrong loses a name somebody chose, so it goes to a person.
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l });
continue;
}
// No stored name to arbitrate with (a row from before this column, or a
// freshly paired document): follow the binding's direction.
if (direction === 'pull') actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
else if (direction === 'push') actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
else actions.push({ kind: 'touch', itemId: it.id, remote: r });
continue;
}
actions.push({ kind: 'touch', itemId: it.id, remote: r });
}
// ── Upstream only ─────────────────────────────────────────────────────────
if (direction !== 'push') {
for (const r of remote) {
if (r.isDeleted) continue;
const existing = itemsByRemote.get(r.remoteId);
if (existing) continue;
// Without stable ids a rename reads as "old one gone, new one appeared".
// Re-pair on content instead of downloading the same bytes again and
// leaving a phantom behind.
if (!stableRemoteIds && r.contentHash) {
const reuse = items.find(
(i) => i.contentSha256 === r.contentHash && i.remoteId !== null && !remoteById.has(i.remoteId),
);
if (reuse) {
actions.push({ kind: 'touch', itemId: reuse.id, remote: r });
continue;
}
}
actions.push({ kind: 'pull', remote: r, itemId: null });
}
}
// ── TREK only ─────────────────────────────────────────────────────────────
if (direction !== 'pull') {
for (const l of local) {
if (l.deletedAt) continue;
const it = itemsByFile.get(l.fileId);
if (it?.remoteId && remoteById.has(it.remoteId)) continue;
// A row that has a remoteId which is no longer listed is handled below as
// "missing", not re-uploaded — otherwise a broken listing duplicates the
// entire trip upstream.
if (it?.remoteId) continue;
actions.push({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
}
}
// ── Vanished upstream ─────────────────────────────────────────────────────
if (!guardTripped && !remoteTruncated) {
for (const it of vanished) {
if (it.remoteMissingAt) continue;
actions.push({ kind: 'mark_remote_missing', itemId: it.id });
}
}
return { actions, massDeleteGuardTripped: guardTripped, missingCount: vanished.length };
}
/** Backoff lookup that saturates instead of running off the end of the curve. */
export function backoffSeconds(curve: readonly number[], failures: number): number {
if (failures <= 0) return curve[0];
return curve[Math.min(failures - 1, curve.length - 1)];
}
+86
View File
@@ -0,0 +1,86 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { logError, logInfo } from '../audit/audit-log.logger';
import { ADDON_IDS } from '../../addons';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import {
DEFAULT_POLL_INTERVAL_SECONDS,
MAX_POLL_INTERVAL_SECONDS,
MIN_POLL_INTERVAL_SECONDS,
SETTING_POLL_INTERVAL,
SETTING_SYNC_ENABLED,
} from './doc-sync.constants';
/**
* The poll that carries document sync.
*
* It is the mechanism, not the fallback. Only Paperless lets TREK subscribe to
* changes on its own; Papra's webhook API is closed to API keys, Nextcloud's
* needs admin rights, OpenCloud has no registrable hook, and Synology has
* nothing at all. On top of that no provider here emits an event for every
* change that matters — Paperless has no deletion trigger, and a tag change in
* Papra moves no timestamp. So the run enumerates, and a webhook only ever
* makes it happen sooner.
*
* The addon gate and the interval are re-read per tick rather than at bootstrap
* so an admin toggling either takes effect without a restart. That differs from
* the Dawarich job on purpose: this one is driven by a per-trip binding an
* ordinary user creates, and telling them "restart your server" is not an
* answer.
*/
@Injectable()
export class DocSyncJob implements OnApplicationBootstrap {
constructor(
private readonly db: DatabaseService,
private readonly sync: DocSyncService,
private readonly config: DocSyncConfigService,
private readonly addons: AddonsService,
private readonly registrar: CronRegistrarService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
const seconds = this.intervalSeconds();
const minutes = Math.max(1, Math.round(seconds / 60));
logInfo(`Document sync: scheduled every ${minutes}m`);
this.registrar.register('docsync', `*/${minutes} * * * *`, () => this.tick());
}
private intervalSeconds(): number {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL)?.value;
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_SECONDS;
return Math.min(MAX_POLL_INTERVAL_SECONDS, Math.max(MIN_POLL_INTERVAL_SECONDS, parsed));
}
async tick(): Promise<void> {
try {
if (!this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS)) return;
const killSwitch = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED)?.value;
// Unrecognised values mean ON here because the setting is absent by
// default; only an explicit 'false' stops the sync.
if (killSwitch === 'false') return;
// Cheap, and it catches a binding whose owner left the trip through a
// path that has no hook to attach to — a transfer, a direct DB edit.
const orphaned = this.config.markOrphanedLinks();
if (orphaned > 0) logInfo(`Document sync: ${orphaned} link(s) orphaned, owner no longer on the trip`);
const links = this.sync.dueLinks();
for (const link of links) {
// One failing provider must not stop the others: an unreachable NAS on
// one trip is not a reason to skip a Paperless binding on another.
try {
await this.sync.syncLink(link);
} catch (err: unknown) {
logError(`Document sync: link ${link.id} failed: ${err instanceof Error ? err.message : err}`);
}
}
} catch (err: unknown) {
logError(`Document sync tick failed: ${err instanceof Error ? err.message : err}`);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { z } from 'zod';
import {
McpController,
Tool,
type McpContext,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
errorResult,
ok,
} from '../../nest-mcp';
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { FilesService } from '../files/files.service';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
const documentsAddonOn = addonGate(ADDON_IDS.DOCUMENTS);
/**
* The MCP half of document sync, mirroring /api/trips/:tripId/docsync.
*
* Read and trigger only. There is no tool that creates a connection, and that
* is deliberate rather than unfinished: setting one up means handing over a
* credential that usually reaches a person's entire document archive, and an
* assistant is the wrong place to collect one. The same reason the REST side
* restricts those routes to the trip owner applies twice over here.
*
* `sync_trip_documents` is a write because it moves files in both directions,
* even though it creates nothing itself.
*/
@McpController()
export class DocSyncMcp {
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly files: FilesService,
readonly addons: AddonsService,
) {}
@Tool({
name: 'get_trip_document_sync',
description:
'Show whether this trip\'s documents are synced with an external document store (Paperless-ngx, Papra, Nextcloud, OpenCloud or a Synology NAS), which folder or tag they are bound to, when the last run happened, and how many documents are waiting, in conflict or missing at the provider. Use this before telling someone where their documents live.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'files', mode: 'read' },
when: documentsAddonOn,
})
getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
return ok(this.sync.status(tripId));
}
@Tool({
name: 'list_trip_document_sync_issues',
description:
'List the documents that need a person to look at them: conflicts where both copies changed, documents the provider refused because of their type or size, and documents that disappeared at the provider. Returns an empty list when everything is in step.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'files', mode: 'read' },
when: documentsAddonOn,
})
listIssues({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const links = this.config.listLinks(tripId);
if (links.length === 0) return ok({ configured: false, issues: [] });
return ok({ configured: true, issues: this.sync.issues(tripId) });
}
@Tool({
name: 'sync_trip_documents',
description:
'Run the document sync for this trip now instead of waiting for the next scheduled check. Reports how many documents were pulled in from the provider, pushed out to it, and how many are in conflict. Safe to call repeatedly: a run that is already in progress is not started twice.',
inputSchema: {
tripId: z.number().int().positive(),
full: z
.boolean()
.optional()
.default(false)
.describe('Ignore the change marker from last time and compare both sides in full'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'files', mode: 'write' },
when: documentsAddonOn,
})
async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) {
if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const links = this.config.listLinks(tripId);
if (links.length === 0) {
return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.');
}
const results = [];
for (const link of links) {
results.push({ linkId: link.id, provider: link.provider_id, ...(await this.sync.syncLink(link, { full: full === true })) });
}
return ok({ runs: results });
}
}
@@ -0,0 +1,95 @@
import { Module } from '@nestjs/common';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { DatabaseModule } from '../database/database.module';
import { FilesModule } from '../files/files.module';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncController } from './doc-sync.controller';
import { DocSyncWebhookController } from './doc-sync-webhook.controller';
import { DocSyncJob } from './doc-sync.job';
import { DocSyncMcp } from './doc-sync.mcp';
import { DocSyncService } from './doc-sync.service';
import { DOCUMENT_PROVIDERS, type DocumentProvider } from './document-provider';
import { DocumentProviderRegistry } from './document-provider.registry';
import { PaperlessDocumentProvider } from './providers/paperless.provider';
import { PapraProvider } from './providers/papra.provider';
import { SynologyDriveProvider } from './providers/synology-drive.provider';
import { NextcloudDocumentProvider, OpencloudDocumentProvider } from './providers/webdav.provider';
import { PaperlessClient } from './providers/paperless.client';
import { PapraClient } from './providers/papra.client';
import { SynologyDriveClient } from './providers/synology-drive.client';
import { WebdavClient } from './providers/webdav.client';
/**
* Document sync: one provider connection per trip, both directions.
*
* Its own domain rather than an extension of `files/`, because that domain's
* coverage ratchet sits at 97/95 and would now be measuring four HTTP clients
* and a cron job; and not under `integrations/`, whose ratchet is 78/68 and
* would be dragged down by new code landing there. The seam is clean anyway:
* this module consumes FilesService and StorageService and nothing consumes it.
*
* Nextcloud and OpenCloud are two registrations of one WebDAV adapter. They are
* separate ids because the admin toggles, the credential fields and the scope
* concept differ (a folder with an `oc:fileid` versus a space with a
* `driveId`), but the protocol underneath is the same and so is the code.
*/
@Module({
imports: [
DatabaseModule,
StorageModule,
FilesModule,
AllowedFileTypesModule,
PermissionsModule,
RealtimeModule,
SchedulingModule,
AddonsModule,
AuthModule,
McpSharedModule,
],
controllers: [DocSyncController, DocSyncWebhookController],
providers: [
DocSyncConfigService,
DocSyncService,
DocSyncJob,
DocSyncMcp,
DocumentProviderRegistry,
// The HTTP clients are providers of their own: each is the ONLY file that
// talks to its instance, which makes it the one thing a test has to replace
// to be sure a suite never opens a socket.
PaperlessClient,
PapraClient,
SynologyDriveClient,
WebdavClient,
PaperlessDocumentProvider,
PapraProvider,
NextcloudDocumentProvider,
OpencloudDocumentProvider,
SynologyDriveProvider,
{
provide: DOCUMENT_PROVIDERS,
useFactory: (
paperless: PaperlessDocumentProvider,
papra: PapraProvider,
nextcloud: NextcloudDocumentProvider,
opencloud: OpencloudDocumentProvider,
synology: SynologyDriveProvider,
): readonly DocumentProvider[] => [paperless, papra, nextcloud, opencloud, synology],
inject: [
PaperlessDocumentProvider,
PapraProvider,
NextcloudDocumentProvider,
OpencloudDocumentProvider,
SynologyDriveProvider,
],
},
],
exports: [DocSyncConfigService, DocSyncService],
})
export class DocSyncModule {}
@@ -0,0 +1,706 @@
import { Injectable, Logger } from '@nestjs/common';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
import { Readable } from 'node:stream';
import type { DocsyncErrorCode } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { StorageService } from '../storage/storage.service';
import { FilesService } from '../files/files.service';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { RealtimeService } from '../realtime/realtime.service';
import { MAX_FILE_SIZE } from '../files/files.constants';
import { DocumentProviderRegistry } from './document-provider.registry';
import { DocSyncConfigService, type ConnectionRow, type LinkRow } from './doc-sync-config.service';
import type { DocumentProvider, RemoteDocument } from './document-provider';
import { docFailed } from './document-provider';
import {
ITEM_BACKOFF_SECONDS,
ITEM_MAX_ATTEMPTS,
LINK_BACKOFF_SECONDS,
LINK_CIRCUIT_OPEN_AFTER,
MAX_TRANSFERS_PER_RUN,
} from './doc-sync.constants';
import {
backoffSeconds,
isAllowedByOperator,
isBlockedName,
newTrekDocUid,
planReconcile,
sanitizeIncomingName,
type LocalDocument,
type PlanAction,
type SyncItemState,
} from './doc-sync.helpers';
/**
* The reconciler: it executes what `planReconcile` decided, and does nothing
* else of consequence.
*
* It enumerates both sides in full on every run rather than asking for a delta.
* That looks wasteful and is deliberate. A changed-since query cannot see a
* deletion at all; Papra does not bump `updatedAt` when a tag changes (measured,
* not assumed); Paperless's bulk edit changes documents without touching
* `modified`; and Synology FileStation has no change feed whatsoever. A webhook
* is therefore only ever "look now", never a source of truth — the same
* conclusion AirTrail and Dawarich reached for their own providers, written
* down in dawarich-sync.service.ts.
*
* Two safety rules outrank everything else here:
* 1. A document that disappears upstream is RECORDED as missing. It is never
* deleted locally as a side effect. An unmounted share answers with an
* empty listing, and a trip is not a cache.
* 2. TREK's own writes must not come back as foreign changes. That is what
* `pushed_sha256` is for, and why the comparison is over content rather
* than over timestamps.
*/
@Injectable()
export class DocSyncService {
private readonly logger = new Logger(DocSyncService.name);
/** Container singleton, so one in-flight run per link across callers. */
private readonly running = new Set<number>();
constructor(
private readonly db: DatabaseService,
private readonly config: DocSyncConfigService,
private readonly registry: DocumentProviderRegistry,
private readonly storage: StorageService,
private readonly files: FilesService,
private readonly allowedTypes: AllowedFileTypesService,
private readonly realtime: RealtimeService,
) {}
// ── Entry points ───────────────────────────────────────────────────────────
/** Links the scheduler should look at now. */
dueLinks(limit = 20): LinkRow[] {
return this.db.connection
.prepare(
`SELECT * FROM trip_document_links
WHERE sync_enabled = 1
AND failure_count < ?
AND (next_attempt_at IS NULL OR next_attempt_at <= CURRENT_TIMESTAMP)
ORDER BY COALESCE(next_attempt_at, '1970-01-01') ASC
LIMIT ?`,
)
.all(LINK_CIRCUIT_OPEN_AFTER, limit) as LinkRow[];
}
/**
* Run one link.
*
* Never throws for provider trouble: a failing link records its state, backs
* off and lets the next link run. One unreachable NAS must not stop the
* Paperless binding on another trip.
*/
async syncLink(link: LinkRow, opts: { full?: boolean } = {}): Promise<{
state: string;
pulled: number;
pushed: number;
conflicts: number;
missing: number;
errorCode?: DocsyncErrorCode;
}> {
if (this.running.has(link.id)) {
return { state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
}
this.running.add(link.id);
try {
return await this.runLink(link, opts);
} catch (err) {
this.logger.error(`link ${link.id} failed: ${err instanceof Error ? err.message : String(err)}`);
this.recordLinkFailure(link, 'unknown');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'unknown' };
} finally {
this.running.delete(link.id);
}
}
private async runLink(link: LinkRow, opts: { full?: boolean }): Promise<{
state: string; pulled: number; pushed: number; conflicts: number; missing: number; errorCode?: DocsyncErrorCode;
}> {
const conn = this.config.getConnection(link.connection_id);
if (!conn) {
this.recordLinkFailure(link, 'not_found');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'not_found' };
}
const provider = this.registry.get(link.provider_id);
if (!provider) {
this.recordLinkFailure(link, 'provider_error');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'provider_error' };
}
const ref = this.config.toRef(conn);
const scope = this.config.toScopeRef(link);
if (opts.full) scope.cursor = null;
// A binding whose folder or tag is gone needs a human, not a retry: the
// alternative is re-creating someone's deleted folder and filling it again.
const resolved = await provider.resolveScope(ref, scope);
if (docFailed(resolved)) {
const code = resolved.error.code === 'not_found' || resolved.error.code === 'scope_missing' ? 'scope_missing' : resolved.error.code;
this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined);
return { state: code === 'scope_missing' ? 'scope_lost' : 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: code };
}
const listing = await provider.list(ref, scope);
if (docFailed(listing)) {
const state = listing.error.code === 'unauthorized' ? 'needs_reauth' : 'failed';
this.recordLinkFailure(link, listing.error.code, state);
return { state, pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: listing.error.code };
}
const items = this.loadItems(link.id);
const local = this.loadLocalDocuments(link);
const plan = planReconcile({
items,
remote: listing.data.documents,
local,
direction: link.direction as 'both' | 'pull' | 'push',
remoteTruncated: listing.data.truncated,
stableRemoteIds: provider.capabilities(ref).stableId,
});
if (plan.massDeleteGuardTripped) {
// Refusing the whole run is the point: the listing is not trustworthy, so
// nothing in it should be acted on, not even the parts that look fine.
this.logger.warn(`link ${link.id}: mass-delete guard tripped (${plan.missingCount} of ${items.length} gone), run abandoned`);
this.recordLinkFailure(link, 'mass_delete_guard', 'partial');
return { state: 'partial', pulled: 0, pushed: 0, conflicts: 0, missing: plan.missingCount, errorCode: 'mass_delete_guard' };
}
let pulled = 0;
let pushed = 0;
let conflicts = 0;
let transfers = 0;
let budgetExhausted = false;
let softFailure: DocsyncErrorCode | undefined;
for (const action of plan.actions) {
if (transfers >= MAX_TRANSFERS_PER_RUN && isTransfer(action)) {
// Leaving the rest for the next run keeps one enormous trip from
// starving every other binding on the instance. Reported as `partial`
// rather than `ok`: the binding is not in step yet, and a status line
// claiming it is would be a lie a user acts on.
budgetExhausted = true;
break;
}
const outcome = await this.applyAction(action, { provider, ref, scope, link, conn });
if (outcome === 'pulled') { pulled += 1; transfers += 1; }
else if (outcome === 'pushed') { pushed += 1; transfers += 1; }
else if (outcome === 'conflict') conflicts += 1;
else if (outcome && outcome !== 'ok') softFailure = outcome;
}
const state = softFailure || budgetExhausted ? 'partial' : 'ok';
this.recordLinkSuccess(link, listing.data.cursor, state, softFailure ?? null);
if (pulled > 0 || pushed > 0) {
this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled, pushed });
}
return { state, pulled, pushed, conflicts, missing: plan.missingCount, errorCode: softFailure };
}
// ── Action execution ───────────────────────────────────────────────────────
private async applyAction(
action: PlanAction,
ctx: { provider: DocumentProvider; ref: ReturnType<DocSyncConfigService['toRef']>; scope: ReturnType<DocSyncConfigService['toScopeRef']>; link: LinkRow; conn: ConnectionRow },
): Promise<'ok' | 'pulled' | 'pushed' | 'conflict' | DocsyncErrorCode> {
switch (action.kind) {
case 'pull':
case 'pull_update':
return this.pull(action.remote, action.kind === 'pull_update' ? action.itemId : null, ctx);
case 'push':
case 'push_update':
return this.push(action.local, 'itemId' in action ? action.itemId : null, 'remoteId' in action ? action.remoteId : null, ctx);
case 'rename_remote': {
const res = await ctx.provider.rename(ctx.ref, ctx.scope, action.remoteId, action.name);
if (docFailed(res)) return res.error.code;
this.db.connection
.prepare('UPDATE document_sync_items SET remote_name = ?, remote_version = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(action.name, res.data.remoteVersion, action.itemId);
return 'ok';
}
case 'rename_local': {
this.db.connection.prepare('UPDATE trip_files SET original_name = ? WHERE id = ?').run(action.name, action.fileId);
this.realtime.broadcast(ctx.link.trip_id, 'file:updated', {
file: this.files.getFileById(action.fileId, ctx.link.trip_id),
});
this.db.connection
.prepare('UPDATE document_sync_items SET remote_name = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(action.name, action.itemId);
return 'ok';
}
case 'conflict': {
this.db.connection
.prepare("UPDATE document_sync_items SET state = 'conflict', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(action.itemId);
return 'conflict';
}
case 'mark_remote_missing': {
// Recorded, not executed. A human decides whether the local copy goes.
this.db.connection
.prepare(
`UPDATE document_sync_items
SET state = 'remote_missing', remote_missing_at = CURRENT_TIMESTAMP, last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(action.itemId);
return 'ok';
}
case 'local_deleted': {
if (ctx.link.delete_policy === 'trash') {
const res = await ctx.provider.trash(ctx.ref, ctx.scope, action.remoteId);
if (docFailed(res)) return res.error.code;
}
this.db.connection
.prepare("UPDATE document_sync_items SET state = 'local_deleted', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(action.itemId);
return 'ok';
}
case 'touch': {
// `error` is NOT cleared here. A row in that state has no bytes in TREK
// — the download failed — and marking it synced would hide a document
// that nobody will ever fetch again. Only a `remote_missing` row, whose
// file is present and whose provider copy came back, recovers this way.
this.db.connection
.prepare(
`UPDATE document_sync_items
SET last_seen_at = CURRENT_TIMESTAMP,
remote_version = COALESCE(?, remote_version),
remote_name = COALESCE(?, remote_name),
remote_id = COALESCE(?, remote_id),
remote_missing_at = NULL,
state = CASE WHEN state = 'remote_missing' AND file_id IS NOT NULL THEN 'synced' ELSE state END
WHERE id = ?`,
)
.run(action.remote?.remoteVersion ?? null, action.remote?.name ?? null, action.remote?.remoteId ?? null, action.itemId);
return 'ok';
}
default:
return 'ok';
}
}
/** Download a remote document into TREK as an ordinary trip file. */
private async pull(
remote: RemoteDocument,
itemId: number | null,
ctx: { provider: DocumentProvider; ref: ReturnType<DocSyncConfigService['toRef']>; scope: ReturnType<DocSyncConfigService['toScopeRef']>; link: LinkRow },
): Promise<'pulled' | DocsyncErrorCode> {
const name = sanitizeIncomingName(remote.name);
// The same defences an upload goes through. A provider folder routinely
// holds .svg and .html, and TREK serves downloads inline with a
// Content-Type derived from the extension — letting those through would be
// stored XSS. Rejected documents become a visible row, never a silent skip.
if (isBlockedName(name) || !isAllowedByOperator(name, this.allowedTypes.get())) {
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (remote.size !== null && remote.size > MAX_FILE_SIZE) {
this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
const fetched = await ctx.provider.fetch(ctx.ref, ctx.scope, remote.remoteId);
if (docFailed(fetched)) {
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code });
return fetched.error.code;
}
// Spool to disk first and hash while writing: the size a provider claims in
// a listing is not a promise, and the hash is needed for the echo guard
// regardless. Committing to storage only after the bytes are complete keeps
// a half-written object from ever becoming a trip_files row.
const ext = path.extname(name);
const storageKey = `${crypto.randomUUID()}${ext}`;
const spoolDir = this.storage.spoolDirFor('files');
const tmpPath = path.join(spoolDir, `${storageKey}.part`);
const hash = crypto.createHash('sha256');
let bytes = 0;
try {
const counting = new Transform({
transform(chunk: Buffer, _enc: BufferEncoding, cb: (e?: Error | null, d?: Buffer) => void) {
bytes += chunk.length;
hash.update(chunk);
if (bytes > MAX_FILE_SIZE) { cb(new Error('too_large')); return; }
cb(null, chunk);
},
});
await pipeline(fetched.data.body, counting, fs.createWriteStream(tmpPath));
} catch (err) {
await fs.promises.rm(tmpPath, { force: true });
const code: DocsyncErrorCode = err instanceof Error && err.message === 'too_large' ? 'too_large' : 'provider_error';
this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code });
return code;
}
const sha256 = hash.digest('hex');
try {
await this.storage.put('files', storageKey, { tmpPath });
} catch {
await fs.promises.rm(tmpPath, { force: true });
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
const created = this.files.createFile(
ctx.link.trip_id,
{ filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' },
// Attributed to the person whose connection brought it in, which is the
// only honest answer: nobody in TREK uploaded it.
this.config.getConnection(ctx.link.connection_id)?.owner_user_id ?? 0,
{},
);
this.upsertItem(ctx.link, {
itemId,
remote,
state: 'synced',
fileId: Number(created.id),
contentSha256: sha256,
errorCode: null,
});
this.realtime.broadcast(ctx.link.trip_id, 'file:created', { file: created });
return 'pulled';
}
/** Upload a TREK document to the provider. */
private async push(
local: LocalDocument,
itemId: number | null,
remoteId: string | null,
ctx: { provider: DocumentProvider; ref: ReturnType<DocSyncConfigService['toRef']>; scope: ReturnType<DocSyncConfigService['toScopeRef']>; link: LinkRow },
): Promise<'pushed' | DocsyncErrorCode> {
const caps = ctx.provider.capabilities(ctx.ref);
const mime = local.mimeType || 'application/octet-stream';
// Paperless refuses anything outside its parser list with a 400. Checking
// first turns a recurring hard failure into one visible row that says why.
if (caps.acceptedMimeTypes && !caps.acceptedMimeTypes.includes(mime)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (caps.maxUploadBytes !== null && local.size > caps.maxUploadBytes) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
const file = this.files.getFileById(local.fileId, ctx.link.trip_id);
if (!file) return 'not_found';
let sha256 = local.sha256;
let stream: Readable;
try {
// Hash before sending when it is not known yet: the push result has to
// record exactly what was written, or the echo guard has nothing to
// compare against on the next run.
if (!sha256) sha256 = await this.hashStoredFile(String(file.filename));
const got = await this.storage.getStream('files', String(file.filename));
stream = got.stream;
} catch {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
const uid = this.existingUid(ctx.link.id, itemId) ?? newTrekDocUid();
const res = await ctx.provider.push(ctx.ref, ctx.scope, {
body: stream,
fileName: local.name,
mimeType: mime,
size: local.size,
sha256,
mtimeSeconds: Math.floor(Date.now() / 1000),
remoteId: remoteId ?? undefined,
trekDocUid: uid,
trekTripUid: `trek-trip-${ctx.link.trip_id}`,
});
if (docFailed(res)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid });
return res.error.code;
}
this.upsertItem(ctx.link, {
itemId,
fileId: local.fileId,
state: 'synced',
remoteIdOverride: res.data.remoteId,
remoteVersion: res.data.remoteVersion,
contentSha256: sha256,
pushedSha256: sha256,
trekDocUid: uid,
errorCode: null,
});
return 'pushed';
}
// ── State loading and writing ──────────────────────────────────────────────
private loadItems(linkId: number): SyncItemState[] {
const rows = this.db.connection
.prepare(
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, content_sha256,
pushed_sha256, state, attempts, remote_missing_at
FROM document_sync_items WHERE link_id = ?`,
)
.all(linkId) as Array<Record<string, unknown>>;
return rows.map((r) => ({
id: Number(r.id),
fileId: r.file_id === null ? null : Number(r.file_id),
trekDocUid: String(r.trek_doc_uid),
remoteId: r.remote_id === null ? null : String(r.remote_id),
remoteVersion: r.remote_version === null ? null : String(r.remote_version),
remoteName: r.remote_name === null || r.remote_name === undefined ? null : String(r.remote_name),
contentSha256: r.content_sha256 === null ? null : String(r.content_sha256),
pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
state: String(r.state),
attempts: Number(r.attempts ?? 0),
remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at),
}));
}
/**
* The trip's own documents, minus the ones that are not really documents.
*
* Chat attachments (`message_id`) and note attachments (`note_id`) live in
* the same table but belong to a conversation, not to the trip's paperwork —
* syncing them would push someone's chat screenshot into a shared Paperless.
*
* The set is per TRIP, not per binding, and that is a property rather than an
* oversight: a binding mirrors the trip's documents, so two bindings on one
* trip each hold a full copy. That is the right answer for "the same papers,
* in both my Nextcloud and my Paperless", and the wrong one for "receipts to
* Paperless, everything else to Nextcloud" — splitting a trip across bindings
* would need a per-document assignment that nothing in the UI offers yet.
*/
private loadLocalDocuments(link: LinkRow): LocalDocument[] {
const rows = this.db.connection
.prepare(
`SELECT f.id, f.original_name, f.file_size, f.mime_type, f.deleted_at
FROM trip_files f
WHERE f.trip_id = ? AND f.message_id IS NULL AND f.note_id IS NULL`,
)
.all(link.trip_id) as Array<Record<string, unknown>>;
return rows.map((r) => ({
fileId: Number(r.id),
name: String(r.original_name),
size: Number(r.file_size ?? 0),
mimeType: r.mime_type === null ? null : String(r.mime_type),
// Deliberately null, and not the agreed hash out of document_sync_items:
// reading that column here would compare it against itself, and
// `localChanged` in the planner could then never be true. TREK has no way
// to replace a document's bytes — an upload creates a new row — so there
// is no local change to detect, and claiming otherwise would be worse
// than admitting it. The day the file manager grows a replace, this needs
// a real hash column on trip_files, filled at upload time.
sha256: null,
deletedAt: r.deleted_at === null ? null : String(r.deleted_at),
}));
}
private existingUid(linkId: number, itemId: number | null): string | null {
if (itemId === null) return null;
const row = this.db.connection
.prepare('SELECT trek_doc_uid FROM document_sync_items WHERE id = ? AND link_id = ?')
.get(itemId, linkId) as { trek_doc_uid?: string } | undefined;
return row?.trek_doc_uid ?? null;
}
private async hashStoredFile(storageKey: string): Promise<string> {
const { stream } = await this.storage.getStream('files', storageKey);
const hash = crypto.createHash('sha256');
for await (const chunk of stream) hash.update(chunk as Buffer);
return hash.digest('hex');
}
private upsertItem(
link: LinkRow,
patch: {
itemId: number | null;
remote?: RemoteDocument;
remoteIdOverride?: string;
remoteVersion?: string;
fileId?: number;
state: string;
errorCode?: DocsyncErrorCode | null;
contentSha256?: string;
pushedSha256?: string;
trekDocUid?: string;
},
): void {
const remoteId = patch.remoteIdOverride ?? patch.remote?.remoteId ?? null;
const remoteVersion = patch.remoteVersion ?? patch.remote?.remoteVersion ?? null;
const failed = patch.state === 'error';
if (patch.itemId !== null) {
const attempts = failed ? 1 : 0;
this.db.connection
.prepare(
`UPDATE document_sync_items
SET state = ?, error_code = ?, file_id = COALESCE(?, file_id),
-- see the giveUp() note below: a row that has run out of
-- attempts stops retrying and waits for a person
remote_id = COALESCE(?, remote_id), remote_version = COALESCE(?, remote_version),
remote_name = COALESCE(?, remote_name), remote_size = COALESCE(?, remote_size),
remote_modified_at = COALESCE(?, remote_modified_at),
content_sha256 = COALESCE(?, content_sha256),
pushed_sha256 = COALESCE(?, pushed_sha256),
attempts = attempts + ?,
next_attempt_at = CASE
WHEN ? = 1 AND attempts + 1 < ? THEN datetime('now', '+' || ? || ' seconds')
ELSE NULL
END,
remote_missing_at = NULL,
synced_at = CASE WHEN ? = 'synced' THEN CURRENT_TIMESTAMP ELSE synced_at END,
last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(
patch.state, patch.errorCode ?? null, patch.fileId ?? null,
remoteId, remoteVersion,
patch.remote?.name ?? null, patch.remote?.size ?? null, patch.remote?.remoteModifiedAt ?? null,
patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
attempts, failed ? 1 : 0, ITEM_MAX_ATTEMPTS, backoffSeconds(ITEM_BACKOFF_SECONDS, 1), patch.state,
patch.itemId,
);
return;
}
// The WHERE clause is repeated on purpose: the unique index is partial
// (`WHERE remote_id IS NOT NULL`, so a not-yet-pushed local file can exist
// without one), and SQLite only matches an ON CONFLICT target to a partial
// index when the predicate is restated. Without it every insert fails with
// "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint".
this.db.connection
.prepare(
`INSERT INTO document_sync_items
(link_id, trip_id, file_id, trek_doc_uid, remote_id, remote_name, remote_version, remote_size,
remote_modified_at, content_sha256, pushed_sha256, state, error_code, attempts, next_attempt_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
CASE WHEN ? = 1 THEN datetime('now', '+' || ? || ' seconds') ELSE NULL END,
CASE WHEN ? = 'synced' THEN CURRENT_TIMESTAMP ELSE NULL END)
ON CONFLICT(link_id, remote_id) WHERE remote_id IS NOT NULL DO UPDATE SET
state = excluded.state, error_code = excluded.error_code,
file_id = COALESCE(excluded.file_id, document_sync_items.file_id),
remote_version = COALESCE(excluded.remote_version, document_sync_items.remote_version),
content_sha256 = COALESCE(excluded.content_sha256, document_sync_items.content_sha256),
pushed_sha256 = COALESCE(excluded.pushed_sha256, document_sync_items.pushed_sha256),
last_seen_at = CURRENT_TIMESTAMP`,
)
.run(
link.id, link.trip_id, patch.fileId ?? null, patch.trekDocUid ?? newTrekDocUid(),
remoteId, patch.remote?.name ?? null, remoteVersion, patch.remote?.size ?? null,
patch.remote?.remoteModifiedAt ?? null, patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
patch.state, patch.errorCode ?? null, failed ? 1 : 0,
failed ? 1 : 0, backoffSeconds(ITEM_BACKOFF_SECONDS, 1),
patch.state,
);
}
private recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): void {
this.db.connection
.prepare(
`UPDATE trip_document_links
SET remote_cursor = ?, last_sync_at = CURRENT_TIMESTAMP, last_sync_state = ?,
last_sync_error = ?, failure_count = 0, next_attempt_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(cursor, state, errorCode, link.id);
}
private recordLinkFailure(link: LinkRow, code: string, state = 'failed'): void {
const failures = link.failure_count + 1;
const wait = backoffSeconds(LINK_BACKOFF_SECONDS, failures);
this.db.connection
.prepare(
`UPDATE trip_document_links
SET last_sync_at = CURRENT_TIMESTAMP, last_sync_state = ?, last_sync_error = ?,
failure_count = ?, next_attempt_at = datetime('now', '+' || ? || ' seconds'),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.run(state, code, failures, wait, link.id);
}
// ── Conflict resolution ────────────────────────────────────────────────────
/**
* Resolve a conflict the way a human chose.
*
* `both` keeps the provider copy as a second TREK document rather than
* overwriting either side, which is the only outcome that cannot lose work
* and is therefore what the UI offers first.
*/
async resolveConflict(itemId: number, keep: 'trek' | 'provider' | 'both'): Promise<boolean> {
const item = this.db.connection
.prepare('SELECT * FROM document_sync_items WHERE id = ?')
.get(itemId) as Record<string, unknown> | undefined;
if (!item || item.state !== 'conflict') return false;
const link = this.config.getLink(Number(item.link_id));
if (!link) return false;
if (keep === 'trek') {
// Forget the provider's version marker, so the next run sees no upstream
// change and pushes TREK's copy. Clearing content_sha256 instead would do
// the opposite: it reads as "TREK never agreed to these bytes", and the
// provider's copy comes down over the one the user just chose to keep.
this.db.connection
.prepare("UPDATE document_sync_items SET state = 'pending', remote_version = NULL, error_code = NULL WHERE id = ?")
.run(itemId);
} else if (keep === 'provider') {
this.db.connection
.prepare("UPDATE document_sync_items SET state = 'pending', content_sha256 = NULL, error_code = NULL WHERE id = ?")
.run(itemId);
} else {
// Detach the pairing and let the next run pull the provider copy as a new
// document. Both versions survive, under two rows.
this.db.connection
.prepare("UPDATE document_sync_items SET state = 'local_deleted', remote_id = NULL, error_code = NULL WHERE id = ?")
.run(itemId);
}
await this.syncLink(link, { full: true });
return true;
}
/**
* The documents a person has to decide about: conflicts, refusals and things
* that vanished upstream. Deliberately not "everything not synced" — a row
* waiting for its turn is not a problem anyone should be shown.
*/
issues(tripId: number): Array<Record<string, unknown>> {
return this.db.connection
.prepare(
`SELECT i.id, i.state, i.error_code, i.remote_name, i.remote_missing_at, f.original_name AS file_name
FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ?
AND i.state IN ('conflict', 'rejected_type', 'too_large', 'remote_missing', 'error')
ORDER BY i.id DESC
LIMIT 200`,
)
.all(tripId) as Array<Record<string, unknown>>;
}
/** Per-trip view for the UI: what is synced, what needs attention. */
status(tripId: number): Record<string, unknown> {
const links = this.config.listLinks(tripId);
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
.all(tripId) as Array<{ state: string; n: number }>;
return {
links: links.map((l) => this.config.publicLink(l, null)),
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
};
}
}
function isTransfer(action: PlanAction): boolean {
return action.kind === 'pull' || action.kind === 'pull_update' || action.kind === 'push' || action.kind === 'push_update';
}
@@ -0,0 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { DOCUMENT_PROVIDERS, type DocumentProvider } from './document-provider';
/**
* The one dispatch site for document providers.
*
* Same shape as PhotoProviderRegistry, and for the same reason: adding a
* backend is registering an adapter in the module, not editing a switch that
* exists in four places. Whether a registered provider is switched ON is a
* different question, answered by the `document_providers` table plus the
* `documents` addon — this only knows what the server can talk to at all.
*/
@Injectable()
export class DocumentProviderRegistry {
private readonly byId: Map<string, DocumentProvider>;
constructor(@Inject(DOCUMENT_PROVIDERS) providers: readonly DocumentProvider[]) {
this.byId = new Map(providers.map((p) => [p.id, p]));
}
get(id: string | null | undefined): DocumentProvider | undefined {
return id ? this.byId.get(id) : undefined;
}
ids(): string[] {
return [...this.byId.keys()];
}
}
@@ -0,0 +1,244 @@
import type { Readable } from 'node:stream';
import type { DocsyncErrorCode } from '@trek/shared';
/**
* The adapter seam for document providers.
*
* Shaped after `PhotoProvider` (memories/photo-provider.ts) — interface, symbol
* token, registry — with one deliberate difference: that interface only covers
* handing out bytes, and everything else (settings, test, search, album list)
* is dispatched through hand-written `if (provider === 'immich')` branches in
* the service, the MCP tools and two separate controllers. With two providers
* that is untidy; with five it would be five parallel controllers and five
* branches per tool. So every operation the sync core needs lives here, and the
* core never learns which provider it is talking to.
*
* Capability differences are answered by data (`DocumentProviderCapabilities`),
* never by type-switching: the core asks what a provider can do and plans
* accordingly. Synology FileStation has no change feed and no stable file id,
* so the core must be correct with nothing but a full enumeration — and because
* the weakest provider sets the model, every provider is driven the same way.
* A webhook is only ever "look now", never a source of truth.
*/
/** Uniform result shape, mirroring `ServiceResult` in memories.helpers.ts. */
export type DocResult<T> =
| { success: true; data: T }
| { success: false; error: { code: DocsyncErrorCode; detail?: string; status?: number } };
export type DocFailure = { success: false; error: { code: DocsyncErrorCode; detail?: string; status?: number } };
export const docOk = <T>(data: T): DocResult<T> => ({ success: true, data });
export const docFail = <T>(
code: DocsyncErrorCode,
detail?: string,
status?: number,
): DocResult<T> => ({ success: false, error: { code, detail, status } });
/**
* Narrow a result to its failure half.
*
* `if (!res.success)` does NOT narrow here: the server workspace compiles with
* `strict: false`, and without `strictNullChecks` a boolean discriminant stops
* discriminating. Testing for the property is what the memories domain already
* does (`if ('error' in result)`), and wrapping it in a predicate keeps every
* call site reading as an intention rather than as a workaround.
*/
export function docFailed<T>(result: DocResult<T>): result is DocFailure {
return 'error' in result;
}
/**
* Whose credentials, against which instance.
*
* A ref object rather than positional arguments, for the reason written into
* `PhotoAssetRef`: two providers took the same three values in a different
* order, and swapping them compiled cleanly and served another user's data.
*/
export interface DocumentConnectionRef {
connectionId: number;
/**
* The user whose credentials these are — the trip admin who set the binding
* up, not whoever is asking. The core passes both around separately and never
* derives one from the other.
*/
ownerId: number;
baseUrl: string;
/** Decrypted `secret = 1` fields. Never logged, never returned to a client. */
secrets: Readonly<Record<string, string>>;
/** Non-secret fields: username, organization_id, base_path, root_tag, … */
settings: Readonly<Record<string, string>>;
allowInsecureTls: boolean;
}
/** The container at the provider that one trip is bound to. */
export interface DocumentScopeRef {
linkId: number;
tripId: number;
/** Opaque, normalised, never empty. See `docsyncScopeKeySchema`. */
scopeKey: string;
remoteRootId: string | null;
remoteRootPath: string | null;
/**
* Whatever the adapter stored last run. A WebDAV adapter puts the root ETag
* here and can skip the whole walk when it is unchanged; Paperless stores the
* highest `modified` it saw. The core treats it as a black box and hands it
* back untouched.
*/
cursor: string | null;
}
export interface RemoteDocument {
/** Stable where the provider has one; a normalised path where it does not. */
remoteId: string;
name: string;
size: number | null;
mimeType: string | null;
/**
* Opaque version marker. Different means changed. That is all the core may
* conclude from it — it is not a content hash, not comparable across
* instances, and not ordered.
*/
remoteVersion: string;
/** sha256 hex when the listing carries one, otherwise null. */
contentHash: string | null;
remoteModifiedAt: string | null;
/** Provider-side tombstone, where one exists (Synology Drive `removed`). */
isDeleted: boolean;
}
export interface DocumentScopeOption {
scopeKey: string;
label: string;
remoteRootId: string | null;
remoteRootPath: string | null;
}
export interface DocumentProviderCapabilities {
/**
* `webhook-self-registered`: TREK can create the subscription itself
* (Paperless workflows). `webhook-manual`: the API exists but is closed to
* API keys, so a human has to paste a URL (Papra, and Nextcloud unless the
* account is an admin). `none`: polling only (Synology).
*/
push: 'webhook-self-registered' | 'webhook-manual' | 'none';
/** False means identity is the path, so rename looks like delete + create. */
stableId: boolean;
/** A delete lands in a recycle bin the user can recover from. */
remoteTrash: boolean;
/** A new revision can replace the bytes under the same remoteId. */
replaceInPlace: boolean;
/** The listing already carries a content hash, so no extra round trip. */
contentHashInListing: boolean;
maxUploadBytes: number | null;
/** null means anything; otherwise the provider's own MIME allowlist. */
acceptedMimeTypes: readonly string[] | null;
/** The adapter can create a new scope container, not just pick one. */
canCreateScope: boolean;
}
export interface PushRequest {
/** Bytes, streamed straight out of `StorageService.getStream('files', …)`. */
body: Readable;
fileName: string;
mimeType: string;
size: number;
/** sha256 of those bytes; adapters that can pin it upstream should. */
sha256: string;
/**
* Unix seconds. WebDAV sends `X-OC-MTime`, Synology multiplies by 1000.
* Without it every file TREK uploads comes back looking freshly changed,
* which is the classic sync loop.
*/
mtimeSeconds: number;
/** Present means replace that document rather than create a new one. */
remoteId?: string;
/** Optimistic concurrency (WebDAV `If-Match`); ignored where unsupported. */
expectedRemoteVersion?: string;
/**
* The anchors that survive a human renaming or re-tagging things upstream.
* Written into a custom field, a property or a tag where the provider has
* somewhere to put them.
*/
trekDocUid: string;
trekTripUid: string;
}
export interface PushResult {
remoteId: string;
remoteVersion: string;
remoteModifiedAt: string | null;
/**
* True when the provider matched existing bytes instead of storing new ones
* (Papra answers 409 on a duplicate hash, and restores a trashed twin under
* its ORIGINAL id). The core must not book that as a fresh document.
*/
deduplicated: boolean;
}
export interface FetchResult {
body: Readable;
size: number | null;
mimeType: string | null;
remoteVersion: string;
}
export interface DocumentProvider {
readonly id: string;
capabilities(conn: DocumentConnectionRef): DocumentProviderCapabilities;
/**
* Verify form values before anything is stored, and report what the account
* can actually do. Always resolves — a failed probe is data, not an
* exception, because the settings UI shows it inline (the photo providers
* pin that with a test marked CRITICAL in their e2e suite).
*/
probe(conn: DocumentConnectionRef): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>>;
/** Offer the containers a trip could be bound to: folders, tags, spaces. */
listScopes(conn: DocumentConnectionRef, query?: string): Promise<DocResult<DocumentScopeOption[]>>;
/** Create one, where the provider has the concept. */
createScope(conn: DocumentConnectionRef, name: string): Promise<DocResult<DocumentScopeOption>>;
/** Confirm the bound scope still exists, so `scope_lost` is detectable. */
resolveScope(conn: DocumentConnectionRef, scope: DocumentScopeRef): Promise<DocResult<DocumentScopeOption>>;
/**
* Enumerate everything currently in the scope, plus the cursor to hand back
* next time.
*
* Full enumeration rather than a delta, deliberately: a changed-since query
* cannot see a deletion, several of these providers bump no timestamp when a
* tag changes, and Paperless's bulk edit changes documents without touching
* `modified` at all. `cursorUnchanged` lets an adapter short-circuit the walk
* (the WebDAV root ETag) while keeping the core's model identical.
*/
list(conn: DocumentConnectionRef, scope: DocumentScopeRef): Promise<DocResult<{
documents: RemoteDocument[];
cursor: string | null;
cursorUnchanged: boolean;
/** True when the listing hit a page cap and is therefore not the whole truth. */
truncated: boolean;
}>>;
fetch(conn: DocumentConnectionRef, scope: DocumentScopeRef, remoteId: string): Promise<DocResult<FetchResult>>;
push(conn: DocumentConnectionRef, scope: DocumentScopeRef, req: PushRequest): Promise<DocResult<PushResult>>;
rename(conn: DocumentConnectionRef, scope: DocumentScopeRef, remoteId: string, name: string): Promise<DocResult<{ remoteVersion: string }>>;
/**
* Move to the provider's recycle bin. Never a hard delete: Papra and
* Paperless only expose permanent deletion to a session, not to a token, and
* on the others an accidental propagation would be unrecoverable.
*/
trash(conn: DocumentConnectionRef, scope: DocumentScopeRef, remoteId: string): Promise<DocResult<void>>;
/** Subscribe to change notifications, where TREK is allowed to do it itself. */
registerWebhook?(conn: DocumentConnectionRef, scope: DocumentScopeRef, callbackUrl: string, secret: string): Promise<DocResult<{ subscriptionId: string }>>;
unregisterWebhook?(conn: DocumentConnectionRef, subscriptionId: string): Promise<DocResult<void>>;
}
export const DOCUMENT_PROVIDERS = Symbol('DOCUMENT_PROVIDERS');
@@ -0,0 +1,954 @@
import { randomUUID } from 'node:crypto';
import type { DocsyncErrorCode } from '@trek/shared';
import { safeFetch, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { readCappedJson, readCappedText, discardBody } from '../../../utils/cappedFetch';
import {
PROVIDER_JSON_MAX_BYTES,
PROVIDER_MAX_PAGES,
PROVIDER_TIMEOUT_MS,
PROVIDER_TRANSFER_TIMEOUT_MS,
} from '../doc-sync.constants';
/**
* Thin HTTP client for the Paperless-ngx REST API.
* This is the ONLY place that talks to a trip's Paperless instance.
*
* Measured against Paperless-ngx 3.1.3 / API version 10, not against its
* documentation — the two disagree in the places that matter here:
*
* - **A document carries no top-level `checksum` at 3.x.** The hash lives in
* `versions[]`, one entry per uploaded revision, and it is a **sha256** of
* the original file (verified: an uploaded file's sha256 came back verbatim).
* `versions` arrives newest first with the ORIGINAL marked `is_root: true`,
* so the entry describing the current bytes is the newest `added`, not the
* root one.
* - `GET /api/documents/{id}/download/` hands out the **archived**, OCR'd PDF,
* whose bytes are not the ones that were uploaded (398 B in, 8483 B out).
* Only `?original=true` round-trips, and only it matches the checksum.
* - **An upload answers with a task UUID, not a document.** The consume job is
* asynchronous (~3 s for a one-page PDF with OCR), and `GET /api/tasks/`
* answers a PAGINATED object at 3.x where older builds answered a bare
* array. `status` is lower case (`success`, `failure`) where older builds
* shouted it, so both spellings are compared case-insensitively.
* - **`POST /api/documents/{id}/update_version/` keeps the document id**, and
* with it the tags and custom fields — but the task it returns reports a
* DIFFERENT, internal document id (the version row), which answers 404 on
* `/api/documents/{id}/`. Callers must keep the id they updated.
* - **Duplicate bytes are not refused.** Uploading the same file twice yields
* two documents with the same checksum and an empty `duplicate_documents`,
* so there is no upstream deduplication to report. `?checksum__iexact=` does
* filter, which is what makes a lost upload recoverable instead of doubled.
* - **Unknown query parameters are ignored, not rejected.** A filter that does
* not exist silently widens the result, so every filter used here was proven
* with a matching and a non-matching value.
* - The MIME allow-list is enforced by the server: an unsupported type comes
* back as `400 {"document": ["File type … not supported"]}` before any task
* is created. Office formats pass only on an instance with Tika configured.
*
* The API version is pinned in the Accept header. Paperless moves its default
* with the server version, so an unpinned client changes shape under a routine
* `docker pull` — that is exactly how the `checksum` field moved out of the
* document serializer.
*/
/** The version this client's response handling was written against. */
const API_ACCEPT = 'application/json; version=10';
/** Tags, documents and tasks are small rows; a page of them is never megabytes. */
const MAX_JSON_BYTES = PROVIDER_JSON_MAX_BYTES;
/** Documents per page. Paperless accepts more, but the response grows with it. */
export const DOCUMENT_PAGE_SIZE = 100;
export const TAG_PAGE_SIZE = 100;
/** How often the consume task is polled, and for how long before giving up. */
export const TASK_POLL_INTERVAL_MS = 1500;
export const TASK_POLL_TIMEOUT_MS = PROVIDER_TRANSFER_TIMEOUT_MS;
export interface PaperlessCreds {
/** Instance origin. The client appends `/api` itself. */
baseUrl: string;
/** The API token from the user's Paperless profile. Never logged. */
token: string;
allowInsecureTls: boolean;
}
/**
* A failed Paperless call, carrying the reason as one of TREK's codes.
*
* The upstream text never reaches a user: it is English, it sometimes quotes
* the URL back, and behind an auth proxy it is an HTML login page. `detail`
* keeps it for the self-hoster reading their own log.
*/
export class PaperlessError extends Error {
readonly code: DocsyncErrorCode;
readonly status?: number;
readonly detail?: string;
constructor(code: DocsyncErrorCode, message: string, status?: number, detail?: string) {
super(message);
this.name = 'PaperlessError';
this.code = code;
this.status = status;
this.detail = detail;
}
}
export interface PaperlessTag {
id: number;
name: string;
/** Absent on the POST response, present in listings. */
documentCount: number | null;
}
export interface PaperlessCustomField {
id: number;
name: string;
dataType: string | null;
}
/** One entry of a document's `versions`, i.e. one uploaded revision. */
export interface PaperlessVersion {
id: number;
/** sha256 hex of that revision's original file. */
checksum: string | null;
added: string | null;
isRoot: boolean;
}
export interface PaperlessDocument {
id: number;
title: string;
/** The name the file was uploaded under; null on documents created elsewhere. */
originalFileName: string | null;
mimeType: string | null;
/** The opaque version marker. Bumped by edits, uploads and bulk edits alike. */
modified: string | null;
added: string | null;
/** Non-null only in `/api/trash/`; a listed document is never trashed. */
deletedAt: string | null;
tagIds: number[];
versions: PaperlessVersion[];
}
export interface PaperlessTask {
taskId: string;
/** Lower case at 3.x, upper case on older builds. Compared case-insensitively. */
status: string;
documentIds: number[];
errorMessage: string | null;
}
export interface PaperlessProfile {
username: string;
version: string | null;
/** Django permission codenames the token's user holds, e.g. `add_workflow`. */
permissions: string[];
}
export interface PaperlessUploadFile {
fileName: string;
mimeType: string;
bytes: Buffer;
}
export interface PaperlessWorkflowSpec {
name: string;
/** Only documents carrying this tag fire the webhook. */
tagId: number;
callbackUrl: string;
/** Sent as a request header, so it never lands in an access log. */
secretHeader: { name: string; value: string };
/** Flat string map; Paperless sends it as the JSON body. */
params: Record<string, string>;
}
/**
* Paperless's own ceiling for a webhook URL (`max_length` on the field). A
* longer callback is rejected by the serializer with a 400 that reads like a
* generic validation failure, so it is worth catching before the round trip.
*/
export const WEBHOOK_URL_MAX_LENGTH = 256;
/**
* Workflow trigger types, as `OPTIONS /api/workflows/` enumerates them. There
* is deliberately no deletion trigger upstream — a document moved to the trash
* fires nothing, which is why a webhook can only ever mean "look now".
*/
export const TRIGGER_DOCUMENT_ADDED = 2;
export const TRIGGER_DOCUMENT_UPDATED = 3;
/** Action type 4 is "Webhook". */
export const ACTION_WEBHOOK = 4;
/**
* Consume folder, API upload, mail fetch, web UI — all four, or a document
* dropped into Paperless's own interface would never reach TREK.
*/
const TRIGGER_SOURCES = [1, 2, 3, 4];
/**
* `https://host/paperless/` → `https://host/paperless/api`, tolerating what
* people paste: a trailing slash, or the `/api` they copied out of the browser.
*/
export function apiBase(baseUrl: string): string {
const origin = baseUrl
.trim()
.replace(/(?<!\/)\/+$/, '')
.replace(/\/api$/i, '');
return `${origin}/api`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/**
* The rows of a paginated answer, or of a bare array.
*
* Both shapes are live: `/api/tasks/` answers a bare array on older builds and
* a paginated object at 3.x, and reading only one of them would make the upload
* path fail on whichever half of the installed base it guessed wrong.
*/
function pageRows(data: unknown): { rows: unknown[]; hasNext: boolean; count: number | null } {
if (Array.isArray(data)) return { rows: data, hasNext: false, count: data.length };
if (isRecord(data) && Array.isArray(data.results)) {
return {
rows: data.results,
hasNext: typeof data.next === 'string' && data.next.length > 0,
count: asNumber(data.count),
};
}
throw new PaperlessError('provider_error', 'Paperless answered a listing in an unknown shape');
}
function parseTag(row: unknown): PaperlessTag | null {
if (!isRecord(row)) return null;
const id = asNumber(row.id);
const name = asString(row.name);
if (id === null || name === null) return null;
return { id, name, documentCount: asNumber(row.document_count) };
}
function parseCustomField(row: unknown): PaperlessCustomField | null {
if (!isRecord(row)) return null;
const id = asNumber(row.id);
const name = asString(row.name);
if (id === null || name === null) return null;
return { id, name, dataType: asString(row.data_type) };
}
function parseVersions(value: unknown): PaperlessVersion[] {
if (!Array.isArray(value)) return [];
const versions: PaperlessVersion[] = [];
for (const row of value) {
if (!isRecord(row)) continue;
const id = asNumber(row.id);
if (id === null) continue;
versions.push({
id,
checksum: asString(row.checksum),
added: asString(row.added),
isRoot: row.is_root === true,
});
}
return versions;
}
function parseDocument(row: unknown): PaperlessDocument | null {
if (!isRecord(row)) return null;
const id = asNumber(row.id);
if (id === null) return null;
const tagIds = Array.isArray(row.tags)
? row.tags.filter((tag): tag is number => typeof tag === 'number')
: [];
return {
id,
title: asString(row.title) ?? '',
originalFileName: asString(row.original_file_name),
mimeType: asString(row.mime_type),
modified: asString(row.modified),
added: asString(row.added),
deletedAt: asString(row.deleted_at),
tagIds,
versions: parseVersions(row.versions),
};
}
function parseTask(row: unknown): PaperlessTask | null {
if (!isRecord(row)) return null;
const taskId = asString(row.task_id);
if (taskId === null) return null;
const related = Array.isArray(row.related_document_ids)
? row.related_document_ids.filter((value): value is number => typeof value === 'number')
: [];
const fromResult = isRecord(row.result_data) ? asNumber(row.result_data.document_id) : null;
const documentIds = related.length > 0 ? related : fromResult === null ? [] : [fromResult];
return {
taskId,
status: asString(row.status) ?? '',
documentIds,
errorMessage: isRecord(row.result_data) ? asString(row.result_data.error_message) : null,
};
}
/**
* The revision whose bytes `?original=true` currently serves.
*
* `versions` arrives newest first at 3.1.3, but that is an ordering nobody
* promised, so the newest `added` decides and the array order is only the
* tie-breaker. The root entry is explicitly NOT it: after an in-place replace
* the root is the file that was replaced.
*/
export function currentVersion(doc: PaperlessDocument): PaperlessVersion | null {
let best: PaperlessVersion | null = null;
let bestAt = Number.NEGATIVE_INFINITY;
for (const version of doc.versions) {
const parsed = version.added === null ? Number.NaN : Date.parse(version.added);
const rank = Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
if (best === null || rank > bestAt) {
best = version;
bestAt = rank;
}
}
return best;
}
/** sha256 hex, or null. A hash of another shape must not be sold as one. */
export function sha256OrNull(value: string | null): string | null {
return value !== null && /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : null;
}
/**
* Whether a thrown error is a timeout rather than a dead host.
*
* `AbortSignal.timeout` rejects with a DOMException named `TimeoutError`; an
* aborted request surfaces as `AbortError`. Both mean the instance did not
* answer in time, which is a different thing for the user to fix than a host
* that refused the connection.
*/
function isTimeout(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.name === 'TimeoutError' || err.name === 'AbortError';
}
const TLS_ERROR_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'CERT_HAS_EXPIRED',
'ERR_TLS_CERT_ALTNAME_INVALID',
'CERT_SIGNATURE_FAILURE',
]);
/**
* A self-signed or otherwise untrusted certificate, anywhere in the cause chain.
*
* undici wraps the TLS failure in a `TypeError: fetch failed`, so the code that
* says what actually went wrong is one or two `cause` levels down. Telling this
* apart from "unreachable" is what lets the settings UI offer the "this
* instance uses a self-signed certificate" switch instead of a dead end.
*/
function isTlsFailure(err: unknown): boolean {
let current: unknown = err;
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
const code = 'code' in current ? String((current as { code?: unknown }).code) : '';
if (TLS_ERROR_CODES.has(code)) return true;
current = (current as { cause?: unknown }).cause;
}
return false;
}
function transportError(err: unknown): PaperlessError {
if (err instanceof SsrfBlockedError) {
return new PaperlessError('ssrf_blocked', 'The address is blocked', undefined, err.message);
}
if (isTimeout(err)) {
return new PaperlessError('timeout', 'Paperless did not answer in time');
}
if (isTlsFailure(err)) {
return new PaperlessError(
'tls_untrusted',
'The certificate of this Paperless instance is not trusted',
undefined,
err instanceof Error ? err.message : undefined,
);
}
return new PaperlessError(
'unreachable',
'Could not reach Paperless',
undefined,
err instanceof Error ? err.message : undefined,
);
}
/**
* The code for an HTTP status, refined by the body where the status alone is
* ambiguous. Paperless answers 400 both for "I do not take this file type" —
* a per-document verdict the user can act on — and for a malformed request,
* and the difference is only in the text.
*/
function classify(status: number, body: string): DocsyncErrorCode {
if (status === 400) {
if (/not supported/i.test(body)) return 'unsupported_type';
if (/unique constraint|already exists/i.test(body)) return 'conflict';
return 'provider_error';
}
if (status === 401) return 'unauthorized';
if (status === 403) return 'forbidden';
if (status === 404) return 'not_found';
if (status === 409) return 'conflict';
if (status === 413) return 'too_large';
if (status === 429) return 'rate_limited';
if (status === 507) return 'quota_exceeded';
return 'provider_error';
}
/** Trim an upstream message to something a log line can hold. */
function shorten(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > 400 ? `${flat.slice(0, 400)}…` : flat;
}
interface RequestOptions {
method?: string;
query?: Record<string, string | undefined>;
json?: unknown;
body?: Buffer;
contentType?: string;
timeoutMs?: number;
/** Streaming responses are handed back unread; everything else is capped. */
raw?: boolean;
}
interface MultipartField {
name: string;
value: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Everything a header value may not contain, replaced rather than rejected. */
function headerSafe(value: string): string {
return value.replace(/[\r\n"\\]/g, '_');
}
/**
* A multipart/form-data body, built by hand.
*
* `FormData` plus `Blob` would be shorter but hands undici a body without a
* length; a Buffer keeps Content-Length honest, which is what lets a reverse
* proxy in front of Paperless enforce its own upload limit instead of buffering
* an unbounded stream. Field and file names are stripped of quotes and line
* breaks first — a document called `a".pdf` would otherwise end the
* Content-Disposition header early and rewrite the rest of the request.
*/
export function buildMultipart(
fields: MultipartField[],
file: PaperlessUploadFile,
): { body: Buffer; contentType: string } {
const boundary = `----TrekDocSync${randomUUID().replace(/-/g, '')}`;
const parts: Buffer[] = [];
for (const field of fields) {
parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${headerSafe(field.name)}"\r\n\r\n${field.value}\r\n`,
'utf8',
),
);
}
parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${headerSafe(file.fileName)}"\r\n` +
`Content-Type: ${headerSafe(file.mimeType)}\r\n\r\n`,
'utf8',
),
);
parts.push(file.bytes);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
return {
body: Buffer.concat(parts),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
export class PaperlessClient {
/**
* One authenticated request. Every call in this file goes through it, so the
* token header, the pinned API version, the timeout and the TLS decision are
* made in exactly one place.
*/
private async request(
creds: PaperlessCreds,
path: string,
options: RequestOptions = {},
): Promise<Response> {
const url = new URL(apiBase(creds.baseUrl) + path);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, value);
}
const headers: Record<string, string> = {
Authorization: `Token ${creds.token}`,
Accept: options.raw === true ? '*/*' : API_ACCEPT,
};
let body: Buffer | string | undefined;
if (options.json !== undefined) {
body = JSON.stringify(options.json);
headers['Content-Type'] = 'application/json';
} else if (options.body !== undefined) {
body = options.body;
headers['Content-Type'] = options.contentType ?? 'application/octet-stream';
// Content-Length is deliberately NOT set: undici derives it from a buffer
// body and rejects the request outright (`UND_ERR_INVALID_ARG: invalid
// content-length header`) when the header is also present, which surfaces
// as a bare `TypeError: fetch failed` two layers up.
}
let response: Response;
try {
response = await safeFetch(
url.toString(),
{
method: options.method ?? 'GET',
headers,
body,
signal: AbortSignal.timeout(options.timeoutMs ?? PROVIDER_TIMEOUT_MS) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw transportError(err);
}
if (!response.ok) {
// The body is what separates "wrong file type" from "wrong request", so it
// is read — through the cap, because a proxy's error page is HTML of
// unbounded length.
const { text } = await readCappedText(response, MAX_JSON_BYTES);
throw new PaperlessError(
classify(response.status, text),
`Paperless answered HTTP ${response.status}`,
response.status,
shorten(text),
);
}
return response;
}
private async requestJson<T>(
creds: PaperlessCreds,
path: string,
options: RequestOptions = {},
): Promise<T> {
const response = await this.request(creds, path, options);
const data = await readCappedJson<T>(response, MAX_JSON_BYTES);
if (data === undefined) {
throw new PaperlessError(
'provider_error',
'Paperless returned something that is not JSON',
response.status,
'Check that the URL points at Paperless itself and is reachable without a separate login.',
);
}
return data;
}
/**
* Who the token belongs to, which build answers, and what that user may do.
*
* `/api/ui_settings/` rather than `/api/profile/`: profile echoes the token
* back in its own body, and nothing here should carry a credential it does not
* need. ui_settings also carries the permission list, which is the only way to
* know in advance whether this token may create the webhook workflow.
*/
async probe(creds: PaperlessCreds): Promise<PaperlessProfile> {
const data = await this.requestJson<unknown>(creds, '/ui_settings/');
if (!isRecord(data)) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown profile shape');
}
const user = isRecord(data.user) ? data.user : {};
const settings = isRecord(data.settings) ? data.settings : {};
return {
username: asString(user.username) ?? '',
version: asString(settings.version),
permissions: Array.isArray(data.permissions)
? data.permissions.filter((permission): permission is string => typeof permission === 'string')
: [],
};
}
/** Every tag, or those whose name contains `query`. */
async listTags(creds: PaperlessCreds, query?: string): Promise<PaperlessTag[]> {
const tags: PaperlessTag[] = [];
for (let page = 1; page <= PROVIDER_MAX_PAGES; page++) {
const data = await this.requestJson<unknown>(creds, '/tags/', {
query: {
page: String(page),
page_size: String(TAG_PAGE_SIZE),
ordering: 'name',
name__icontains: query !== undefined && query.trim().length > 0 ? query.trim() : undefined,
},
});
const { rows, hasNext } = pageRows(data);
for (const row of rows) {
const tag = parseTag(row);
if (tag !== null) tags.push(tag);
}
if (!hasNext) break;
}
return tags;
}
async getTag(creds: PaperlessCreds, tagId: number): Promise<PaperlessTag> {
const tag = parseTag(await this.requestJson<unknown>(creds, `/tags/${tagId}/`));
if (tag === null) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown tag shape');
}
return tag;
}
async createTag(creds: PaperlessCreds, name: string): Promise<PaperlessTag> {
const tag = parseTag(
await this.requestJson<unknown>(creds, '/tags/', { method: 'POST', json: { name } }),
);
if (tag === null) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown tag shape');
}
return tag;
}
/**
* Every document carrying the tag, oldest id first.
*
* `tags__id__all` rather than `tags__id__in`: with a single tag the two agree,
* and `__all` keeps meaning "carries this tag" if a caller ever passes more.
* Ordering by id keeps the pages stable while documents are being added —
* ordering by `modified`, the field that changes under us, would let a
* document move between pages and never be seen at all.
*
* Pages are walked by number instead of by following `next`: Paperless builds
* that URL from the request's own Host header, so behind a reverse proxy it
* can name an origin this connection was never allowed to talk to.
*/
async listDocuments(
creds: PaperlessCreds,
tagId: number,
): Promise<{ documents: PaperlessDocument[]; truncated: boolean; count: number | null }> {
const documents: PaperlessDocument[] = [];
let truncated = false;
let count: number | null = null;
for (let page = 1; page <= PROVIDER_MAX_PAGES; page++) {
const data = await this.requestJson<unknown>(creds, '/documents/', {
query: {
tags__id__all: String(tagId),
ordering: 'id',
page: String(page),
page_size: String(DOCUMENT_PAGE_SIZE),
},
});
const { rows, hasNext, count: total } = pageRows(data);
count ??= total;
for (const row of rows) {
const doc = parseDocument(row);
if (doc !== null) documents.push(doc);
}
if (!hasNext) break;
if (page === PROVIDER_MAX_PAGES) truncated = true;
}
return { documents, truncated, count };
}
async getDocument(creds: PaperlessCreds, documentId: number): Promise<PaperlessDocument> {
const doc = parseDocument(await this.requestJson<unknown>(creds, `/documents/${documentId}/`));
if (doc === null) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown document shape');
}
return doc;
}
/**
* Documents whose original file has exactly these bytes.
*
* The filter is server-side and exact. It exists here for one case: an upload
* whose consume task was never observed, where the document may or may not
* have landed and a blind retry would leave two — Paperless does not refuse
* duplicate bytes.
*/
async findByChecksum(creds: PaperlessCreds, sha256: string): Promise<PaperlessDocument[]> {
const data = await this.requestJson<unknown>(creds, '/documents/', {
query: { checksum__iexact: sha256, ordering: '-id', page_size: String(DOCUMENT_PAGE_SIZE) },
});
const { rows } = pageRows(data);
const documents: PaperlessDocument[] = [];
for (const row of rows) {
const doc = parseDocument(row);
if (doc !== null) documents.push(doc);
}
return documents;
}
async patchDocument(
creds: PaperlessCreds,
documentId: number,
patch: { title?: string },
): Promise<PaperlessDocument> {
const doc = parseDocument(
await this.requestJson<unknown>(creds, `/documents/${documentId}/`, {
method: 'PATCH',
json: patch,
}),
);
if (doc === null) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown document shape');
}
return doc;
}
/** Moves the document to Paperless's trash. There is no hard delete here. */
async deleteDocument(creds: PaperlessCreds, documentId: number): Promise<void> {
discardBody(await this.request(creds, `/documents/${documentId}/`, { method: 'DELETE' }));
}
/**
* The ORIGINAL bytes, streaming. The caller owns the body from here.
*
* Without `original=true` Paperless serves the archived PDF it produced
* itself, which is a different file with a different length and a different
* hash — TREK would store something the user never uploaded, and every
* comparison against the checksum would fail.
*/
async downloadOriginal(creds: PaperlessCreds, documentId: number): Promise<Response> {
return this.request(creds, `/documents/${documentId}/download/`, {
query: { original: 'true' },
timeoutMs: PROVIDER_TRANSFER_TIMEOUT_MS,
raw: true,
});
}
/** Upload a new document. Answers the consume task's id, not a document id. */
async postDocument(
creds: PaperlessCreds,
file: PaperlessUploadFile,
fields: { title?: string; tagId: number; customFields?: Record<string, string> },
): Promise<string> {
const parts: MultipartField[] = [{ name: 'tags', value: String(fields.tagId) }];
if (fields.title !== undefined && fields.title.length > 0) {
parts.push({ name: 'title', value: fields.title });
}
if (fields.customFields !== undefined && Object.keys(fields.customFields).length > 0) {
// Paperless expects the custom fields as ONE JSON object in a single form
// field, keyed by field id. Repeating `custom_fields=<id>` instead
// attaches the fields with no value, which loses the anchor silently.
parts.push({ name: 'custom_fields', value: JSON.stringify(fields.customFields) });
}
return this.postMultipart(creds, '/documents/post_document/', parts, file);
}
/**
* Replace the bytes of an existing document, keeping its id, tags and custom
* fields. Answers the consume task's id.
*/
async updateVersion(
creds: PaperlessCreds,
documentId: number,
file: PaperlessUploadFile,
): Promise<string> {
return this.postMultipart(creds, `/documents/${documentId}/update_version/`, [], file);
}
private async postMultipart(
creds: PaperlessCreds,
path: string,
fields: MultipartField[],
file: PaperlessUploadFile,
): Promise<string> {
const { body, contentType } = buildMultipart(fields, file);
const data = await this.requestJson<unknown>(creds, path, {
method: 'POST',
body,
contentType,
timeoutMs: PROVIDER_TRANSFER_TIMEOUT_MS,
});
// The whole answer is the task UUID as a bare JSON string.
const taskId = asString(data);
if (taskId === null || taskId.length === 0) {
throw new PaperlessError('provider_error', 'Paperless accepted the upload without naming a task');
}
return taskId;
}
async getTask(creds: PaperlessCreds, taskId: string): Promise<PaperlessTask | null> {
const data = await this.requestJson<unknown>(creds, '/tasks/', { query: { task_id: taskId } });
const { rows } = pageRows(data);
for (const row of rows) {
const task = parseTask(row);
// `task_id` is compared rather than trusted: the filter is a query
// parameter, and Paperless ignores parameters it does not know instead of
// rejecting them, so an older build would answer with every task there is.
if (task !== null && task.taskId === taskId) return task;
}
return null;
}
/**
* Wait for a consume task to finish and answer the document it produced.
*
* Bounded twice: each poll carries its own request timeout, and the loop as a
* whole gives up at `timeoutMs`. A large scan with OCR can outlast any budget
* worth holding a worker for, and the caller has a cheaper way to find out
* whether the upload landed — the checksum filter — so this reports `timeout`
* rather than waiting longer.
*/
async awaitConsume(
creds: PaperlessCreds,
taskId: string,
options: { intervalMs?: number; timeoutMs?: number } = {},
): Promise<{ documentId: number }> {
const interval = options.intervalMs ?? TASK_POLL_INTERVAL_MS;
const deadline = Date.now() + (options.timeoutMs ?? TASK_POLL_TIMEOUT_MS);
for (;;) {
const task = await this.getTask(creds, taskId);
const status = task?.status.toLowerCase() ?? '';
if (status === 'failure' || status === 'revoked') {
// A consume that fails on the file itself (a broken PDF, an encrypted
// one) is a per-document verdict, not a transport fault.
const detail = task?.errorMessage ?? null;
throw new PaperlessError(
detail !== null && /duplicate/i.test(detail) ? 'conflict' : 'provider_error',
'Paperless could not process the uploaded file',
undefined,
detail === null ? undefined : shorten(detail),
);
}
if (status === 'success' && task !== null) {
const documentId = task.documentIds[0];
if (documentId === undefined) {
throw new PaperlessError(
'provider_error',
'Paperless finished the upload without naming a document',
);
}
return { documentId };
}
if (Date.now() + interval >= deadline) {
throw new PaperlessError(
'timeout',
'Paperless is still processing the uploaded file',
undefined,
`task ${taskId} was still ${status === '' ? 'unknown' : status}`,
);
}
await sleep(interval);
}
}
/** The custom field with this exact name, or null. */
async findCustomField(creds: PaperlessCreds, name: string): Promise<PaperlessCustomField | null> {
const data = await this.requestJson<unknown>(creds, '/custom_fields/', {
query: { name__iexact: name, page_size: String(TAG_PAGE_SIZE) },
});
const { rows } = pageRows(data);
for (const row of rows) {
const field = parseCustomField(row);
// The filter is server-side, but an unknown query parameter is IGNORED by
// Paperless rather than rejected, so a build without it would hand back
// every field there is. Comparing the name here makes that harmless.
if (field !== null && field.name.toLowerCase() === name.toLowerCase()) return field;
}
return null;
}
async createCustomField(creds: PaperlessCreds, name: string): Promise<PaperlessCustomField> {
const field = parseCustomField(
await this.requestJson<unknown>(creds, '/custom_fields/', {
method: 'POST',
json: { name, data_type: 'string' },
}),
);
if (field === null) {
throw new PaperlessError('provider_error', 'Paperless answered an unknown custom field shape');
}
return field;
}
/**
* Subscribe Paperless to its own changes for one tag.
*
* Two triggers rather than one: "document added" misses every later edit, and
* "document updated" misses the arrival. Both are filtered on the tag, so the
* rest of a household's papers never cause a callback.
*
* The payload is deliberately thin. Placeholders such as `{doc_id}` are NOT
* substituted in a webhook body at 3.1.3 (measured: they arrive literally),
* and `as_json` wraps a body STRING into a JSON string rather than sending an
* object — so the parameters, which do arrive as a real JSON object, carry the
* link, and the callback means nothing more than "look now".
*/
async createWorkflow(creds: PaperlessCreds, spec: PaperlessWorkflowSpec): Promise<number> {
if (spec.callbackUrl.length > WEBHOOK_URL_MAX_LENGTH) {
throw new PaperlessError(
'provider_error',
'The callback URL is longer than Paperless accepts',
undefined,
`${spec.callbackUrl.length} characters, limit ${WEBHOOK_URL_MAX_LENGTH}`,
);
}
const trigger = (type: number) => ({
type,
sources: TRIGGER_SOURCES,
filter_has_tags: [spec.tagId],
matching_algorithm: 0,
match: '',
is_insensitive: true,
});
const data = await this.requestJson<unknown>(creds, '/workflows/', {
method: 'POST',
json: {
name: spec.name,
order: 1,
enabled: true,
triggers: [trigger(TRIGGER_DOCUMENT_ADDED), trigger(TRIGGER_DOCUMENT_UPDATED)],
actions: [
{
type: ACTION_WEBHOOK,
webhook: {
url: spec.callbackUrl,
use_params: true,
as_json: true,
params: spec.params,
headers: { [spec.secretHeader.name]: spec.secretHeader.value },
include_document: false,
},
},
],
},
});
const id = isRecord(data) ? asNumber(data.id) : null;
if (id === null) {
throw new PaperlessError('provider_error', 'Paperless created the workflow without naming it');
}
return id;
}
/** Removing a subscription that is already gone is success, not a failure. */
async deleteWorkflow(creds: PaperlessCreds, workflowId: number): Promise<void> {
try {
discardBody(await this.request(creds, `/workflows/${workflowId}/`, { method: 'DELETE' }));
} catch (err: unknown) {
if (err instanceof PaperlessError && err.code === 'not_found') return;
throw err;
}
}
}
@@ -0,0 +1,722 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'node:stream';
import {
docFail,
docOk,
type DocResult,
type DocumentConnectionRef,
type DocumentProvider,
type DocumentProviderCapabilities,
type DocumentScopeOption,
type DocumentScopeRef,
type FetchResult,
type PushRequest,
type PushResult,
type RemoteDocument,
} from '../document-provider';
import {
PaperlessClient,
PaperlessError,
currentVersion,
sha256OrNull,
type PaperlessCreds,
type PaperlessDocument,
type PaperlessTag,
} from './paperless.client';
/**
* Paperless-ngx as a document scope.
*
* The scope is a TAG, because that is the only container Paperless has: there
* are no folders, and a document belongs to as many tags as someone gives it.
* A tag is also the one thing the instance's owner can see and reason about in
* their own interface, which matters — this is their archive, and TREK is a
* guest in it.
*
* The tag is paired with a custom field (`trek_trip_id`) carrying the trip's
* uid. A tag can be removed by a human in two clicks and the binding would be
* silently empty; the custom field survives that and says which trip a document
* came from even after someone re-tags it. Nothing here treats the field as
* authority — it is a breadcrumb for the person cleaning up, and for a support
* question that starts with "where did this come from".
*
* Deletion propagates only as far as Paperless's own trash. `DELETE` on a
* document is a soft delete there (`deleted_at` is set, the document leaves
* every listing and `/api/documents/{id}/` answers 404), and the retention is
* the instance's `trash_delay`. There is no hard delete on the API at all.
*/
/** `tag:<id>`, the only scope key shape this adapter reads or writes. */
const SCOPE_PREFIX = 'tag:';
/** The custom field the trip uid is written into, created on demand. */
export const TRIP_UID_FIELD_NAME = 'trek_trip_id';
/** The header the self-registered workflow sends its shared secret in. */
export const PAPERLESS_WEBHOOK_SECRET_HEADER = 'X-Trek-Docsync-Secret';
/**
* What one push may buffer when the core did not say how large the file is.
*
* The body has to become a Buffer (Paperless wants a sized multipart request),
* so an unbounded read would be a memory bomb driven by a remote file. The
* declared size wins where there is one; this is the fallback ceiling.
*/
const PUSH_FALLBACK_MAX_BYTES = 64 * 1024 * 1024;
/**
* What Paperless accepts without Tika. Measured against 3.1.3: an unsupported
* type is refused with `400 … not supported` before a task is created.
*
* Note that Paperless decides on the SNIFFED type of the bytes, not on the
* Content-Type of the multipart part — a .csv arrives as `text/plain` and is
* stored as such. The list is therefore a pre-filter that saves a round trip,
* never the last word; the 400 is still mapped to the same code.
*/
const BASE_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/tiff',
'image/gif',
'image/bmp',
'image/webp',
'image/heic',
'image/heif',
'text/plain',
'text/csv',
'message/rfc822',
] as const;
/**
* Accepted only on an instance that runs Tika and Gotenberg. They stay in the
* list: an instance WITH Tika is a supported setup, and refusing a .docx here
* would drop a file the provider would have taken. Without Tika the upload
* comes back as `unsupported_type` from Paperless itself, which is the same
* verdict one round trip later.
*/
const TIKA_MIME_TYPES = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/rtf',
] as const;
const ACCEPTED_MIME_TYPES: readonly string[] = [...BASE_MIME_TYPES, ...TIKA_MIME_TYPES];
/**
* The extension a Paperless document should carry in TREK.
*
* Paperless keeps a `title` without an extension and the uploaded file name
* separately, while TREK's file list is a list of file names. The extension is
* taken from the original name where there is one and derived from the MIME
* type otherwise, so a document pulled twice keeps the same name.
*/
const EXTENSION_BY_MIME: Readonly<Record<string, string>> = {
'application/pdf': '.pdf',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/tiff': '.tif',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/heic': '.heic',
'image/heif': '.heif',
'text/plain': '.txt',
'text/csv': '.csv',
'message/rfc822': '.eml',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-powerpoint': '.ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'application/vnd.oasis.opendocument.text': '.odt',
'application/vnd.oasis.opendocument.spreadsheet': '.ods',
'application/vnd.oasis.opendocument.presentation': '.odp',
'application/rtf': '.rtf',
};
const KNOWN_EXTENSIONS = new Set(Object.values(EXTENSION_BY_MIME));
/**
* The bare type, without the parameters a stored Content-Type may carry.
* `text/plain; charset=utf-8` is text/plain, and comparing it verbatim against
* the allow-list would refuse a file the provider takes happily.
*/
export function normalizeMime(mimeType: string): string {
const [type = ''] = mimeType.split(';');
return type.trim().toLowerCase();
}
function extensionOf(name: string): string | null {
const match = /\.[A-Za-z0-9]{1,5}$/.exec(name);
return match === null ? null : match[0].toLowerCase();
}
/**
* The file name a document gets in TREK: the title, plus the extension it lost
* on the way into Paperless.
*/
export function documentFileName(doc: PaperlessDocument): string {
const base = doc.title.trim().length > 0
? doc.title.trim()
: (doc.originalFileName ?? `document-${doc.id}`);
const fromOriginal = doc.originalFileName === null ? null : extensionOf(doc.originalFileName);
const fromMime = doc.mimeType === null ? null : (EXTENSION_BY_MIME[doc.mimeType] ?? null);
const extension = fromOriginal ?? fromMime;
if (extension === null) return base;
return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`;
}
/**
* The title for a file name. Only an extension this adapter would have ADDED is
* removed, so a document genuinely called `Vertrag 2026.2` keeps its name.
*/
export function titleFromFileName(name: string): string {
const trimmed = name.trim();
const extension = extensionOf(trimmed);
if (extension === null || !KNOWN_EXTENSIONS.has(extension)) return trimmed;
const base = trimmed.slice(0, trimmed.length - extension.length).trim();
return base.length > 0 ? base : trimmed;
}
export function formatScopeKey(tagId: number): string {
return `${SCOPE_PREFIX}${tagId}`;
}
/** The tag id in a scope key, or null when it is not one of ours. */
export function parseScopeKey(scopeKey: string): number | null {
if (!scopeKey.startsWith(SCOPE_PREFIX)) return null;
const raw = scopeKey.slice(SCOPE_PREFIX.length);
if (!/^\d+$/.test(raw)) return null;
const id = Number.parseInt(raw, 10);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function scopeOption(tag: PaperlessTag): DocumentScopeOption {
return {
scopeKey: formatScopeKey(tag.id),
label: tag.name,
remoteRootId: String(tag.id),
// The filter URL of the instance's own document list: paste it after the
// base URL and you are looking at exactly what TREK syncs.
remoteRootPath: `/documents?tags__id__all=${tag.id}`,
};
}
function toRemoteDocument(doc: PaperlessDocument): RemoteDocument {
const version = currentVersion(doc);
return {
remoteId: String(doc.id),
name: documentFileName(doc),
// Paperless publishes no byte count anywhere in the document serializer;
// the only way to learn it is to download, which is not worth a round trip
// per document per run.
size: null,
mimeType: doc.mimeType,
// `modified` moves on every edit, including the bulk edits that older
// Paperless builds left untouched (3.1.3 does bump it, measured).
remoteVersion: doc.modified ?? doc.added ?? String(doc.id),
contentHash: sha256OrNull(version?.checksum ?? null),
remoteModifiedAt: doc.modified,
isDeleted: doc.deletedAt !== null,
};
}
/**
* Cursor shape. The count travels with the timestamp because Paperless has no
* change feed: a document leaving the tag moves no timestamp at all, and only
* the count notices it.
*/
function buildCursor(count: number | null, documents: PaperlessDocument[]): string {
let newest = '';
for (const doc of documents) {
const modified = doc.modified ?? '';
if (modified > newest) newest = modified;
}
return `v1:${count ?? documents.length}:${newest}`;
}
/** Read a stream into memory, refusing to grow past the cap. */
async function readStreamCapped(
body: Readable,
maxBytes: number,
): Promise<{ bytes: Buffer; truncated: boolean }> {
const chunks: Buffer[] = [];
let received = 0;
for await (const chunk of body as AsyncIterable<Buffer | Uint8Array | string>) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk);
received += buffer.length;
if (received > maxBytes) {
body.destroy();
return { bytes: Buffer.concat(chunks), truncated: true };
}
chunks.push(buffer);
}
return { bytes: Buffer.concat(chunks), truncated: false };
}
@Injectable()
export class PaperlessDocumentProvider implements DocumentProvider {
readonly id = 'paperless';
constructor(private readonly client: PaperlessClient) {}
capabilities(_conn: DocumentConnectionRef): DocumentProviderCapabilities {
return {
// Paperless can subscribe itself: a workflow with a webhook action is
// creatable through the same token, provided its user holds
// `add_workflow`. probe() reports the downgrade when it does not.
push: 'webhook-self-registered',
stableId: true,
remoteTrash: true,
// `update_version` replaces the bytes under the same document id, which
// is what keeps the mapping and the document's history intact.
replaceInPlace: true,
// Every listed document carries the sha256 of its current revision in
// `versions[]`, so no extra round trip is needed to compare content.
contentHashInListing: true,
// Paperless imposes no limit of its own; what a large upload runs into is
// the reverse proxy in front of it, which the adapter cannot know.
maxUploadBytes: null,
acceptedMimeTypes: ACCEPTED_MIME_TYPES,
canCreateScope: true,
};
}
async probe(
conn: DocumentConnectionRef,
): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
try {
const profile = await this.client.probe(creds);
const capabilities = this.capabilities(conn);
// A token whose user may not create workflows can still sync; it just
// cannot subscribe itself, and the operator has to be told that here
// rather than after the first silent hour of polling.
const canSubscribe = profile.permissions.length === 0
|| profile.permissions.includes('add_workflow');
return docOk({
account: profile.username.length > 0 ? profile.username : 'paperless',
capabilities: canSubscribe ? capabilities : { ...capabilities, push: 'webhook-manual' },
});
} catch (err: unknown) {
return failure(err);
}
}
async listScopes(
conn: DocumentConnectionRef,
query?: string,
): Promise<DocResult<DocumentScopeOption[]>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
try {
const tags = await this.client.listTags(creds, query);
return docOk(tags.map(scopeOption));
} catch (err: unknown) {
return failure(err);
}
}
async createScope(
conn: DocumentConnectionRef,
name: string,
): Promise<DocResult<DocumentScopeOption>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
try {
return docOk(scopeOption(await this.client.createTag(creds, name.trim())));
} catch (err: unknown) {
// A name that is already taken comes back as a 400 about the owner/name
// unique constraint. It is not adopted silently: an existing tag may be
// someone else's, and binding a trip to it would publish their documents
// to everyone on that trip.
return failure(err);
}
}
async resolveScope(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<DocumentScopeOption>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
const tagId = parseScopeKey(scope.scopeKey);
if (tagId === null) return badScope(scope.scopeKey);
try {
return docOk(scopeOption(await this.client.getTag(creds, tagId)));
} catch (err: unknown) {
// A tag that is gone is `scope_lost` for the link, not a generic 404 —
// someone deleted or renamed it upstream and a human has to choose again.
if (err instanceof PaperlessError && err.code === 'not_found') {
return docFail('scope_missing', `tag ${tagId} no longer exists`, err.status);
}
return failure(err);
}
}
async list(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<{
documents: RemoteDocument[];
cursor: string | null;
cursorUnchanged: boolean;
truncated: boolean;
}>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
const tagId = parseScopeKey(scope.scopeKey);
if (tagId === null) return badScope(scope.scopeKey);
try {
const { documents, truncated, count } = await this.client.listDocuments(creds, tagId);
const cursor = buildCursor(count, documents);
return docOk({
documents: documents.map(toRemoteDocument),
cursor,
// A truncated walk never claims the scope is unchanged: the pages it
// did not read could hold anything.
cursorUnchanged: !truncated && scope.cursor !== null && scope.cursor === cursor,
truncated,
});
} catch (err: unknown) {
// An empty tag and a deleted tag look alike in a document listing, so the
// tag is only ever confirmed by resolveScope. A 404 here is the documents
// endpoint itself, which is a different problem.
return failure(err);
}
}
async fetch(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<FetchResult>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
if (parseScopeKey(scope.scopeKey) === null) return badScope(scope.scopeKey);
const documentId = parseRemoteId(remoteId);
if (documentId === null) return docFail('not_found', `unreadable document id ${remoteId}`);
try {
// The metadata first: it carries the version marker the core stores
// alongside the bytes, and it fails cheaply when the document is gone.
const doc = await this.client.getDocument(creds, documentId);
const response = await this.client.downloadOriginal(creds, documentId);
if (response.body === null) {
return docFail('provider_error', 'Paperless sent no body for the document');
}
return docOk({
body: Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]),
size: declaredLength(response.headers.get('content-length')),
mimeType: response.headers.get('content-type') ?? doc.mimeType,
remoteVersion: doc.modified ?? doc.added ?? String(doc.id),
});
} catch (err: unknown) {
return failure(err);
}
}
async push(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
req: PushRequest,
): Promise<DocResult<PushResult>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
const tagId = parseScopeKey(scope.scopeKey);
if (tagId === null) return badScope(scope.scopeKey);
const mimeType = normalizeMime(req.mimeType);
if (!ACCEPTED_MIME_TYPES.includes(mimeType)) {
return docFail('unsupported_type', `Paperless does not accept ${req.mimeType}`);
}
const cap = req.size > 0 ? req.size : PUSH_FALLBACK_MAX_BYTES;
let bytes: Buffer;
try {
const read = await readStreamCapped(req.body, cap);
if (read.truncated) {
return docFail('too_large', `the file is larger than the declared ${cap} bytes`);
}
bytes = read.bytes;
} catch (err: unknown) {
return docFail(
'provider_error',
err instanceof Error ? err.message : 'could not read the local file',
);
}
const file = { fileName: req.fileName, mimeType, bytes };
const replacing = req.remoteId !== undefined;
try {
const documentId = replacing
? await this.replace(creds, file, req)
: await this.create(creds, tagId, file, req);
const doc = await this.client.getDocument(creds, documentId);
// Paperless stores the sha256 of what it received. Comparing it here
// turns a truncated upload into a reported mismatch instead of a
// document that silently differs from the one in the trip.
const stored = sha256OrNull(currentVersion(doc)?.checksum ?? null);
if (stored !== null && stored !== req.sha256.toLowerCase()) {
// A document this call created and nobody has seen is litter, so it
// goes to the trash where the owner can still get at it. A REPLACE is
// left alone: that document existed before and its earlier revisions
// are still attached to it.
if (!replacing) await this.discard(creds, doc.id);
return docFail(
'checksum_mismatch',
`Paperless stored ${stored} for document ${doc.id}`,
);
}
return docOk({
remoteId: String(doc.id),
remoteVersion: doc.modified ?? doc.added ?? String(doc.id),
remoteModifiedAt: doc.modified,
// Paperless does not deduplicate: the same bytes uploaded twice become
// two documents with the same checksum and an empty
// `duplicate_documents`. Nothing here may claim otherwise.
deduplicated: false,
});
} catch (err: unknown) {
return failure(err);
}
}
/**
* Trash a document this push created but is not going to report.
*
* Best effort on purpose: the caller is already returning a failure, and a
* second one on top of it would replace a precise diagnosis with the fallout
* of the cleanup.
*/
private async discard(creds: PaperlessCreds, documentId: number): Promise<void> {
try {
await this.client.deleteDocument(creds, documentId);
} catch {
// Nothing to do: the document stays in the archive and the mismatch is
// still what the caller hears about.
}
}
/**
* A new document, with both anchors set in the same request: the tag, which
* is what the listing filters on, and the trip uid in the custom field, which
* outlives someone re-tagging the document by hand.
*
* The field id is resolved per upload rather than cached. It is one small GET
* against a local instance, and a cached id goes stale exactly when it hurts
* most — after a human deleted the field, when every later upload would fail
* with a validation error nobody can read.
*/
private async create(
creds: PaperlessCreds,
tagId: number,
file: { fileName: string; mimeType: string; bytes: Buffer },
req: PushRequest,
): Promise<number> {
const field = (await this.client.findCustomField(creds, TRIP_UID_FIELD_NAME))
?? (await this.client.createCustomField(creds, TRIP_UID_FIELD_NAME));
const taskId = await this.client.postDocument(creds, file, {
title: titleFromFileName(req.fileName),
tagId,
customFields: { [String(field.id)]: req.trekTripUid },
});
try {
return (await this.client.awaitConsume(creds, taskId)).documentId;
} catch (err: unknown) {
// The consume outlived the poll budget. The document may well be there —
// OCR on a long scan takes minutes — and retrying blind would leave two,
// because Paperless accepts duplicate bytes. So the checksum decides.
if (err instanceof PaperlessError && err.code === 'timeout') {
const existing = await this.client.findByChecksum(creds, req.sha256);
const mine = existing.find((doc) => doc.tagIds.includes(tagId)) ?? existing[0];
if (mine !== undefined) return mine.id;
}
throw err;
}
}
/**
* Replace the bytes of a document TREK already knows.
*
* The document id is the one that was passed in, never the one the consume
* task reports: `update_version` files the new revision under a fresh
* internal id that answers 404 on its own, and adopting it would break the
* mapping on the first replace.
*/
private async replace(
creds: PaperlessCreds,
file: { fileName: string; mimeType: string; bytes: Buffer },
req: PushRequest,
): Promise<number> {
const documentId = parseRemoteId(req.remoteId ?? '');
if (documentId === null) {
throw new PaperlessError('not_found', `unreadable document id ${req.remoteId ?? ''}`);
}
const taskId = await this.client.updateVersion(creds, documentId, file);
try {
await this.client.awaitConsume(creds, taskId);
} catch (err: unknown) {
if (!(err instanceof PaperlessError) || err.code !== 'timeout') throw err;
// Same reasoning as create(), one step shorter: the document keeps its
// id, so whether the new revision landed is visible on the document.
const doc = await this.client.getDocument(creds, documentId);
if (sha256OrNull(currentVersion(doc)?.checksum ?? null) !== req.sha256.toLowerCase()) {
throw err;
}
}
return documentId;
}
async rename(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
name: string,
): Promise<DocResult<{ remoteVersion: string }>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
if (parseScopeKey(scope.scopeKey) === null) return badScope(scope.scopeKey);
const documentId = parseRemoteId(remoteId);
if (documentId === null) return docFail('not_found', `unreadable document id ${remoteId}`);
try {
// Only the title moves. The uploaded file name is immutable in Paperless,
// and the archived file it serves is named after the title anyway.
const doc = await this.client.patchDocument(creds, documentId, {
title: titleFromFileName(name),
});
return docOk({ remoteVersion: doc.modified ?? doc.added ?? String(doc.id) });
} catch (err: unknown) {
return failure(err);
}
}
async trash(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<void>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
if (parseScopeKey(scope.scopeKey) === null) return badScope(scope.scopeKey);
const documentId = parseRemoteId(remoteId);
if (documentId === null) return docFail('not_found', `unreadable document id ${remoteId}`);
try {
await this.client.deleteDocument(creds, documentId);
return docOk(undefined);
} catch (err: unknown) {
// Already in the trash is the state that was asked for.
if (err instanceof PaperlessError && err.code === 'not_found') return docOk(undefined);
return failure(err);
}
}
async registerWebhook(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
callbackUrl: string,
secret: string,
): Promise<DocResult<{ subscriptionId: string }>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
const tagId = parseScopeKey(scope.scopeKey);
if (tagId === null) return badScope(scope.scopeKey);
try {
const workflowId = await this.client.createWorkflow(creds, {
name: `TREK document sync (link ${scope.linkId})`,
tagId,
callbackUrl,
secretHeader: { name: PAPERLESS_WEBHOOK_SECRET_HEADER, value: secret },
// Whatever Paperless sends is untrusted and only means "look now", so
// the payload carries the link and nothing about the document. It could
// not carry more: placeholders are not substituted in a webhook payload.
params: { linkId: String(scope.linkId), tripId: String(scope.tripId) },
});
return docOk({ subscriptionId: String(workflowId) });
} catch (err: unknown) {
return failure(err);
}
}
async unregisterWebhook(
conn: DocumentConnectionRef,
subscriptionId: string,
): Promise<DocResult<void>> {
const creds = credsOf(conn);
if (creds === null) return missingToken();
const workflowId = parseRemoteId(subscriptionId);
if (workflowId === null) {
return docFail('not_found', `unreadable subscription id ${subscriptionId}`);
}
try {
await this.client.deleteWorkflow(creds, workflowId);
return docOk(undefined);
} catch (err: unknown) {
return failure(err);
}
}
}
function credsOf(conn: DocumentConnectionRef): PaperlessCreds | null {
const token = conn.secrets.api_token ?? '';
if (token.trim().length === 0) return null;
return {
baseUrl: conn.baseUrl,
token: token.trim(),
allowInsecureTls: conn.allowInsecureTls,
};
}
/**
* The byte count a download announced, or null.
*
* An absent header must not become 0: `Number('')` is 0, and a size of zero
* reads as "an empty file" to everything downstream. Paperless omits the header
* whenever the response is compressed on the way out.
*/
function declaredLength(header: string | null): number | null {
if (header === null || header.trim().length === 0) return null;
const value = Number(header);
return Number.isFinite(value) && value >= 0 ? value : null;
}
function parseRemoteId(remoteId: string): number | null {
if (!/^\d+$/.test(remoteId.trim())) return null;
const id = Number.parseInt(remoteId.trim(), 10);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function missingToken<T>(): DocResult<T> {
return docFail<T>('unauthorized', 'this connection has no Paperless API token');
}
function badScope<T>(scopeKey: string): DocResult<T> {
return docFail<T>('scope_missing', `not a Paperless scope key: ${scopeKey}`);
}
/**
* Every failure leaves through here, so a stray upstream exception cannot
* escape as an `unknown` with an English sentence in it.
*/
function failure<T>(err: unknown): DocResult<T> {
if (err instanceof PaperlessError) {
return docFail<T>(err.code, err.detail ?? err.message, err.status);
}
return docFail<T>('unknown', err instanceof Error ? err.message : undefined);
}
@@ -0,0 +1,762 @@
import { exceedsDeclaredLength, discardBody, readCappedJson } from '../../../utils/cappedFetch';
import { safeFetch, SsrfBlockedError } from '../../../utils/ssrfGuard';
import type { DocsyncErrorCode } from '@trek/shared';
import crypto from 'node:crypto';
import { Readable, Transform } from 'node:stream';
/**
* Thin HTTP client for the Papra REST API (github.com/papra-hq/papra).
* This is the ONLY place that talks to a Papra instance.
*
* Verified against a running 26.6.2 instance on 2026-09-18, because Papra's
* prose documentation is silent on most of what a two-way sync depends on:
*
* - **Auth** is `Authorization: Bearer ppapi_…`. A key carries per-verb scopes
* (`documents:read`, `tags:create`, …) and a missing scope answers **401
* `auth.unauthorized`**, exactly like a wrong key — there is no 403 to tell
* the two apart, so a failed call names the route it failed on instead.
* - **No timestamp is usable for change detection.** `updatedAt` did not move
* when a tag was added, when the document was renamed, or when it was
* trashed; only `createdAt` and `deletedAt` are ever written. Hence
* `snapshotVersion()` — a hash over a fixed field order — rather than an
* ETag or a modified time.
* - **Uploads deduplicate on the sha256 of the bytes**, not on the name. An
* identical file answers 409 `document.already_exists`, and if the twin is
* in the trash it is **restored** and returned with its ORIGINAL id under a
* plain 200. Both are `deduplicated` to the sync core; see `uploadDocument`.
* - **The search grammar needs quoting**: `tag:"Trek Reise 2026"` matches,
* `tag:Trek Reise 2026` matches nothing. It is an exact, case-insensitive
* match on the tag name (`tag:trek-trip-4` does not find `trek-trip-42`),
* and it cannot search a sha256 at all.
* - **`custom-properties`, `webhooks` and the restore and hard-delete routes
* are session-only.** With a valid API key they answer 401; with a browser
* session the same request succeeds. So there is no anchor property to write
* and no subscription TREK can register for itself.
* - `pageSize` caps at 100 (101 is a 400), `pageIndex` is 0-based, and
* `documentsCount` is the total rather than the length of the page. The
* trash listing takes those two parameters and refuses every other one.
*/
const TIMEOUT_MS = 20000;
/** Uploads and downloads move real files, so they get their own budget. */
const TRANSFER_TIMEOUT_MS = 180000;
/** Papra's JSON answers are documents metadata; a page of 100 is tens of kilobytes. */
const MAX_JSON_BYTES = 8 * 1024 * 1024;
/**
* A runaway guard on a download, not the sync core's file-size policy — that
* one lives in the core, which knows what it is willing to store. This only has
* to stop an instance that answers a document request with an endless body.
*/
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
/** Papra refuses anything above this outright. */
export const PAPRA_PAGE_SIZE = 100;
/**
* How many pages one enumeration walks before reporting itself incomplete.
*
* Papra has no change feed, so a sync is a full walk of the tag every time. A
* cap rather than a loop: a caller told the answer is partial can keep the
* previous state instead of reading absence as deletion.
*/
export const PAPRA_MAX_PAGES = 50;
/** Papra requires a colour on every tag, so scope creation has to pick one. */
export const PAPRA_DEFAULT_TAG_COLOR = '#4F46E5';
/** `org_` + 24 lowercase alphanumerics — Papra 400s on anything else. */
const ORG_ID_PATTERN = /^org_[a-z0-9]{24}$/;
export interface PapraCreds {
/** Instance origin. The client appends `/api` itself. */
baseUrl: string;
apiKey: string;
organizationId: string;
allowInsecureTls: boolean;
}
export interface PapraTag {
id: string;
name: string;
color: string | null;
description: string | null;
documentsCount: number | null;
}
export interface PapraDocument {
id: string;
name: string;
originalName: string | null;
originalSize: number | null;
/** Real sha256 of the stored bytes, and the key Papra deduplicates on. */
originalSha256Hash: string | null;
mimeType: string | null;
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
isDeleted: boolean;
/**
* Absent from the upload and rename answers, present in every listing. Null
* therefore means "not stated here", never "no tags".
*/
tags: PapraTag[] | null;
}
export interface PapraPage {
documents: PapraDocument[];
/** Total across all pages, not the length of this one. */
total: number;
}
export interface PapraUploadResult {
document: PapraDocument;
/**
* True when Papra answered with a document it already held: a trashed twin it
* restored under its original id, or, on the 409 path, the live twin this
* client then looked up.
*/
deduplicated: boolean;
}
export interface PapraDownload {
body: Readable;
/** From `content-length`, which Papra does send on the file route. */
size: number | null;
}
/**
* A failed Papra call, carrying the reason as a sync-core error code.
*
* The upstream message is English and occasionally an HTML error page from a
* reverse proxy, so it travels in `detail` for the self-hoster's log while the
* code is what the user sees through i18n. `papraCode` is kept separately
* because several branches turn on it — `document.already_exists` is a dedup,
* not a failure, and `tags.not_found` is a lost scope rather than a bad id.
*/
export class PapraError extends Error {
readonly code: DocsyncErrorCode;
readonly status?: number;
readonly detail?: string;
readonly papraCode?: string;
constructor(code: DocsyncErrorCode, message: string, status?: number, detail?: string, papraCode?: string) {
super(message);
this.name = 'PapraError';
this.code = code;
this.status = status;
this.detail = detail;
this.papraCode = papraCode;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function str(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function parseTag(value: unknown): PapraTag | null {
if (!isRecord(value)) return null;
const id = str(value.id);
const name = str(value.name);
if (!id || name === null) return null;
return {
id,
name,
color: str(value.color),
description: str(value.description),
documentsCount: num(value.documentsCount),
};
}
function parseDocument(value: unknown): PapraDocument | null {
if (!isRecord(value)) return null;
const id = str(value.id);
if (!id) return null;
const rawTags = value.tags;
let tags: PapraTag[] | null = null;
if (Array.isArray(rawTags)) {
tags = [];
for (const entry of rawTags) {
const tag = parseTag(entry);
if (tag) tags.push(tag);
}
}
return {
id,
// Papra has always sent a name; falling back to the original one keeps a
// stripped-down answer usable instead of dropping the whole document.
name: str(value.name) ?? str(value.originalName) ?? id,
originalName: str(value.originalName),
originalSize: num(value.originalSize),
originalSha256Hash: str(value.originalSha256Hash),
mimeType: str(value.mimeType),
createdAt: str(value.createdAt),
updatedAt: str(value.updatedAt),
deletedAt: str(value.deletedAt),
isDeleted: value.isDeleted === true,
tags,
};
}
/** The `{ error: { message, code } }` envelope Papra answers every 4xx with. */
function parseErrorBody(body: unknown): { message: string | null; code: string | null } {
if (!isRecord(body) || !isRecord(body.error)) return { message: null, code: null };
return { message: str(body.error.message), code: str(body.error.code) };
}
/**
* `https://host/papra/api/` → `https://host/papra/api`, tolerating what people
* paste. Without it a pasted `…/api` becomes `…/api/api` and every call 404s.
*/
export function papraApiBase(baseUrl: string): string {
const origin = baseUrl
.trim()
.replace(/(?<!\/)\/+$/, '')
.replace(/\/api$/i, '');
return `${origin}/api`;
}
/**
* Stable snapshot hash over a FIXED field order, standing in for the version
* marker Papra does not have. `updatedAt` is in it although it never moves: it
* costs nothing and it is the field that would start carrying the signal if a
* later Papra ever began writing it.
*/
export function snapshotVersion(doc: PapraDocument): string {
const snapshot = [doc.id, doc.name, doc.originalSha256Hash ?? '', doc.updatedAt ?? ''];
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
}
/**
* A tag name as the search grammar wants it: quoted, because an unquoted name
* with a space matches nothing at all.
*
* Returns null for a name the grammar cannot express — Papra accepts `"` in a
* tag name and offers no escape for it — so the caller can fall back to
* enumerating the organisation and filtering by tag id.
*/
export function tagSearchQuery(tagName: string): string | null {
if (tagName.includes('"') || tagName.includes('\\')) return null;
return `tag:"${tagName}"`;
}
/**
* Papra's status codes mapped onto the sync core's vocabulary.
*
* 400 on a path parameter is a `not_found` rather than a provider fault: the
* ids are format-checked upstream, so a remote id Papra refuses to parse is one
* that cannot exist. The organisation id is validated here before the call, so
* it can never be the parameter Papra is complaining about.
*/
function classify(status: number, papraCode: string | null): DocsyncErrorCode {
if (papraCode === 'tags.not_found') return 'scope_missing';
if (status === 400) return papraCode === 'server.invalid_request.params' ? 'not_found' : 'provider_error';
if (status === 401) return 'unauthorized';
if (status === 403) return 'forbidden';
if (status === 404) return 'not_found';
if (status === 409) return 'conflict';
if (status === 413) return 'too_large';
if (status === 415) return 'unsupported_type';
if (status === 429) return 'rate_limited';
if (status === 507) return 'quota_exceeded';
return 'provider_error';
}
/** Why a request never produced a response at all. */
function classifyTransport(err: unknown): PapraError {
if (err instanceof SsrfBlockedError) {
return new PapraError('ssrf_blocked', 'The Papra URL is not allowed', undefined, err.message);
}
const detail = err instanceof Error ? err.message : String(err);
const name = err instanceof Error ? err.name : '';
if (name === 'TimeoutError' || name === 'AbortError') {
return new PapraError('timeout', 'Papra did not answer in time', undefined, detail);
}
// undici buries the certificate verdict in a cause chain, so the whole chain
// is searched rather than the top-level message, which is a bare "fetch failed".
if (/self-signed|self signed|CERT_|ERR_TLS|unable to verify|certificate/i.test(causeChain(err))) {
return new PapraError('tls_untrusted', 'Papra presented a certificate that is not trusted', undefined, detail);
}
return new PapraError('unreachable', 'Could not reach Papra', undefined, detail);
}
function causeChain(err: unknown): string {
const parts: string[] = [];
let current: unknown = err;
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
parts.push(current.message, String((current as { code?: unknown }).code ?? ''));
current = current.cause;
}
return parts.join(' ');
}
/**
* A stream that fails instead of growing past `maxBytes`.
*
* The download is handed to the caller as a stream rather than buffered, so the
* declared-length check is only half a cap: a chunked answer declares nothing.
* This is the other half.
*/
function capStream(source: Readable, maxBytes: number): Readable {
let seen = 0;
const limiter = new Transform({
transform(chunk: Buffer, _encoding, done) {
seen += chunk.length;
if (seen > maxBytes) {
done(new Error(`Papra document exceeded ${maxBytes} bytes`));
return;
}
done(null, chunk);
},
});
source.on('error', (err) => limiter.destroy(err));
return source.pipe(limiter);
}
/** RFC 7578 as undici's own FormData writes it: UTF-8 bytes, three characters escaped. */
function escapeFormValue(value: string): string {
return value.replace(/\r/g, '%0D').replace(/\n/g, '%0A').replace(/"/g, '%22');
}
/** The bytes before the file in a one-field multipart body. Exported so the tests can read them. */
export function multipartHeader(boundary: string, fileName: string, mimeType: string): Buffer {
return Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${escapeFormValue(fileName)}"\r\n` +
`Content-Type: ${mimeType}\r\n\r\n`,
'utf8',
);
}
export function multipartFooter(boundary: string): Buffer {
return Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
}
export class PapraClient {
/**
* One authenticated JSON call.
*
* `expectedEmpty` covers the 204s (tagging, some deletes): those have no body
* to parse and asking for one would turn a success into a parse failure.
*/
private async call(
creds: PapraCreds,
method: string,
path: string,
options: { query?: Record<string, string>; json?: unknown; expectEmpty?: boolean } = {},
): Promise<unknown> {
const url = new URL(papraApiBase(creds.baseUrl) + path);
for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value);
const headers: Record<string, string> = {
Authorization: `Bearer ${creds.apiKey}`,
Accept: 'application/json',
};
if (options.json !== undefined) headers['Content-Type'] = 'application/json';
let response: Response;
try {
response = await safeFetch(
url.toString(),
{
method,
headers,
body: options.json === undefined ? undefined : JSON.stringify(options.json),
signal: AbortSignal.timeout(TIMEOUT_MS) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw classifyTransport(err);
}
return this.readBody(response, `${method} ${path}`, options.expectEmpty === true);
}
/** Shared tail of every JSON call: cap the body, parse it, turn a non-2xx into a PapraError. */
private async readBody(response: Response, route: string, expectEmpty: boolean): Promise<unknown> {
const body = response.status === 204 ? null : await readCappedJson<unknown>(response, MAX_JSON_BYTES);
if (!response.ok) {
const { message, code } = parseErrorBody(body);
throw new PapraError(
classify(response.status, code),
`Papra answered HTTP ${response.status} on ${route}`,
response.status,
message ?? undefined,
code ?? undefined,
);
}
if (expectEmpty) return null;
if (body === undefined) {
throw new PapraError(
'provider_error',
'Papra returned something that is not JSON',
response.status,
`on ${route}; check the URL points at Papra itself and not at a login page in front of it`,
);
}
return body;
}
/**
* The organisation the connection names, checked before it is put in a URL.
*
* Papra answers a malformed organisation id with the same 400 shape it uses
* for a malformed document id, and those two mean very different things to a
* caller. Settling it here keeps the wire-level mapping unambiguous.
*/
private orgPath(creds: PapraCreds): string {
if (!ORG_ID_PATTERN.test(creds.organizationId)) {
throw new PapraError(
'provider_error',
'The organisation id is not in Papra’s format',
undefined,
`expected org_ followed by 24 lowercase alphanumerics, got "${creds.organizationId}"`,
);
}
return `/organizations/${creds.organizationId}`;
}
/**
* The organisations the key can reach, for the connection's display name.
*
* Separate from the reachability test on purpose: this needs the
* `organizations:read` scope, which a key limited to documents and tags does
* not carry, and a connection that syncs perfectly well should not fail its
* probe over a missing label.
*/
async listOrganizations(creds: PapraCreds): Promise<Array<{ id: string; name: string }>> {
const body = await this.call(creds, 'GET', '/organizations');
const raw = isRecord(body) && Array.isArray(body.organizations) ? body.organizations : [];
const out: Array<{ id: string; name: string }> = [];
for (const entry of raw) {
if (!isRecord(entry)) continue;
const id = str(entry.id);
if (!id) continue;
out.push({ id, name: str(entry.name) ?? id });
}
return out;
}
async listTags(creds: PapraCreds): Promise<PapraTag[]> {
const body = await this.call(creds, 'GET', `${this.orgPath(creds)}/tags`);
const raw = isRecord(body) && Array.isArray(body.tags) ? body.tags : [];
const tags: PapraTag[] = [];
for (const entry of raw) {
const tag = parseTag(entry);
if (tag) tags.push(tag);
}
return tags;
}
async createTag(creds: PapraCreds, name: string, color: string): Promise<PapraTag> {
const body = await this.call(creds, 'POST', `${this.orgPath(creds)}/tags`, { json: { name, color } });
const tag = isRecord(body) ? parseTag(body.tag) : null;
if (!tag) throw new PapraError('provider_error', 'Papra created a tag but did not describe it');
return tag;
}
/**
* One page of documents.
*
* Sorted by `createdAt` ascending rather than descending: a document uploaded
* while the walk is in flight then lands at the END of the last page, where
* missing it costs one sync cycle. Descending puts it at the front and pushes
* every later page down by one, which silently skips an existing document.
*
* The trash route takes `pageIndex` and `pageSize` and NOTHING else — it
* rejects a sort or a search with 400 `Invalid key: Expected never` rather
* than ignoring it — so the sort and the query are dropped there.
*/
async listDocuments(
creds: PapraCreds,
options: { searchQuery?: string; pageIndex: number; deleted?: boolean },
): Promise<PapraPage> {
const trash = options.deleted === true;
const query: Record<string, string> = {
pageIndex: String(options.pageIndex),
pageSize: String(PAPRA_PAGE_SIZE),
};
if (!trash) {
query.sortField = 'createdAt';
query.sortOrder = 'asc';
if (options.searchQuery) query.searchQuery = options.searchQuery;
}
const path = `${this.orgPath(creds)}/documents${trash ? '/deleted' : ''}`;
const body = await this.call(creds, 'GET', path, { query });
const raw = isRecord(body) && Array.isArray(body.documents) ? body.documents : [];
const documents: PapraDocument[] = [];
for (const entry of raw) {
const doc = parseDocument(entry);
if (doc) documents.push(doc);
}
const total = isRecord(body) ? num(body.documentsCount) : null;
return { documents, total: total ?? documents.length };
}
/**
* Every page, up to the cap.
*
* `truncated` is the honest half of the answer: the sync core reads absence
* from a listing as a possible deletion, so a walk that stopped early has to
* say so or a capped enumeration would look like a mass delete.
*/
async listAllDocuments(
creds: PapraCreds,
options: { searchQuery?: string; deleted?: boolean } = {},
): Promise<{ documents: PapraDocument[]; truncated: boolean }> {
const documents: PapraDocument[] = [];
let truncated = false;
for (let pageIndex = 0; pageIndex < PAPRA_MAX_PAGES; pageIndex++) {
const page = await this.listDocuments(creds, { ...options, pageIndex });
documents.push(...page.documents);
if (page.documents.length < PAPRA_PAGE_SIZE) break;
if (documents.length >= page.total) break;
if (pageIndex === PAPRA_MAX_PAGES - 1) truncated = true;
}
return { documents, truncated };
}
async getDocument(creds: PapraCreds, documentId: string): Promise<PapraDocument> {
const body = await this.call(creds, 'GET', `${this.orgPath(creds)}/documents/${encodeURIComponent(documentId)}`);
const doc = isRecord(body) ? parseDocument(body.document) : null;
if (!doc) throw new PapraError('provider_error', 'Papra returned a document in an unexpected shape');
return doc;
}
/**
* The stored bytes.
*
* The response's own `content-type` is always `application/octet-stream`, so
* the caller takes the MIME type from the document record instead of from the
* transfer.
*/
async downloadDocument(creds: PapraCreds, documentId: string): Promise<PapraDownload> {
const url = `${papraApiBase(creds.baseUrl) + this.orgPath(creds)}/documents/${encodeURIComponent(documentId)}/file`;
let response: Response;
try {
response = await safeFetch(
url,
{
method: 'GET',
headers: { Authorization: `Bearer ${creds.apiKey}` },
signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw classifyTransport(err);
}
if (!response.ok) {
const body = await readCappedJson<unknown>(response, MAX_JSON_BYTES);
const { message, code } = parseErrorBody(body);
throw new PapraError(
classify(response.status, code),
`Papra answered HTTP ${response.status} on the document file`,
response.status,
message ?? undefined,
code ?? undefined,
);
}
if (exceedsDeclaredLength(response, MAX_DOWNLOAD_BYTES)) {
discardBody(response);
throw new PapraError('too_large', 'The Papra document is larger than TREK will transfer', response.status);
}
if (!response.body) {
throw new PapraError('provider_error', 'Papra answered the document file with no body', response.status);
}
const declared = Number(response.headers.get('content-length') ?? '');
return {
body: capStream(Readable.fromWeb(response.body), MAX_DOWNLOAD_BYTES),
size: Number.isFinite(declared) && declared >= 0 ? declared : null,
};
}
/**
* Store bytes as a new document.
*
* Streamed rather than buffered: the sync core hands over whatever is in trip
* storage, and holding a document in memory to build a multipart body is the
* one thing a file sync must not do. The body is assembled by hand because
* `FormData` would want the bytes first.
*
* Two answers are not what they look like. A 200 carrying a `createdAt` from
* before this request is a trashed twin Papra restored under its original id,
* and a 409 `document.already_exists` is a live twin — neither is a new
* document, and the caller resolves the second one through `findByHash`.
*/
async uploadDocument(
creds: PapraCreds,
file: { body: Readable; fileName: string; mimeType: string },
): Promise<PapraUploadResult> {
const boundary = `----trek${crypto.randomBytes(16).toString('hex')}`;
const header = multipartHeader(boundary, file.fileName, file.mimeType);
const footer = multipartFooter(boundary);
async function* frame(): AsyncGenerator<Buffer> {
yield header;
for await (const chunk of file.body) {
yield Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
}
yield footer;
}
const sentAt = Date.now();
let response: Response;
try {
response = await safeFetch(
`${papraApiBase(creds.baseUrl) + this.orgPath(creds)}/documents`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${creds.apiKey}`,
Accept: 'application/json',
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: Readable.toWeb(Readable.from(frame())),
// Required by undici for a streamed request body; the platform types
// carry the field, the DOM lib this project does not load does not.
duplex: 'half',
signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw classifyTransport(err);
}
const elapsedMs = Date.now() - sentAt;
const body = await this.readBody(response, 'POST /documents', false);
const document = isRecord(body) ? parseDocument(body.document) : null;
if (!document)
throw new PapraError('provider_error', 'Papra accepted the upload but did not describe the document');
return {
document,
deduplicated: isRestoredTwin(document, response.headers.get('date'), elapsedMs),
};
}
/**
* The document holding these bytes, for the 409 path.
*
* Three widening passes because Papra cannot search a hash: the name the
* upload used, then every live document, then the trash. The name pass is
* almost always enough and costs one request; the others exist because a twin
* can have been stored under a different name entirely, which is exactly the
* case where guessing would pair the wrong document.
*/
async findByHash(creds: PapraCreds, sha256: string, nameHint?: string): Promise<PapraDocument | null> {
const wanted = sha256.toLowerCase();
const match = (docs: PapraDocument[]): PapraDocument | null =>
docs.find((doc) => doc.originalSha256Hash?.toLowerCase() === wanted) ?? null;
if (nameHint) {
const byName = await this.listAllDocuments(creds, { searchQuery: nameHint });
const hit = match(byName.documents);
if (hit) return hit;
}
const live = await this.listAllDocuments(creds);
const hit = match(live.documents);
if (hit) return hit;
const trashed = await this.listAllDocuments(creds, { deleted: true });
return match(trashed.documents);
}
/**
* Put a tag on a document.
*
* An already-present tag answers 409 `documents.already_has_tag`, which is the
* state the caller wanted, so it is swallowed here rather than left for every
* call site to recognise.
*/
async addTag(creds: PapraCreds, documentId: string, tagId: string): Promise<void> {
try {
await this.call(creds, 'POST', `${this.orgPath(creds)}/documents/${encodeURIComponent(documentId)}/tags`, {
json: { tagId },
expectEmpty: true,
});
} catch (err: unknown) {
if (err instanceof PapraError && err.papraCode === 'documents.already_has_tag') return;
throw err;
}
}
async renameDocument(creds: PapraCreds, documentId: string, name: string): Promise<PapraDocument> {
const body = await this.call(creds, 'PATCH', `${this.orgPath(creds)}/documents/${encodeURIComponent(documentId)}`, {
json: { name },
});
const doc = isRecord(body) ? parseDocument(body.document) : null;
if (!doc) throw new PapraError('provider_error', 'Papra renamed the document but did not describe it');
return doc;
}
/**
* Move a document to Papra's trash.
*
* This is the only destructive call the API offers a key: permanent deletion
* and restoring are session-only, and `?permanent=true` is accepted and then
* ignored, which is worse than being refused. Papra keeps trashed documents
* for its configured retention window, so a wrong propagation stays
* recoverable through its web interface.
*/
async trashDocument(creds: PapraCreds, documentId: string): Promise<void> {
await this.call(creds, 'DELETE', `${this.orgPath(creds)}/documents/${encodeURIComponent(documentId)}`, {
expectEmpty: true,
});
}
}
/**
* Whether a 200 from the upload route describes a document Papra already held —
* a trashed twin it silently restored under its original id.
*
* Papra says nothing about which of the two happened, so this has to be read
* off `createdAt`: a restored document was created before this request started.
* That start is not a time this process knows in Papra's clock, so it is
* reconstructed from the answer as `Date` minus how long the call took.
* Comparing a duration instead of two clocks is what makes the test survive a
* self-hosted instance whose clock is minutes out.
*
* `Date` carries whole seconds, so the reconstruction always lands at or BEFORE
* the true start. That asymmetry is chosen rather than tolerated: a document
* this request really did create can never be mistaken for a restore, and the
* price is blindness to a twin trashed and re-pushed within roughly a second of
* being created. Nothing else in the answer closes that second — the name is
* not a tiebreaker, because a restore overwrites the stored name with the
* filename of the upload that triggered it.
*
* Without a `Date` header at all there is nothing to anchor to and the local
* clock has to stand in, which is why that branch needs a tolerance.
*/
function isRestoredTwin(doc: PapraDocument, dateHeader: string | null, elapsedMs: number): boolean {
if (!doc.createdAt) return false;
const created = Date.parse(doc.createdAt);
if (!Number.isFinite(created)) return false;
const answeredAt = dateHeader ? Date.parse(dateHeader) : Number.NaN;
if (Number.isFinite(answeredAt)) return created < answeredAt - elapsedMs;
return created < Date.now() - elapsedMs - UNANCHORED_CLOCK_TOLERANCE_MS;
}
/** How far a headerless instance's clock may sit from this one before dedup detection misfires. */
const UNANCHORED_CLOCK_TOLERANCE_MS = 30000;
@@ -0,0 +1,393 @@
import {
docFail,
docOk,
type DocResult,
type DocumentConnectionRef,
type DocumentProvider,
type DocumentProviderCapabilities,
type DocumentScopeOption,
type DocumentScopeRef,
type FetchResult,
type PushRequest,
type PushResult,
type RemoteDocument,
} from '../document-provider';
import {
PapraClient,
PapraError,
PAPRA_DEFAULT_TAG_COLOR,
snapshotVersion,
tagSearchQuery,
type PapraCreds,
type PapraDocument,
type PapraTag,
} from './papra.client';
import { Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
/**
* Papra as a document scope: one organisation, one tag per trip.
*
* Papra has no folders, so the container a trip binds to is a tag — which is
* why the scope key carries both halves (`org:…/tag:…`). The organisation is in
* there although it is also on the connection: an API key reaches every
* organisation its owner belongs to, so a binding that only named the tag would
* silently follow the connection if its organisation were ever re-pointed.
*
* Two capabilities are `false`/`manual` because of what Papra withholds from an
* API key rather than what it cannot do. Webhook CRUD and the restore and
* hard-delete routes answer 401 to a key and 200 to a browser session, so TREK
* cannot subscribe itself, and there is no endpoint that replaces the bytes of
* an existing document at all.
*/
@Injectable()
export class PapraProvider implements DocumentProvider {
readonly id = 'papra';
private readonly client = new PapraClient();
capabilities(_conn: DocumentConnectionRef): DocumentProviderCapabilities {
return {
// The API exists, but only a browser session may call it.
push: 'webhook-manual',
stableId: true,
remoteTrash: true,
// No endpoint replaces a document's bytes. A new revision is a new
// document, and an identical one is deduplicated into the old.
replaceInPlace: false,
// `originalSha256Hash` is a real sha256 of the stored bytes.
contentHashInListing: true,
// The ceiling is DOCUMENT_STORAGE_MAX_UPLOAD_SIZE on the instance and is
// not exposed anywhere in the API, so claiming Papra's 10 MB default here
// would refuse uploads a tuned instance accepts. An oversized one comes
// back as 413 `too_large` instead.
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
}
async probe(
conn: DocumentConnectionRef,
): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>> {
return this.withCreds(conn, async (creds) => {
// Tags rather than `/organizations`: it is the narrowest call that proves
// all three things at once — the instance answers, the key is valid, and
// the key's owner is in this organisation.
await this.client.listTags(creds);
return { account: await this.accountLabel(creds), capabilities: this.capabilities(conn) };
});
}
async listScopes(conn: DocumentConnectionRef, query?: string): Promise<DocResult<DocumentScopeOption[]>> {
return this.withCreds(conn, async (creds) => {
const tags = await this.client.listTags(creds);
const needle = query?.trim().toLowerCase();
return tags
.filter((tag) => !needle || tag.name.toLowerCase().includes(needle))
.map((tag) => toScopeOption(creds.organizationId, tag));
});
}
async createScope(conn: DocumentConnectionRef, name: string): Promise<DocResult<DocumentScopeOption>> {
return this.withCreds(conn, async (creds) => {
// A name collision is reported rather than resolved to the existing tag:
// that tag may be someone else's filing, and the picker already lists it
// for an admin who meant to bind to it.
const tag = await this.client.createTag(creds, name, PAPRA_DEFAULT_TAG_COLOR);
return toScopeOption(creds.organizationId, tag);
});
}
async resolveScope(conn: DocumentConnectionRef, scope: DocumentScopeRef): Promise<DocResult<DocumentScopeOption>> {
return this.withCreds(conn, async (creds) => {
const tag = await this.requireTag(creds, scope);
return toScopeOption(creds.organizationId, tag);
});
}
async list(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<
DocResult<{ documents: RemoteDocument[]; cursor: string | null; cursorUnchanged: boolean; truncated: boolean }>
> {
return this.withCreds(conn, async (creds) => {
const tag = await this.requireTag(creds, scope);
const searchQuery = tagSearchQuery(tag.name);
const walked = await this.client.listAllDocuments(creds, searchQuery === null ? {} : { searchQuery });
const documents = walked.documents.filter((doc) => inScope(doc, tag.id, searchQuery !== null)).map(toRemote);
// A capped walk saw part of the tag, and the core reads absence as a
// possible deletion. Reporting no cursor keeps the incomplete picture out
// of the agreed state instead of pinning it as the new truth.
if (walked.truncated) {
return { documents, cursor: null, cursorUnchanged: false, truncated: true };
}
const cursor = enumerationDigest(documents);
return { documents, cursor, cursorUnchanged: scope.cursor === cursor, truncated: false };
});
}
/**
* The scope is not re-checked here. Every route is organisation-scoped
* upstream, so a document from elsewhere cannot be reached, and a document
* that has lost its tag is exactly the one the core is fetching to resolve
* the drift.
*/
async fetch(
conn: DocumentConnectionRef,
_scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<FetchResult>> {
return this.withCreds(conn, async (creds) => {
// The document first: the file route answers
// `content-type: application/octet-stream` for everything, so the real
// MIME type and the version marker only exist on the record.
const doc = await this.client.getDocument(creds, remoteId);
const download = await this.client.downloadDocument(creds, remoteId);
return {
body: download.body,
size: download.size ?? doc.originalSize,
mimeType: doc.mimeType,
remoteVersion: snapshotVersion(doc),
};
});
}
/**
* Store a trip file as a Papra document and tag it into the trip's scope.
*
* `trekDocUid`/`trekTripUid` are not written anywhere: Papra's custom
* properties are session-only, so a key has nowhere to put an anchor. The tag
* is the whole binding, and a human who removes it upstream detaches the
* document — which is `scope_drift` in the core, not a silent loss.
*
* The tag goes on in a second call because the upload route ignores every
* multipart field except the file. That leaves a window in which the document
* exists untagged; it closes on the next call, and a concurrent listing that
* misses it picks it up on the following run.
*/
async push(conn: DocumentConnectionRef, scope: DocumentScopeRef, req: PushRequest): Promise<DocResult<PushResult>> {
return this.withCreds(conn, async (creds) => {
if (req.remoteId !== undefined) {
throw new PapraError(
'provider_error',
'Papra has no endpoint that replaces the bytes of a document; push a new one instead (replaceInPlace is false)',
);
}
const tag = await this.requireTag(creds, scope);
let document: PapraDocument;
let deduplicated: boolean;
try {
const uploaded = await this.client.uploadDocument(creds, {
body: req.body,
fileName: req.fileName,
mimeType: req.mimeType,
});
document = uploaded.document;
deduplicated = uploaded.deduplicated;
} catch (err: unknown) {
if (!(err instanceof PapraError) || err.papraCode !== 'document.already_exists') throw err;
// Papra refused the bytes because it already holds them. The twin is
// the document this push is about, so it has to be found before the tag
// can go on — and it is found by hash, because a twin stored under
// another name would otherwise pair the wrong document.
const twin = await this.client.findByHash(creds, req.sha256, req.fileName);
if (!twin) {
throw new PapraError(
'conflict',
'Papra holds these bytes already but did not return the document that has them',
err.status,
err.detail,
err.papraCode,
);
}
document = twin;
deduplicated = true;
}
await this.client.addTag(creds, document.id, tag.id);
return {
remoteId: document.id,
remoteVersion: snapshotVersion(document),
remoteModifiedAt: document.updatedAt ?? document.createdAt,
deduplicated,
};
});
}
async rename(
conn: DocumentConnectionRef,
_scope: DocumentScopeRef,
remoteId: string,
name: string,
): Promise<DocResult<{ remoteVersion: string }>> {
return this.withCreds(conn, async (creds) => {
const doc = await this.client.renameDocument(creds, remoteId, name);
return { remoteVersion: snapshotVersion(doc) };
});
}
async trash(conn: DocumentConnectionRef, _scope: DocumentScopeRef, remoteId: string): Promise<DocResult<void>> {
return this.withCreds(conn, async (creds) => {
await this.client.trashDocument(creds, remoteId);
});
}
/** The bound tag, or a `scope_missing` a caller can surface as `scope_lost`. */
private async requireTag(creds: PapraCreds, scope: DocumentScopeRef): Promise<PapraTag> {
const parsed = parseScopeKey(scope.scopeKey);
if (!parsed) {
throw new PapraError('scope_missing', 'The stored scope key is not a Papra scope', undefined, scope.scopeKey);
}
if (parsed.organizationId !== creds.organizationId) {
throw new PapraError(
'scope_missing',
'The bound tag belongs to a different Papra organisation than this connection',
undefined,
`scope ${parsed.organizationId}, connection ${creds.organizationId}`,
);
}
// Papra has no single-tag route, so existence is answered by the list — the
// same call that supplies the name the document search needs.
const tags = await this.client.listTags(creds);
const tag = tags.find((candidate) => candidate.id === parsed.tagId);
if (!tag) {
throw new PapraError('scope_missing', 'The tag this trip is bound to no longer exists', undefined, parsed.tagId);
}
return tag;
}
/** The organisation's name, if the key is allowed to read it. */
private async accountLabel(creds: PapraCreds): Promise<string> {
try {
const organizations = await this.client.listOrganizations(creds);
const mine = organizations.find((org) => org.id === creds.organizationId);
if (mine) return mine.name;
} catch {
// `organizations:read` is a separate scope a working sync key need not
// hold, so its absence costs a nicer label and nothing else.
}
return creds.organizationId;
}
/**
* Resolve the connection's credentials and run one operation on them.
*
* Every operation answers with a result rather than a throw, because the
* settings screen shows a failed probe inline. Reading the credentials inside
* the same guard is what keeps a half-filled connection form on that path
* too: it is reported exactly like a provider fault, and nothing reaches the
* network to find out.
*/
private async withCreds<T>(
conn: DocumentConnectionRef,
run: (creds: PapraCreds) => Promise<T>,
): Promise<DocResult<T>> {
try {
return docOk(await run(readCreds(conn)));
} catch (err: unknown) {
if (err instanceof PapraError) return docFail<T>(err.code, describe(err), err.status);
return docFail<T>('unknown', err instanceof Error ? err.message : String(err));
}
}
}
function toScopeOption(organizationId: string, tag: PapraTag): DocumentScopeOption {
return {
scopeKey: buildScopeKey(organizationId, tag.id),
label: tag.name,
remoteRootId: tag.id,
remoteRootPath: `#${tag.name}`,
};
}
function toRemote(doc: PapraDocument): RemoteDocument {
return {
remoteId: doc.id,
name: doc.name,
size: doc.originalSize,
mimeType: doc.mimeType,
remoteVersion: snapshotVersion(doc),
contentHash: doc.originalSha256Hash,
// In practice the creation time: Papra writes `updatedAt` once and never
// touches it again, not on a rename, a tag change or a delete.
remoteModifiedAt: doc.updatedAt ?? doc.createdAt,
isDeleted: doc.isDeleted,
};
}
/**
* Whether a listed document really carries the bound tag.
*
* The search already filters server-side, so this is belt and braces — except
* for the one case that matters: a tag name the grammar cannot express makes
* the client enumerate the whole organisation, and then this is the only filter
* there is. A document that states no tags at all is kept when the server did
* the filtering, because reading a missing field as "no tags" would empty the
* listing, and an empty listing is how a mass delete looks to the core.
*/
function inScope(doc: PapraDocument, tagId: string, serverFiltered: boolean): boolean {
if (doc.tags === null) return serverFiltered;
return doc.tags.some((tag) => tag.id === tagId);
}
/**
* A fingerprint of the whole enumeration, used as the cursor.
*
* Papra offers nothing to short-circuit a walk with, so this cannot save the
* enumeration — it only lets the core skip diffing when the tag is provably
* unchanged. It covers deletions as well as edits because it is built from the
* complete set rather than from the newest entry.
*/
function enumerationDigest(documents: RemoteDocument[]): string {
const pairs = documents.map((doc) => `${doc.remoteId}:${doc.remoteVersion}`).sort();
return crypto.createHash('sha256').update(pairs.join('\n')).digest('hex');
}
export function buildScopeKey(organizationId: string, tagId: string): string {
return `org:${organizationId}/tag:${tagId}`;
}
export function parseScopeKey(scopeKey: string): { organizationId: string; tagId: string } | null {
const match = /^org:([A-Za-z0-9_-]{1,64})\/tag:([A-Za-z0-9_-]{1,64})$/.exec(scopeKey.trim());
if (!match) return null;
return { organizationId: match[1], tagId: match[2] };
}
/**
* Connection fields to client credentials.
*
* A missing field is answered here rather than as a 401 from Papra, because the
* two look identical to the admin staring at the form and only one of them is
* fixed by issuing a new key.
*/
function readCreds(conn: DocumentConnectionRef): PapraCreds {
const apiKey = conn.secrets.api_key?.trim();
if (!apiKey) throw new PapraError('unauthorized', 'The connection has no Papra API key');
const organizationId = conn.settings.organization_id?.trim();
if (!organizationId) throw new PapraError('provider_error', 'The connection names no Papra organisation');
const baseUrl = conn.baseUrl.trim();
if (!baseUrl) throw new PapraError('provider_error', 'The connection has no Papra URL');
return { baseUrl, apiKey, organizationId, allowInsecureTls: conn.allowInsecureTls };
}
/** Upstream text for the self-hoster's log, never for the user's file list. */
function describe(err: PapraError): string {
const parts = [err.message];
if (err.papraCode) parts.push(`papra=${err.papraCode}`);
if (err.detail) parts.push(err.detail);
return parts.join(' | ');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
import { Injectable } from '@nestjs/common';
import { createHash, type Hash } from 'node:crypto';
import { Readable } from 'node:stream';
import {
docFail,
docOk,
type DocResult,
type DocumentConnectionRef,
type DocumentProvider,
type DocumentProviderCapabilities,
type DocumentScopeOption,
type DocumentScopeRef,
type FetchResult,
type PushRequest,
type PushResult,
type RemoteDocument,
} from '../document-provider';
import { PROVIDER_MAX_PAGES } from '../doc-sync.constants';
import { contentTypeFor } from '../../storage/content-type';
import {
isValidSynoName,
isWithinScope,
joinSynoPath,
MAX_SCOPE_PATH_LENGTH,
normalizeSynoPath,
snapshotVersion,
SynologyDriveClient,
SynologyDriveError,
type SynoEntry,
type SynologyDriveCreds,
} from './synology-drive.client';
/**
* Synology Drive over the FileStation Web API.
*
* The scope is a folder and identity is its path — FileStation has no file id,
* no change feed and no webhook — so this adapter is the one the sync core's
* "full enumeration, nothing assumed" model was shaped around. Everything it
* cannot honestly promise it declares as absent in `capabilities()` rather than
* faking: no push, no stable id, no hash in the listing.
*
* All HTTP lives in `synology-drive.client.ts`; this file is scope arithmetic,
* the mapping into the sync core's vocabulary, and the boundary checks DSM does
* not do for us.
*/
/** Matches `DOCUMENT_PROVIDER_IDS` in shared/src/docsync/docsync.schema.ts. */
const PROVIDER_ID = 'synologydrive';
/** What the connection form offers as a placeholder, and the fallback here. */
const DEFAULT_BASE_PATH = '/trek';
/** Scope keys are `path:` plus the normalised path. See `docsyncScopeKeySchema`. */
const SCOPE_PREFIX = 'path:';
/**
* DSM exposes no recycle-bin switch on the API's delete, so a propagated delete
* moves the file here instead. It is inside the scope so it inherits the scope's
* permissions, and it is skipped by every listing so a trashed document does not
* come back as a new one on the next run.
*/
const TRASH_FOLDER = '.trek-trash';
/**
* The adapter's own ceiling for one transfer. Not a policy — the install's limit
* lives in `docsync_max_file_bytes` and is enforced by the core — just a bound
* so a wrong path or a truncated header cannot start an unbounded read.
*/
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
/**
* Up to this size an upload is verified against the NAS's own digest.
*
* SYNO.FileStation.MD5 is a task that reads the whole file, so it is priced per
* megabyte on the NAS's disks. Below the threshold that is a fraction of the
* upload that just happened; above it, the verification would cost more than the
* transfer and the core's own sha256 of the local bytes is the better check.
*/
const MD5_VERIFY_MAX_BYTES = 64 * 1024 * 1024;
/** How many scope options one picker call may answer with. */
const MAX_SCOPE_OPTIONS = 200;
const CAPABILITIES: DocumentProviderCapabilities = {
// FileStation has no subscription API at all. DSM's own notifications are
// mail and mobile push, neither reachable from here.
push: 'none',
// The path IS the id: a rename upstream reads as delete + create.
stableId: false,
// Through the move into TRASH_FOLDER below, not through DSM's own recycle bin
// — the API's delete is permanent and offers no flag to route it elsewhere.
remoteTrash: true,
// An upload with overwrite=true replaces the bytes under the same path.
replaceInPlace: true,
// FileStation computes MD5 only, as a task per file, and MD5 is not the
// sha256 the core compares against. `contentHash` therefore stays null.
contentHashInListing: false,
maxUploadBytes: MAX_TRANSFER_BYTES,
// DSM stores whatever it is given; there is no server-side type allow-list.
acceptedMimeTypes: null,
canCreateScope: true,
};
/** Hash the bytes on their way past, without buffering them. */
async function* hashing(source: Readable, hash: Hash): AsyncGenerator<Buffer> {
for await (const chunk of source) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
hash.update(buffer);
yield buffer;
}
}
function baseName(path: string): string {
const cut = path.lastIndexOf('/');
return cut === -1 ? path : path.slice(cut + 1);
}
function parentPath(path: string): string {
const cut = path.lastIndexOf('/');
return cut <= 0 ? '/' : path.slice(0, cut);
}
function isoFromSeconds(seconds: number | null): string | null {
return seconds === null ? null : new Date(seconds * 1000).toISOString();
}
function toRemoteDocument(entry: SynoEntry): RemoteDocument {
return {
remoteId: entry.path,
name: entry.name,
size: entry.size,
// `additional.type` is the extension in capitals, not a media type, so the
// shared mapping is the honest source for what the bytes are.
mimeType: contentTypeFor(entry.name),
remoteVersion: snapshotVersion(entry.path, entry.size, entry.mtimeSeconds),
contentHash: null,
remoteModifiedAt: isoFromSeconds(entry.mtimeSeconds),
// FileStation keeps no tombstone: a file that is gone is simply absent.
isDeleted: false,
};
}
function scopeOption(path: string, label?: string): DocumentScopeOption {
return {
scopeKey: `${SCOPE_PREFIX}${path}`,
label: label ?? baseName(path),
// There is no stable folder id on a Synology share — the path is all there
// is, and pretending otherwise would put a fiction in the binding.
remoteRootId: null,
remoteRootPath: path,
};
}
@Injectable()
export class SynologyDriveProvider implements DocumentProvider {
readonly id = PROVIDER_ID;
/**
* Constructed here rather than injected: the client holds the SID cache, so
* there has to be exactly one of it per process, and it has no dependencies
* of its own to resolve.
*/
private readonly client = new SynologyDriveClient();
capabilities(): DocumentProviderCapabilities {
return CAPABILITIES;
}
async probe(
conn: DocumentConnectionRef,
): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
try {
const probe = await this.client.probe(creds);
if (!probe.availableApis.includes('SYNO.FileStation.List')) {
return docFail(
'provider_error',
'The account logged in, but this DSM does not expose File Station. Install the File Station package and allow this account to use it.',
);
}
return docOk({ account: creds.username, capabilities: CAPABILITIES });
} catch (error: unknown) {
return this.failure(error);
}
}
/**
* The shares this account can see, plus the folders under the configured base
* path — the two levels an operator actually picks from. Shares are offered
* too because a small NAS often has a share per purpose and no sub-folders.
*/
async listScopes(conn: DocumentConnectionRef, query?: string): Promise<DocResult<DocumentScopeOption[]>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
const basePath = this.basePath(conn);
try {
const options = new Map<string, DocumentScopeOption>();
for (const share of await this.client.listShares(creds)) {
const path = normalizeSynoPath(share.path, MAX_SCOPE_PATH_LENGTH);
if (path && share.isdir) options.set(path, scopeOption(path, share.name));
}
// The base path is a convenience, not a requirement: an install that has
// not created it yet should still see its shares rather than an error.
try {
const listing = await this.client.listFolder(creds, basePath);
for (const entry of listing.entries) {
if (!entry.isdir || entry.name === TRASH_FOLDER) continue;
const path = normalizeSynoPath(entry.path, MAX_SCOPE_PATH_LENGTH);
if (path) options.set(path, scopeOption(path, entry.name));
}
} catch (error: unknown) {
if (!(error instanceof SynologyDriveError) || error.code !== 'not_found') throw error;
}
const needle = query?.trim().toLowerCase();
const filtered = [...options.values()]
.filter((option) => !needle || option.label.toLowerCase().includes(needle) || option.remoteRootPath!.toLowerCase().includes(needle))
.sort((a, b) => a.remoteRootPath!.localeCompare(b.remoteRootPath!))
.slice(0, MAX_SCOPE_OPTIONS);
return docOk(filtered);
} catch (error: unknown) {
return this.failure(error);
}
}
async createScope(conn: DocumentConnectionRef, name: string): Promise<DocResult<DocumentScopeOption>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
const trimmed = name.trim();
if (!isValidSynoName(trimmed)) {
return docFail('provider_error', 'DSM will not accept that folder name');
}
const basePath = this.basePath(conn);
const path = normalizeSynoPath(joinSynoPath(basePath, trimmed), MAX_SCOPE_PATH_LENGTH);
if (!path) return docFail('provider_error', 'The resulting folder path is not one DSM accepts');
try {
const folder = await this.client.createFolder(creds, basePath, trimmed);
const created = normalizeSynoPath(folder.path, MAX_SCOPE_PATH_LENGTH) ?? path;
return docOk(scopeOption(created, folder.name));
} catch (error: unknown) {
// With force_parent DSM creates missing folders, but never a missing
// share — that is a storage operation, not a file one. Saying "the base
// path does not exist" is the actionable version of its 408.
if (error instanceof SynologyDriveError && error.code === 'not_found') {
return docFail('scope_missing', `The base path ${basePath} does not exist on the NAS`);
}
return this.failure(error);
}
}
async resolveScope(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<DocumentScopeOption>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
const scopePath = this.scopePath(scope);
if (!scopePath) return docFail('scope_missing', 'The stored scope key is not a usable path');
try {
const entry = await this.client.getInfo(creds, scopePath);
if (!entry.isdir) {
return docFail('scope_missing', 'The bound path is a file on the NAS, not a folder');
}
return docOk(scopeOption(scopePath, entry.name));
} catch (error: unknown) {
if (error instanceof SynologyDriveError && error.code === 'not_found') {
return docFail('scope_missing', `The NAS no longer has ${scopePath}`);
}
return this.failure(error);
}
}
/**
* Every file under the scope, folders walked breadth-first.
*
* Recursive because FileStation's `list` is not, and a sub-folder someone made
* upstream would otherwise be an invisible hole in the trip's documents. One
* request budget covers the whole walk, so a deep tree runs out of budget and
* says `truncated` instead of holding a worker.
*
* The cursor is a digest of the result, not a change token: DSM offers nothing
* to ask "what changed", and a folder's own mtime does not move when a file
* inside it is edited in place. It cannot save the walk, so it is only used to
* tell the core that the walk found exactly what it found last time.
*/
async list(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<{ documents: RemoteDocument[]; cursor: string | null; cursorUnchanged: boolean; truncated: boolean }>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
const scopePath = this.scopePath(scope);
if (!scopePath) return docFail('scope_missing', 'The stored scope key is not a usable path');
const documents: RemoteDocument[] = [];
const folders = [scopePath];
const seen = new Set<string>([scopePath]);
let budget = PROVIDER_MAX_PAGES;
let truncated = false;
try {
while (folders.length > 0) {
if (budget <= 0) {
truncated = true;
break;
}
const folder = folders.shift()!;
const listing = await this.client.listFolder(creds, folder, { maxPages: budget });
budget -= listing.pages;
if (listing.truncated) truncated = true;
for (const entry of listing.entries) {
const path = normalizeSynoPath(entry.path);
// A path the adapter cannot spell is one it could not fetch, rename or
// trash either, so it is left out rather than offered to the core.
if (!path || !isWithinScope(scopePath, path)) continue;
if (entry.isdir) {
if (entry.name === TRASH_FOLDER || seen.has(path)) continue;
seen.add(path);
folders.push(path);
continue;
}
documents.push(toRemoteDocument({ ...entry, path }));
}
}
} catch (error: unknown) {
if (error instanceof SynologyDriveError && error.code === 'not_found') {
return docFail('scope_missing', `The NAS no longer has ${scopePath}`);
}
return this.failure(error);
}
const digest = createHash('sha256');
for (const document of [...documents].sort((a, b) => a.remoteId.localeCompare(b.remoteId))) {
digest.update(`${document.remoteId}\t${document.remoteVersion}\n`);
}
const cursor = `v1:${digest.digest('hex')}`;
return docOk({
documents,
cursor,
// A partial walk saw a partial tree, so its digest says nothing about
// whether anything changed.
cursorUnchanged: !truncated && scope.cursor === cursor,
truncated,
});
}
async fetch(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<FetchResult>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
try {
const path = this.requireInScope(scope, remoteId);
// The version is read before the bytes so it describes the file that is
// about to be streamed. Reading it after would describe whatever the file
// became while it was being read.
const entry = await this.client.getInfo(creds, path);
if (entry.isdir) return docFail('not_found', 'That path is a folder on the NAS');
const download = await this.client.download(creds, path, MAX_TRANSFER_BYTES);
return docOk({
body: download.body,
size: download.size ?? entry.size,
mimeType: contentTypeFor(entry.name),
remoteVersion: snapshotVersion(entry.path, entry.size, entry.mtimeSeconds),
});
} catch (error: unknown) {
return this.failure(error);
}
}
/**
* Upload, then confirm what landed.
*
* The confirmation is not ceremony: FileStation answers `success: true` as
* soon as it has written the part, and the version the core stores has to
* describe the file as the NAS now sees it — including the mtime DSM rounded
* to whole seconds — or the next listing reads TREK's own upload as a change
* made upstream.
*/
async push(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
req: PushRequest,
): Promise<DocResult<PushResult>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
if (req.size > MAX_TRANSFER_BYTES) {
return docFail('too_large', `This adapter refuses transfers over ${MAX_TRANSFER_BYTES} bytes`);
}
const md5 = createHash('md5');
try {
const scopePath = this.requireScopePath(scope);
let folderPath = scopePath;
let fileName = req.fileName;
if (req.remoteId !== undefined) {
const replacing = this.requireInScope(scope, req.remoteId);
folderPath = parentPath(replacing);
// The name follows the path being replaced, not the request: changing
// both the bytes and the name in one upload would leave the old path
// behind as a second document.
fileName = baseName(replacing);
}
if (!isValidSynoName(fileName)) {
return docFail('provider_error', 'DSM will not accept that file name');
}
const targetPath = joinSynoPath(folderPath, fileName);
if (normalizeSynoPath(targetPath) !== targetPath || !isWithinScope(scopePath, targetPath)) {
return docFail('provider_error', 'The resulting file path is not one DSM accepts');
}
await this.client.upload(creds, {
folderPath,
fileName,
body: Readable.from(hashing(req.body, md5)),
size: req.size,
mimeType: req.mimeType,
mtimeSeconds: req.mtimeSeconds,
// Replacing is only ever what the caller asked for. Without a remoteId a
// name collision is a real conflict: the file at that path is somebody
// else's, and overwriting it would destroy it silently.
overwrite: req.remoteId !== undefined,
createParents: true,
});
const entry = await this.client.getInfo(creds, targetPath);
if (entry.size !== null && entry.size !== req.size) {
return docFail(
'checksum_mismatch',
`The NAS stored ${entry.size} bytes of an ${req.size}-byte upload`,
);
}
if (req.size <= MD5_VERIFY_MAX_BYTES) {
const remote = await this.client.md5(creds, targetPath);
if (remote !== md5.digest('hex')) {
return docFail('checksum_mismatch', 'The NAS computed a different digest for the uploaded bytes');
}
}
return docOk({
remoteId: entry.path,
remoteVersion: snapshotVersion(entry.path, entry.size, entry.mtimeSeconds),
remoteModifiedAt: isoFromSeconds(entry.mtimeSeconds),
// FileStation stores what it is given; there is no content-addressed
// store behind it that could answer with an existing file.
deduplicated: false,
});
} catch (error: unknown) {
return this.failure(error);
}
}
/**
* Rename in place.
*
* The caller's `remoteId` is stale afterwards, because the path is the id.
* That is why `capabilities().stableId` is false: the core is expected to
* re-derive the id from the folder and the new name, or to find it on the next
* enumeration.
*/
async rename(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
name: string,
): Promise<DocResult<{ remoteVersion: string }>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
const trimmed = name.trim();
if (!isValidSynoName(trimmed)) {
return docFail('provider_error', 'DSM will not accept that file name');
}
try {
const entry = await this.client.rename(creds, this.requireInScope(scope, remoteId), trimmed);
return docOk({ remoteVersion: snapshotVersion(entry.path, entry.size, entry.mtimeSeconds) });
} catch (error: unknown) {
return this.failure(error);
}
}
/**
* Move into the scope's own bin.
*
* DSM's API delete is permanent and takes no "to the recycle bin" flag, and a
* recycle bin is not even enabled on every share. A document that four people
* are travelling on must not be one API call away from gone, so a propagated
* delete becomes a move that any of them can undo in File Station.
*/
async trash(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<void>> {
const creds = this.credentials(conn);
if (!creds) return docFail('unauthorized', 'username and password are required');
try {
const scopePath = this.requireScopePath(scope);
const path = this.requireInScope(scope, remoteId);
const trashPath = joinSynoPath(scopePath, TRASH_FOLDER);
await this.client.createFolder(creds, scopePath, TRASH_FOLDER);
try {
await this.client.move(creds, path, trashPath, { overwrite: false });
} catch (error: unknown) {
if (!(error instanceof SynologyDriveError) || error.code !== 'conflict') throw error;
// A file of that name is already in the bin. Overwriting it would throw
// away the older copy, which is the one thing a bin exists to prevent,
// so the older copy steps aside under a timestamped name.
const occupant = joinSynoPath(trashPath, baseName(path));
await this.client.rename(creds, occupant, this.archivedName(baseName(path)));
await this.client.move(creds, path, trashPath, { overwrite: false });
}
return docOk(undefined);
} catch (error: unknown) {
return this.failure(error);
}
}
/** `boarding.pdf` → `boarding.1789745537.pdf`, keeping the extension readable. */
private archivedName(name: string): string {
const stamp = Math.floor(Date.now() / 1000);
const dot = name.lastIndexOf('.');
return dot <= 0 ? `${name}.${stamp}` : `${name.slice(0, dot)}.${stamp}${name.slice(dot)}`;
}
private credentials(conn: DocumentConnectionRef): SynologyDriveCreds | null {
const username = (conn.settings.username ?? '').trim();
const password = conn.secrets.password ?? '';
if (!username || !password || !conn.baseUrl.trim()) return null;
const otpCode = (conn.secrets.otp_code ?? '').trim();
return {
baseUrl: conn.baseUrl,
username,
password,
otpCode: otpCode === '' ? undefined : otpCode,
allowInsecureTls: conn.allowInsecureTls,
};
}
private basePath(conn: DocumentConnectionRef): string {
const configured = (conn.settings.base_path ?? '').trim();
if (!configured) return DEFAULT_BASE_PATH;
return normalizeSynoPath(configured, MAX_SCOPE_PATH_LENGTH) ?? DEFAULT_BASE_PATH;
}
/**
* The folder this trip is bound to, from the scope key.
*
* `remoteRootPath` is only a fallback: the key is what a unique index protects
* and what a restore keeps, so a row whose two columns disagree is read the
* way the constraint reads it.
*/
private scopePath(scope: DocumentScopeRef): string | null {
const raw = scope.scopeKey.startsWith(SCOPE_PREFIX)
? scope.scopeKey.slice(SCOPE_PREFIX.length)
: scope.remoteRootPath;
if (!raw) return null;
const path = normalizeSynoPath(raw, MAX_SCOPE_PATH_LENGTH);
// The FileStation root is not a scope: it holds the shares, and binding a
// trip to it would put every share on the NAS inside one trip.
return path === null || path === '/' ? null : path;
}
/**
* A remote id turned into a path this call is allowed to touch.
*
* DSM checks DSM permissions and nothing else: the account TREK logs in with
* can reach every share it owns, so `/trips/japan/../../payroll/2026.pdf` is a
* perfectly legal request that another trip's binding must never be able to
* make. The trip boundary exists only here.
*
* Throws rather than returning a result, so a caller cannot forget to check
* it — every caller already runs inside the try that turns a
* `SynologyDriveError` into a `DocResult`.
*/
private requireInScope(scope: DocumentScopeRef, remoteId: string): string {
const scopePath = this.requireScopePath(scope);
const path = normalizeSynoPath(remoteId);
if (!path || path === scopePath || !isWithinScope(scopePath, path)) {
throw new SynologyDriveError('not_found', 'That path is outside the folder this trip is bound to', {
detail: 'That path is outside the folder this trip is bound to',
});
}
if (path.split('/').includes(TRASH_FOLDER)) {
throw new SynologyDriveError('not_found', 'That path is inside the sync bin', {
detail: 'That path is inside the sync bin',
});
}
return path;
}
private requireScopePath(scope: DocumentScopeRef): string {
const scopePath = this.scopePath(scope);
if (!scopePath) {
throw new SynologyDriveError('scope_missing', 'The stored scope key is not a usable path', {
detail: 'The stored scope key is not a usable path',
});
}
return scopePath;
}
private failure<T>(error: unknown): DocResult<T> {
if (error instanceof SynologyDriveError) {
return docFail<T>(error.code, error.detail, error.status);
}
return docFail<T>('unknown', error instanceof Error ? error.message : undefined);
}
}
@@ -0,0 +1,843 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'node:stream';
import { XMLParser } from 'fast-xml-parser';
import type { DocsyncErrorCode } from '@trek/shared';
import { safeFetch, SsrfBlockedError } from '../../../utils/ssrfGuard';
import { readCappedJson, readCappedText } from '../../../utils/cappedFetch';
import {
PROVIDER_JSON_MAX_BYTES,
PROVIDER_TIMEOUT_MS,
PROVIDER_TRANSFER_TIMEOUT_MS,
} from '../doc-sync.constants';
/**
* HTTP client for Nextcloud and OpenCloud. This is the ONLY place that talks to
* either instance; the adapter above it holds no URLs and no transport logic.
*
* Measured against Nextcloud 31.0.14 and OpenCloud 8.0.1 on 2026-09-18, not
* against their prose documentation, which is silent or wrong on most of this:
*
* - **`OC-Checksum` is a Nextcloud-only header here.** OpenCloud answers HTTP
* 400 to `SHA256:<hex>` — it knows SHA1, MD5 and ADLER32, and treats an
* unknown algorithm exactly like a mismatch, so sending the sha256 TREK
* already has would fail every upload into a space. Nextcloud accepts SHA256
* but does NOT verify it: `SHA256:0000` is stored verbatim and served back
* through `oc:checksums`. A checksum from a listing is therefore a claim,
* never a proof, and the core still has to hash the bytes it downloads.
* - **OpenCloud computes SHA1/MD5/ADLER32 itself** for every file, whether or
* not one was sent, and never SHA256. That is why `contentHashInListing` is
* false for both products rather than true for one.
* - **`X-OC-MTime` is honoured by both**, answered with `X-OC-MTime: accepted`.
* Without it every file TREK uploads comes back on the next run looking
* freshly changed upstream, which is the classic sync loop.
* - **Nextcloud's `OC-FileId` response header is not its `oc:fileid`**: the PUT
* that creates `oc:fileid` 62 answers `OC-FileId: 00000062oc9fy1g5e2cj`
* (zero-padded id plus instance id). Storing the header form would make every
* file TREK uploaded look like a stranger on the next listing, so it is
* normalised back to the numeric id. OpenCloud's two spellings agree.
* - **PROPFIND has no pagination.** `Paginate: true` plus `Nc-Paginate-*` is
* advertised in the `DAV:` header (`nc-paginate`) and ignored on PROPFIND, so
* one Depth:1 request is the whole folder. Hence the capped read and the
* repair of a cut-off body into the responses that did arrive, with
* `truncated` set, rather than failing a big folder outright.
* - **Webhook registration is form-encoded only.** A JSON body reaches the
* controller with every argument null and the instance answers HTTP 500.
*/
/** Which product a connection points at. Decided by the connection, not by the class. */
export type WebdavFlavor = 'nextcloud' | 'opencloud';
export interface WebdavCreds {
/** Instance origin, already normalised: no trailing slash, no DAV suffix. */
origin: string;
username: string;
password: string;
allowInsecureTls: boolean;
flavor: WebdavFlavor;
}
/**
* A failed call, carrying the reason as a code.
*
* The code is what reaches the user through i18n; `detail` keeps the upstream
* text, which is English, sometimes an HTML login page from a reverse proxy, and
* in Sabre's case a stack-shaped XML document.
*/
export class WebdavError extends Error {
readonly code: DocsyncErrorCode;
readonly status?: number;
readonly detail?: string;
constructor(code: DocsyncErrorCode, message: string, status?: number, detail?: string) {
super(message);
this.name = 'WebdavError';
this.code = code;
this.status = status;
this.detail = detail;
}
}
/** One `<d:response>` of a PROPFIND, reduced to what the sync core reads. */
export interface WebdavEntry {
/** Path portion of the `d:href`, still percent-encoded, as sent. */
href: string;
/** The same path decoded, for comparing against a requested path. */
path: string;
name: string;
isCollection: boolean;
/** Quoted ETag exactly as the server spelled it, or '' when absent. */
etag: string;
fileId: string | null;
size: number | null;
mimeType: string | null;
lastModifiedIso: string | null;
/** Only when the instance carries a SHA256; see the header note above. */
sha256: string | null;
}
export interface WebdavPropfindResult {
/** The requested resource itself. */
self: WebdavEntry;
children: WebdavEntry[];
/** The body hit the size cap, so `children` is a prefix of the folder. */
truncated: boolean;
}
/** An OpenCloud space, as `/graph/v1.0/…/drives` describes it. */
export interface WebdavDrive {
id: string;
name: string;
driveType: string;
/**
* Path of `root.webDavUrl`, never its origin: the instance answers with its
* own configured `OC_URL` (`https://localhost:8804` for a host reached as
* 127.0.0.1), and following a host that an untrusted response chose is how an
* SSRF guard gets walked around. Only the path survives, re-based on the
* connection's own origin.
*/
webDavPath: string;
}
export interface WebdavPutOptions {
size: number;
mimeType: string;
/** Unix seconds, sent as `X-OC-MTime`. */
mtimeSeconds: number;
sha256: string;
/** Optimistic concurrency. Both products answer a mismatch with 412. */
ifMatch?: string;
}
export interface WebdavPutResult {
fileId: string | null;
etag: string | null;
/** 201 rather than 204: a new resource rather than a replaced one. */
created: boolean;
}
export interface WebdavDownload {
body: Readable;
size: number | null;
mimeType: string | null;
etag: string;
}
const PROPFIND_BODY =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop>' +
'<d:getetag/><d:getlastmodified/><d:getcontentlength/><d:getcontenttype/><d:resourcetype/>' +
'<oc:fileid/><oc:checksums/>' +
'</d:prop></d:propfind>';
/**
* Namespace for the dead properties TREK writes. A URI, not an address — it is
* never fetched, it only has to be unmistakably ours so a second tool writing
* `doc-uid` on the same file cannot collide with it.
*/
export const TREK_PROP_NS = 'https://github.com/liketrek/TREK/ns/docsync';
/** Property names under that namespace, matching the anchors in `PushRequest`. */
export const TREK_PROP_DOC_UID = 'doc-uid';
export const TREK_PROP_TRIP_UID = 'trip-uid';
const OCS_WEBHOOKS = '/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks';
/** OCS refuses any request without the first header and answers XML without the second. */
const OCS_HEADERS: Record<string, string> = {
'OCS-APIRequest': 'true',
Accept: 'application/json',
};
/**
* `removeNSPrefix` because the prefix is the server's choice rather than the
* protocol's (`d:` on both of these, `D:` on other Sabre builds), and
* `parseTagValue: false` because an ETag, a file id and a checksum are opaque
* strings that have to survive a server spelling them as digits.
*/
const propfindParser = new XMLParser({
removeNSPrefix: true,
parseTagValue: false,
ignoreAttributes: true,
isArray: (name) => name === 'response' || name === 'propstat' || name === 'checksum',
});
/** Nextcloud's `OC-FileId` header carries the instance id after the number. */
export function normalizeFileId(raw: string | null, flavor: WebdavFlavor): string | null {
const value = raw?.trim();
if (!value) return null;
if (flavor !== 'nextcloud') return value;
const digits = /^0*(\d+)/.exec(value);
return digits ? digits[1] : value;
}
/**
* What a user pastes into the URL field, reduced to an origin.
*
* People paste the address bar of their own file list, so the DAV and Graph
* prefixes come off rather than turning into `/remote.php/dav/remote.php/dav`
* 404s, which read like a wrong password.
*/
export function normalizeOrigin(baseUrl: string): string {
return baseUrl
.trim()
.replace(/[?#].*$/, '')
.replace(/\/+$/, '')
.replace(/\/(?:remote|index)\.php(?:\/.*)?$/i, '')
.replace(/\/dav\/spaces(?:\/.*)?$/i, '')
.replace(/\/graph(?:\/.*)?$/i, '')
.replace(/\/+$/, '');
}
/** Percent-encode each segment of a decoded path, keeping the separators. */
export function encodePath(path: string): string {
return path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
/** `/a/b/` and `a//b` both mean `/a/b`. Every comparison here depends on one spelling. */
export function normalizePath(path: string): string {
const collapsed = `/${path}`.replace(/\/+/g, '/').replace(/\/+$/, '');
return collapsed === '' ? '/' : collapsed;
}
/** The path portion of a stored root, which is a URL on OpenCloud and a path on Nextcloud. */
export function davPathOf(rootPathOrUrl: string): string | null {
const value = rootPathOrUrl.trim();
if (!value) return null;
if (/^https?:\/\//i.test(value)) {
try {
return normalizePath(new URL(value).pathname);
} catch {
return null;
}
}
return value.startsWith('/') ? normalizePath(value) : null;
}
function hrefPathname(href: string): string {
try {
return new URL(href).pathname;
} catch {
return href;
}
}
/** The still-encoded path of an href, which is what the next request is built from. */
function hrefPath(href: string): string {
return /^https?:\/\//i.test(href) ? hrefPathname(href) : href;
}
function decodeHrefPath(href: string): string {
const raw = hrefPath(href);
try {
return normalizePath(decodeURIComponent(raw));
} catch {
return normalizePath(raw);
}
}
function classifyStatus(status: number): DocsyncErrorCode {
if (status === 401) return 'unauthorized';
if (status === 403) return 'forbidden';
if (status === 404) return 'not_found';
// 405 is MKCOL on an existing collection, 412 a failed If-Match, 423 a locked
// node: three ways of saying the caller's picture of the remote is stale.
if (status === 405 || status === 409 || status === 412 || status === 423) return 'conflict';
if (status === 413) return 'too_large';
if (status === 415) return 'unsupported_type';
if (status === 429) return 'rate_limited';
if (status === 507) return 'quota_exceeded';
return status >= 500 ? 'provider_error' : 'unknown';
}
/** Every `cause` in the chain, because undici hides the real reason two deep. */
function causeChain(err: unknown): unknown[] {
const chain: unknown[] = [];
let current: unknown = err;
for (let depth = 0; depth < 5 && current !== undefined && current !== null; depth++) {
chain.push(current);
current = current instanceof Error ? (current as Error & { cause?: unknown }).cause : undefined;
}
return chain;
}
function classifyTransport(err: unknown): { code: DocsyncErrorCode; detail: string } {
if (err instanceof SsrfBlockedError) {
return { code: 'ssrf_blocked', detail: err.message };
}
const chain = causeChain(err);
const markers = chain.map((entry) => {
const named = entry instanceof Error ? entry.name : '';
const coded =
typeof entry === 'object' && entry !== null && 'code' in entry
? String((entry as { code: unknown }).code)
: '';
return `${named}|${coded}`;
});
const detail = chain
.map((entry) => (entry instanceof Error ? entry.message : String(entry)))
.filter((message) => message.length > 0)
.join(': ');
if (markers.some((marker) => /TimeoutError|ABORT_ERR|HEADERS_TIMEOUT|BODY_TIMEOUT/i.test(marker))) {
return { code: 'timeout', detail };
}
if (markers.some((marker) => /CERT|SELF_SIGNED|ERR_TLS|ERR_SSL/i.test(marker))) {
return { code: 'tls_untrusted', detail };
}
return { code: 'unreachable', detail };
}
/**
* A body cut off at the cap is still a run of complete `<d:response>` blocks
* followed by a fragment. Dropping the fragment and closing the document keeps
* the first few thousand files of a huge folder usable, which is what
* `truncated` is for — the alternative is telling someone with one big folder
* that nothing works at all.
*/
function repairTruncatedMultistatus(xml: string): string | null {
const closing = /<\/([a-z0-9]+:)?response\s*>/gi;
let cut = -1;
for (let match = closing.exec(xml); match !== null; match = closing.exec(xml)) {
cut = match.index + match[0].length;
}
if (cut < 0) return null;
const open = /<([a-z0-9]+:)?multistatus[^>]*>/i.exec(xml);
if (!open) return null;
return `${xml.slice(0, cut)}</${open[1] ?? ''}multistatus>`;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asArray(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
return value === undefined || value === null ? [] : [value];
}
function textOf(value: unknown): string | null {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return null;
}
/**
* Props out of the propstat blocks that reported 2xx only. A PROPFIND answers
* one block per status, and reading the 404 block too turns "this folder has no
* content type" into an empty string that looks like a real value.
*/
function propsOf(response: Record<string, unknown>): Record<string, unknown> {
const props: Record<string, unknown> = {};
for (const stat of asArray(response.propstat)) {
const record = asRecord(stat);
if (!record) continue;
const status = textOf(record.status) ?? '';
if (!/\s2\d\d(\s|$)/.test(status)) continue;
const prop = asRecord(record.prop);
if (prop) Object.assign(props, prop);
}
return props;
}
/**
* `SHA256:<hex>` out of either spelling: Nextcloud sends one element carrying
* one algorithm, OpenCloud sends one element carrying all three separated by
* spaces.
*/
function sha256Of(checksums: unknown): string | null {
const record = asRecord(checksums);
if (!record) return null;
for (const entry of asArray(record.checksum)) {
const text = textOf(entry);
if (!text) continue;
for (const token of text.split(/\s+/)) {
const match = /^SHA256:([0-9a-f]{64})$/i.exec(token);
if (match) return match[1].toLowerCase();
}
}
return null;
}
function entryOf(response: Record<string, unknown>, flavor: WebdavFlavor): WebdavEntry | null {
const href = textOf(response.href);
if (!href) return null;
const props = propsOf(response);
const path = decodeHrefPath(href);
const segments = path.split('/');
const length = textOf(props.getcontentlength);
const modified = textOf(props.getlastmodified);
const parsedModified = modified ? new Date(modified) : null;
const mimeType = textOf(props.getcontenttype);
const resourceType = asRecord(props.resourcetype);
return {
href: hrefPath(href),
path,
name: segments[segments.length - 1] ?? '',
isCollection: resourceType !== null && 'collection' in resourceType,
etag: textOf(props.getetag) ?? '',
fileId: normalizeFileId(textOf(props.fileid), flavor),
size: length !== null && length !== '' && Number.isFinite(Number(length)) ? Number(length) : null,
mimeType: mimeType ? mimeType.split(';')[0].trim() : null,
lastModifiedIso:
parsedModified && !Number.isNaN(parsedModified.getTime()) ? parsedModified.toISOString() : null,
sha256: sha256Of(props.checksums),
};
}
function toDrive(value: unknown): WebdavDrive | null {
const record = asRecord(value);
const id = textOf(record?.id);
if (!record || !id) return null;
const webDavUrl = textOf(asRecord(record.root)?.webDavUrl);
return {
id,
name: textOf(record.name) ?? id,
driveType: textOf(record.driveType) ?? '',
webDavPath: (webDavUrl ? davPathOf(webDavUrl) : null) ?? `/dav/spaces/${encodeURIComponent(id)}`,
};
}
/** Text content for the PROPPATCH body, which is built as a string rather than parsed back. */
function escapeXmlText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* A web stream as a Node stream, by hand rather than through `Readable.fromWeb`,
* because the two `ReadableStream` declarations in scope — the platform's and
* `node:stream/web`'s — are different types and bridging them needs a cast this
* avoids. Cancelling the reader on destroy matters: undici keeps the connection
* reserved until the body ends.
*/
function toNodeStream(stream: ReadableStream<Uint8Array>): Readable {
const reader = stream.getReader();
return new Readable({
read() {
reader.read().then(
({ done, value }) => {
this.push(done || !value ? null : Buffer.from(value));
},
(err: unknown) => {
this.destroy(err instanceof Error ? err : new Error(String(err)));
},
);
},
destroy(err, callback) {
void reader.cancel().catch(() => {});
callback(err);
},
});
}
@Injectable()
export class WebdavClient {
/**
* One authenticated request. Throws for anything that never reached the
* application, and hands the caller the response otherwise — a 404 on DELETE
* and a 405 on MKCOL are answers, not failures.
*/
private async send(
creds: WebdavCreds,
path: string,
init: RequestInit,
timeoutMs = PROVIDER_TIMEOUT_MS,
): Promise<Response> {
const auth = Buffer.from(`${creds.username}:${creds.password}`, 'utf8').toString('base64');
const headers = new Headers(init.headers);
headers.set('Authorization', `Basic ${auth}`);
try {
return await safeFetch(
`${creds.origin}${path}`,
{
...init,
headers,
signal: AbortSignal.timeout(timeoutMs) as AbortSignal,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
const { code, detail } = classifyTransport(err);
throw new WebdavError(code, 'Could not reach the WebDAV instance', undefined, detail);
}
}
/** The error for a response nobody wanted, with a bounded slice of its body as detail. */
private async failed(response: Response, what: string): Promise<WebdavError> {
const { text } = await readCappedText(response, 8 * 1024);
return new WebdavError(
classifyStatus(response.status),
`${what} answered HTTP ${response.status}`,
response.status,
text.slice(0, 500),
);
}
/**
* PROPFIND with the props the sync core reads.
*
* Depth 0 is the cursor probe: a write anywhere below a collection changes
* that collection's ETag on both products, up to the root, so an unchanged
* root ETag means the whole walk can be skipped. Depth 1 is the walk.
*/
async propfind(creds: WebdavCreds, path: string, depth: 0 | 1): Promise<WebdavPropfindResult> {
const response = await this.send(creds, path, {
method: 'PROPFIND',
headers: { Depth: String(depth), 'Content-Type': 'application/xml; charset=utf-8' },
body: PROPFIND_BODY,
});
if (response.status !== 207) {
throw await this.failed(response, 'PROPFIND');
}
const { text, truncated } = await readCappedText(response, PROVIDER_JSON_MAX_BYTES);
const xml = truncated ? repairTruncatedMultistatus(text) : text;
if (xml === null) {
throw new WebdavError('too_large', 'The folder listing exceeded the size limit', response.status);
}
const multistatus = asRecord(asRecord(propfindParser.parse(xml))?.multistatus);
if (!multistatus) {
throw new WebdavError(
'provider_error',
'The instance answered something that is not a WebDAV multistatus',
response.status,
text.slice(0, 200),
);
}
const entries: WebdavEntry[] = [];
for (const element of asArray(multistatus.response)) {
const record = asRecord(element);
const entry = record ? entryOf(record, creds.flavor) : null;
if (entry) entries.push(entry);
}
if (entries.length === 0) {
throw new WebdavError('provider_error', 'The instance answered an empty multistatus', response.status);
}
// RFC 4918 puts the requested resource first, but it is matched by path
// rather than trusted by position — the root must never end up in the
// document list, and one misordered server would put it there.
const wanted = decodeHrefPath(path);
const selfIndex = Math.max(
entries.findIndex((entry) => entry.path === wanted),
0,
);
return {
self: entries[selfIndex],
children: entries.filter((_, index) => index !== selfIndex),
truncated,
};
}
/**
* Write dead properties, which both products persist (Nextcloud in
* `oc_properties`, OpenCloud in the space's metadata) and hand back through a
* PROPFIND that asks for them. That is where the TREK anchors go: they are the
* only thing that still identifies a document after a human has renamed it and
* moved it somewhere else in the folder.
*
* Answers whether the server accepted them. A 207 is not enough on its own —
* a refusal arrives as a non-2xx status inside the multistatus.
*/
async proppatch(creds: WebdavCreds, path: string, props: Record<string, string>): Promise<boolean> {
const body =
'<?xml version="1.0" encoding="UTF-8"?>' +
`<d:propertyupdate xmlns:d="DAV:" xmlns:trek="${TREK_PROP_NS}"><d:set><d:prop>` +
Object.entries(props)
.map(([name, value]) => `<trek:${name}>${escapeXmlText(value)}</trek:${name}>`)
.join('') +
'</d:prop></d:set></d:propertyupdate>';
const response = await this.send(creds, path, {
method: 'PROPPATCH',
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
body,
});
if (response.status !== 207) {
throw await this.failed(response, 'PROPPATCH');
}
const { text } = await readCappedText(response, PROVIDER_JSON_MAX_BYTES);
const multistatus = asRecord(asRecord(propfindParser.parse(text))?.multistatus);
for (const element of asArray(multistatus?.response)) {
const record = asRecord(element);
if (!record) continue;
const accepted = propsOf(record);
if (Object.keys(props).every((name) => name in accepted)) return true;
}
return false;
}
/** MKCOL. `exists` rather than an error, because 405 is how both say "already there". */
async mkcol(creds: WebdavCreds, path: string): Promise<'created' | 'exists'> {
const response = await this.send(creds, path, { method: 'MKCOL' });
void response.body?.cancel().catch(() => {});
if (response.ok) return 'created';
if (response.status === 405) return 'exists';
throw await this.failed(response, 'MKCOL');
}
async put(
creds: WebdavCreds,
path: string,
body: Readable,
options: WebdavPutOptions,
): Promise<WebdavPutResult> {
const headers: Record<string, string> = {
'Content-Type': options.mimeType,
// Explicit, so the upload does not go out chunked: a reverse proxy in
// front of a self-hosted instance is the common case and several refuse
// a chunked PUT outright.
'Content-Length': String(options.size),
'X-OC-MTime': String(options.mtimeSeconds),
};
if (creds.flavor === 'nextcloud' && /^[0-9a-f]{64}$/i.test(options.sha256)) {
headers['OC-Checksum'] = `SHA256:${options.sha256.toLowerCase()}`;
}
if (options.ifMatch) {
headers['If-Match'] = options.ifMatch;
}
const response = await this.send(
creds,
path,
{ method: 'PUT', headers, body, duplex: 'half' },
PROVIDER_TRANSFER_TIMEOUT_MS,
);
if (!response.ok) {
throw await this.failed(response, 'PUT');
}
void response.body?.cancel().catch(() => {});
return {
fileId: normalizeFileId(response.headers.get('oc-fileid'), creds.flavor),
etag: response.headers.get('oc-etag') ?? response.headers.get('etag'),
created: response.status === 201,
};
}
/**
* GET, handed back as a stream.
*
* Deliberately not read through the size cap: the caller pipes it into
* storage, and buffering a trip's scanned passport in memory to count its
* bytes is the opposite of what the cap is for. The declared length is
* reported so the core can refuse an oversized document before writing it.
*/
async get(creds: WebdavCreds, path: string): Promise<WebdavDownload> {
const response = await this.send(creds, path, { method: 'GET' }, PROVIDER_TRANSFER_TIMEOUT_MS);
if (!response.ok) {
throw await this.failed(response, 'GET');
}
const stream = response.body;
if (!stream) {
throw new WebdavError('provider_error', 'The instance answered a download without a body', response.status);
}
const length = response.headers.get('content-length');
const mimeType = response.headers.get('content-type');
return {
body: toNodeStream(stream),
size: length !== null && length !== '' && Number.isFinite(Number(length)) ? Number(length) : null,
mimeType: mimeType ? mimeType.split(';')[0].trim() : null,
etag: response.headers.get('oc-etag') ?? response.headers.get('etag') ?? '',
};
}
/**
* MOVE within one instance. `Overwrite: F` on purpose: a rename that silently
* replaced a namesake would destroy a document nobody asked about, and the
* 412 it produces instead is a conflict the core can show someone.
*/
async move(creds: WebdavCreds, fromPath: string, toPath: string): Promise<{ etag: string | null }> {
const response = await this.send(creds, fromPath, {
method: 'MOVE',
headers: { Destination: `${creds.origin}${toPath}`, Overwrite: 'F' },
});
if (!response.ok) {
throw await this.failed(response, 'MOVE');
}
void response.body?.cancel().catch(() => {});
return { etag: response.headers.get('oc-etag') ?? response.headers.get('etag') };
}
/** DELETE, which lands in the owner's trash on Nextcloud and the space trash on OpenCloud. */
async delete(creds: WebdavCreds, path: string): Promise<void> {
const response = await this.send(creds, path, { method: 'DELETE' });
void response.body?.cancel().catch(() => {});
// A document that is already gone is the state the caller asked for.
if (response.ok || response.status === 404) return;
throw await this.failed(response, 'DELETE');
}
private async graph(
creds: WebdavCreds,
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<unknown> {
const response = await this.send(creds, path, {
method,
headers:
body === undefined
? { Accept: 'application/json' }
: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw await this.failed(response, 'The Graph API');
}
const parsed = await readCappedJson<unknown>(response, PROVIDER_JSON_MAX_BYTES);
if (parsed === undefined) {
throw new WebdavError(
'provider_error',
'The Graph API answered something that is not JSON. Check that the URL points at OpenCloud itself.',
response.status,
);
}
return parsed;
}
async listDrives(creds: WebdavCreds): Promise<WebdavDrive[]> {
const parsed = asRecord(await this.graph(creds, 'GET', '/graph/v1.0/me/drives'));
const drives: WebdavDrive[] = [];
for (const value of asArray(parsed?.value)) {
const drive = toDrive(value);
if (drive) drives.push(drive);
}
return drives;
}
/** One space, or null when it is gone — which is what makes `scope_missing` detectable. */
async getDrive(creds: WebdavCreds, driveId: string): Promise<WebdavDrive | null> {
try {
return toDrive(await this.graph(creds, 'GET', `/graph/v1.0/drives/${encodeURIComponent(driveId)}`));
} catch (err: unknown) {
if (err instanceof WebdavError && err.status === 404) return null;
throw err;
}
}
async createDrive(creds: WebdavCreds, name: string): Promise<WebdavDrive> {
const drive = toDrive(
await this.graph(creds, 'POST', '/graph/v1.0/drives', { name, driveType: 'project' }),
);
if (!drive) {
throw new WebdavError('provider_error', 'OpenCloud created a space it then described incompletely');
}
return drive;
}
/** The account's own name, for the line the settings screen shows after a test. */
async whoAmI(creds: WebdavCreds): Promise<string> {
const me = asRecord(await this.graph(creds, 'GET', '/graph/v1.0/me'));
return (
textOf(me?.onPremisesSamAccountName) ??
textOf(me?.displayName) ??
textOf(me?.mail) ??
creds.username
);
}
/**
* Whether this account may manage webhooks at all.
*
* The endpoint is admin-only and a read is harmless, so asking is cheaper and
* more honest than registering four subscriptions to find out. A non-admin
* app password gets 403 here, which is exactly the case `webhook-manual`
* exists for.
*/
async canRegisterWebhooks(creds: WebdavCreds): Promise<boolean> {
const response = await this.send(creds, OCS_WEBHOOKS, { method: 'GET', headers: OCS_HEADERS });
void response.body?.cancel().catch(() => {});
return response.ok;
}
/**
* Register one event, unfiltered.
*
* Form-encoded, because a JSON body arrives at the controller with every
* argument null and the instance answers HTTP 500.
*
* No `eventFilter`, and that is measured rather than lazy. The filter is
* evaluated by the app's own bundled PHPMongoQuery, which implements
* `$all $e $in $lt $lte $gt $gte $ne $nin $exists $mod` and nothing else, so a
* `$regex` prefix match on the changed path throws "Operator $regex is
* unknown" inside the event listener. The damage does not stop at this
* subscription: the throw aborts the listener for that event, so one filtered
* webhook silently stops delivery for every other webhook on the same event,
* including ones belonging to other applications. An unfiltered subscription
* that occasionally says "look now" about an unrelated folder is the cheap
* mistake; the filter is the expensive one.
*/
async createWebhook(
creds: WebdavCreds,
options: { uri: string; event: string; secretHeader: string; secret: string },
): Promise<number> {
const form = new URLSearchParams();
form.set('httpMethod', 'POST');
form.set('uri', options.uri);
form.set('event', options.event);
form.set('authMethod', 'header');
form.set(`authData[${options.secretHeader}]`, options.secret);
const response = await this.send(creds, OCS_WEBHOOKS, {
method: 'POST',
headers: { ...OCS_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
if (!response.ok) {
throw await this.failed(response, 'The webhook API');
}
const parsed = await readCappedJson<unknown>(response, PROVIDER_JSON_MAX_BYTES);
const id = Number(textOf(asRecord(asRecord(asRecord(parsed)?.ocs)?.data)?.id));
if (!Number.isInteger(id) || id <= 0) {
throw new WebdavError('provider_error', 'The webhook API answered without an id', response.status);
}
return id;
}
async deleteWebhook(creds: WebdavCreds, id: number): Promise<void> {
const response = await this.send(creds, `${OCS_WEBHOOKS}/${id}`, {
method: 'DELETE',
headers: OCS_HEADERS,
});
void response.body?.cancel().catch(() => {});
if (!response.ok && response.status !== 404) {
throw new WebdavError(
classifyStatus(response.status),
`Removing the webhook answered HTTP ${response.status}`,
response.status,
);
}
}
}
@@ -0,0 +1,844 @@
import { Injectable } from '@nestjs/common';
import type {
DocResult,
DocumentConnectionRef,
DocumentProvider,
DocumentProviderCapabilities,
DocumentScopeOption,
DocumentScopeRef,
FetchResult,
PushRequest,
PushResult,
RemoteDocument,
} from '../document-provider';
import { docFail, docFailed, docOk } from '../document-provider';
import {
TREK_PROP_DOC_UID,
TREK_PROP_TRIP_UID,
WebdavClient,
WebdavError,
davPathOf,
encodePath,
normalizeOrigin,
normalizePath,
type WebdavCreds,
type WebdavEntry,
type WebdavFlavor,
} from './webdav.client';
/**
* The document adapter for Nextcloud and OpenCloud.
*
* One implementation serves both, and which one it is talking to is read off
* the connection — Nextcloud brings a login name, an app password and a folder,
* OpenCloud a username, an app token and a space. Nothing below branches on the
* registered `id`; the two subclasses at the end of this file exist only because
* the registry keys providers by `id` and Nest keys them by class, so two
* product ids need two tokens. Everything a product does differently lives in
* `flavorOf()` and the handful of branches it feeds.
*
* The scope is a folder (`fileid:<oc:fileid>`) or a space (`drive:<driveId>`).
* Both ids survive a rename and a move within the same storage, which is what
* lets `stableId` be true and a trip binding survive someone tidying up.
*
* Deliberately NOT here: a change feed. `sync-collection` (RFC 6578) is not
* implemented by either product — Nextcloud answers HTTP 415 with
* `ReportNotSupported` — so the listing is a full enumeration, short-circuited
* by the root ETag, exactly as `DocumentProvider.list` describes.
*/
/** The four Nextcloud events that mean "something in this folder moved". */
const WEBHOOK_EVENTS = [
'OCP\\Files\\Events\\Node\\NodeCreatedEvent',
'OCP\\Files\\Events\\Node\\NodeWrittenEvent',
'OCP\\Files\\Events\\Node\\NodeDeletedEvent',
'OCP\\Files\\Events\\Node\\NodeRenamedEvent',
] as const;
/** Header the subscription carries the shared secret in; the core verifies it. */
const WEBHOOK_SECRET_HEADER = 'X-TREK-Docsync-Signature';
/** Where a Nextcloud connection looks for trip folders when nobody said otherwise. */
const DEFAULT_BASE_PATH = '/TREK';
/** Enough scopes for a busy install, and a ceiling so the cache cannot grow forever. */
const MAX_CACHED_SCOPES = 200;
/**
* A document whose entry carried no `oc:fileid`. Both products always send one,
* but a reverse proxy stripping the `oc:` namespace would otherwise drop the
* file from the trip silently; a path-shaped id keeps it visible at the cost of
* looking renamed if someone renames it.
*/
const PATH_ID_PREFIX = 'path:';
/** Everything the adapter needs to address one bound scope. */
interface ResolvedScope {
creds: WebdavCreds;
/** DAV path of the scope root, percent-encoded, on the connection's own origin. */
rootPath: string;
/** The same path decoded, for deriving a document's path inside the scope. */
rootDecoded: string;
/** The id inside the scope key: an `oc:fileid` or a `driveId`. */
scopeId: string;
}
function textSetting(values: Readonly<Record<string, string>>, key: string): string {
const value = values[key];
return typeof value === 'string' ? value.trim() : '';
}
/**
* Which product this connection describes.
*
* Read off the field set the admin filled in rather than off the provider id,
* so a connection cannot end up being addressed as the product it is not
* because two rows in `document_providers` were swapped.
*/
export function flavorOf(conn: DocumentConnectionRef): WebdavFlavor | null {
if (textSetting(conn.settings, 'login_name') || textSetting(conn.secrets, 'app_password')) {
return 'nextcloud';
}
if (textSetting(conn.settings, 'username') || textSetting(conn.secrets, 'app_token')) {
return 'opencloud';
}
return null;
}
function credsOf(conn: DocumentConnectionRef): WebdavCreds | null {
const flavor = flavorOf(conn);
const origin = normalizeOrigin(conn.baseUrl);
if (!flavor || !/^https?:\/\/[^/]+$/i.test(origin)) return null;
const username =
flavor === 'nextcloud'
? textSetting(conn.settings, 'login_name')
: textSetting(conn.settings, 'username');
const password =
flavor === 'nextcloud'
? textSetting(conn.secrets, 'app_password')
: textSetting(conn.secrets, 'app_token');
if (!username || !password) return null;
return { origin, username, password, allowInsecureTls: conn.allowInsecureTls, flavor };
}
/** `/remote.php/dav/files/<login>` — the per-account root of Nextcloud's file DAV. */
function filesRoot(creds: WebdavCreds): string {
return `/remote.php/dav/files/${encodeURIComponent(creds.username)}`;
}
function basePathOf(conn: DocumentConnectionRef): string {
const configured = textSetting(conn.settings, 'base_path');
return normalizePath(configured || DEFAULT_BASE_PATH);
}
/** `fileid:437` / `drive:storage$space` → the id, for the kind this flavor uses. */
function scopeIdOf(scopeKey: string, flavor: WebdavFlavor): string | null {
const expected = flavor === 'nextcloud' ? 'fileid:' : 'drive:';
if (!scopeKey.startsWith(expected)) return null;
const id = scopeKey.slice(expected.length).trim();
return id.length > 0 ? id : null;
}
/**
* Names both products accept.
*
* Nextcloud refuses `\ / < > : " | ? *` and a trailing dot or space outright, so
* a trip called "Japan: 2026" would fail to get a folder at all. The replacement
* is visible — the label in the returned option is the name that was really
* used, not the one that was asked for.
*/
function sanitizeName(name: string): string {
const cleaned = name
// eslint-disable-next-line no-control-regex
.replace(/[\\/<>:"|?*\u0000-\u001f]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/, '');
return cleaned.slice(0, 200);
}
function parentOf(encodedPath: string): string {
const cut = encodedPath.lastIndexOf('/');
return cut > 0 ? encodedPath.slice(0, cut) : '';
}
/** The document's path inside its scope, decoded, with no leading slash. */
function relativeOf(entry: WebdavEntry, rootDecoded: string): string {
return entry.path.startsWith(`${rootDecoded}/`)
? entry.path.slice(rootDecoded.length + 1)
: entry.name;
}
function documentOf(entry: WebdavEntry, rootDecoded: string): RemoteDocument {
const relative = relativeOf(entry, rootDecoded);
return {
remoteId: entry.fileId ?? `${PATH_ID_PREFIX}${relative}`,
name: entry.name,
size: entry.size,
mimeType: entry.mimeType,
// An ETag is what both products change on every write. When one is missing
// the pair below still differs after a write, which is all the core reads
// out of this field.
remoteVersion: entry.etag || `mtime:${entry.lastModifiedIso ?? ''}:${entry.size ?? ''}`,
contentHash: entry.sha256,
remoteModifiedAt: entry.lastModifiedIso,
// WebDAV has no tombstone in a listing: a deleted file is simply absent, and
// the core's own mass-delete guard is what stands between that and a trip
// emptying itself.
isDeleted: false,
};
}
/**
* Anything that goes wrong, as a result rather than an exception.
*
* `probe` and its siblings always resolve because the settings screen renders
* the answer inline. An error that is not a WebdavError is a bug in this file
* rather than a provider problem, and it still has to come back as data — one
* broken adapter must not take down the run for every other link.
*/
function failureOf<T>(err: unknown): DocResult<T> {
if (err instanceof WebdavError) {
return docFail<T>(err.code, err.detail ?? err.message, err.status);
}
return docFail<T>('unknown', err instanceof Error ? err.message : String(err));
}
@Injectable()
export class WebdavDocumentProvider implements DocumentProvider {
readonly id: string = 'nextcloud';
/**
* Scope key → (remote id → still-encoded href), refilled by every listing.
*
* WebDAV addresses resources by path while the core hands back the stable id
* it stored, and neither product can PROPFIND a Nextcloud file id directly.
* Without this, every download, rename and trash would re-walk the folder
* first. It is a cache, not state: a miss costs one PROPFIND, never a wrong
* answer.
*/
private readonly hrefCache = new Map<string, Map<string, string>>();
constructor(private readonly client: WebdavClient) {}
capabilities(conn: DocumentConnectionRef): DocumentProviderCapabilities {
return {
// Conservative on purpose: most Nextcloud connections are an ordinary
// account's app password, and `webhook_listeners` is admin-only. `probe`
// upgrades this to `webhook-self-registered` once it has asked the
// instance. OpenCloud has no HTTP subscription API at all — its change
// events live on an internal NATS bus that no trip admin can point at
// TREK — so it is `none` rather than a `webhook-manual` that would send
// someone looking for a settings page that does not exist.
push: flavorOf(conn) === 'nextcloud' ? 'webhook-manual' : 'none',
stableId: true,
remoteTrash: true,
replaceInPlace: true,
// False for both: Nextcloud stores whatever checksum the uploader claimed
// without verifying it, and OpenCloud computes SHA1/MD5/ADLER32 but never
// SHA256. See the header of webdav.client.ts.
contentHashInListing: false,
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
}
async probe(
conn: DocumentConnectionRef,
): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>> {
const creds = credsOf(conn);
if (!creds) return docFail('unauthorized', 'The connection is missing a URL, a username or a password');
try {
const capabilities = this.capabilities(conn);
if (creds.flavor === 'opencloud') {
const account = await this.client.whoAmI(creds);
return docOk({ account, capabilities });
}
// The WebDAV root rather than an OCS endpoint: it is the surface every
// other call uses, and a login name that does not match the account
// answers 404 here while the password checks out fine.
try {
await this.client.propfind(creds, filesRoot(creds), 0);
} catch (err: unknown) {
if (err instanceof WebdavError && err.status === 404) {
return docFail(
'not_found',
`No WebDAV home for login name "${creds.username}". Nextcloud shows the exact spelling under Settings → Personal → Security.`,
404,
);
}
throw err;
}
const canRegister = await this.client.canRegisterWebhooks(creds);
return docOk({
account: creds.username,
capabilities: {
...capabilities,
push: canRegister ? 'webhook-self-registered' : 'webhook-manual',
},
});
} catch (err: unknown) {
return failureOf(err);
}
}
async listScopes(conn: DocumentConnectionRef, query?: string): Promise<DocResult<DocumentScopeOption[]>> {
const creds = credsOf(conn);
if (!creds) return docFail('unauthorized', 'The connection is missing a URL, a username or a password');
const needle = query?.trim().toLowerCase() ?? '';
try {
const options =
creds.flavor === 'nextcloud'
? await this.nextcloudScopes(conn, creds)
: await this.opencloudScopes(creds);
return docOk(needle ? options.filter((option) => option.label.toLowerCase().includes(needle)) : options);
} catch (err: unknown) {
return failureOf(err);
}
}
private async nextcloudScopes(
conn: DocumentConnectionRef,
creds: WebdavCreds,
): Promise<DocumentScopeOption[]> {
const basePath = basePathOf(conn);
let listing;
try {
listing = await this.client.propfind(creds, `${filesRoot(creds)}${encodePath(basePath)}`, 1);
} catch (err: unknown) {
// The base folder does not exist yet on a fresh instance. An empty picker
// plus a working "create" is a better answer than an error that reads
// like the credentials are wrong.
if (err instanceof WebdavError && err.status === 404) return [];
throw err;
}
const options: DocumentScopeOption[] = [];
for (const child of listing.children) {
if (!child.isCollection || !child.fileId) continue;
options.push({
scopeKey: `fileid:${child.fileId}`,
label: child.name,
remoteRootId: child.fileId,
remoteRootPath: `${basePath}/${child.name}`,
});
}
return options;
}
private async opencloudScopes(creds: WebdavCreds): Promise<DocumentScopeOption[]> {
const drives = await this.client.listDrives(creds);
return drives
// `virtual` is the synthetic "Shares" drive, which has no storage of its
// own and cannot hold an upload.
.filter((drive) => drive.driveType !== 'virtual')
.map((drive) => ({
scopeKey: `drive:${drive.id}`,
label: drive.name,
remoteRootId: drive.id,
remoteRootPath: `${creds.origin}${drive.webDavPath}`,
}));
}
async createScope(conn: DocumentConnectionRef, name: string): Promise<DocResult<DocumentScopeOption>> {
const creds = credsOf(conn);
if (!creds) return docFail('unauthorized', 'The connection is missing a URL, a username or a password');
const safeName = sanitizeName(name);
if (!safeName) return docFail('provider_error', 'The name has no characters the provider accepts');
try {
if (creds.flavor === 'opencloud') {
const drive = await this.client.createDrive(creds, safeName);
return docOk({
scopeKey: `drive:${drive.id}`,
label: drive.name,
remoteRootId: drive.id,
remoteRootPath: `${creds.origin}${drive.webDavPath}`,
});
}
const basePath = basePathOf(conn);
const path = `${filesRoot(creds)}${encodePath(`${basePath}/${safeName}`)}`;
let created: 'created' | 'exists';
try {
created = await this.client.mkcol(creds, path);
} catch (err: unknown) {
// 409 is Sabre's "parent node does not exist", i.e. the base folder has
// never been created. Making it is the whole point of this call.
if (!(err instanceof WebdavError) || err.status !== 409) throw err;
await this.client.mkcol(creds, `${filesRoot(creds)}${encodePath(basePath)}`);
created = await this.client.mkcol(creds, path);
}
if (created === 'exists') {
return docFail(
'conflict',
`"${safeName}" already exists under ${basePath}. Pick it in the list instead of creating it again.`,
405,
);
}
// MKCOL answers an OC-FileId header, but only on Nextcloud and only in the
// padded spelling; one PROPFIND gets the id in the form every listing
// will use, which is the form the scope key has to carry.
const folder = await this.client.propfind(creds, path, 0);
if (!folder.self.fileId) {
return docFail('provider_error', 'The new folder came back without an oc:fileid');
}
return docOk({
scopeKey: `fileid:${folder.self.fileId}`,
label: safeName,
remoteRootId: folder.self.fileId,
remoteRootPath: `${basePath}/${safeName}`,
});
} catch (err: unknown) {
return failureOf(err);
}
}
async resolveScope(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<DocumentScopeOption>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
const { creds, rootPath, scopeId } = resolved.data;
try {
if (creds.flavor === 'opencloud') {
const drive = await this.client.getDrive(creds, scopeId);
if (!drive) return docFail('scope_missing', `The space ${scopeId} no longer exists`);
return docOk({
scopeKey: `drive:${drive.id}`,
label: drive.name,
remoteRootId: drive.id,
remoteRootPath: `${creds.origin}${drive.webDavPath}`,
});
}
const folder = await this.client.propfind(creds, rootPath, 0);
// The path still resolves but the id behind it changed: someone deleted
// the folder and made a new one with the same name, and its contents have
// nothing to do with this trip.
if (folder.self.fileId !== scopeId) {
return docFail(
'scope_missing',
`${scope.remoteRootPath ?? rootPath} is a different folder now (oc:fileid ${folder.self.fileId ?? 'missing'} instead of ${scopeId})`,
);
}
return docOk({
scopeKey: `fileid:${scopeId}`,
label: folder.self.name,
remoteRootId: scopeId,
remoteRootPath: scope.remoteRootPath,
});
} catch (err: unknown) {
if (err instanceof WebdavError && err.status === 404) {
return docFail('scope_missing', err.detail ?? err.message, 404);
}
return failureOf(err);
}
}
async list(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): Promise<DocResult<{
documents: RemoteDocument[];
cursor: string | null;
cursorUnchanged: boolean;
truncated: boolean;
}>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
const { creds, rootPath, rootDecoded } = resolved.data;
try {
// A write anywhere below a collection changes that collection's ETag up to
// the root on both products, so one Depth:0 request answers "has anything
// at all happened here" for a folder of any size.
if (scope.cursor) {
const head = await this.client.propfind(creds, rootPath, 0);
if (head.self.etag && head.self.etag === scope.cursor) {
return docOk({ documents: [], cursor: scope.cursor, cursorUnchanged: true, truncated: false });
}
}
const listing = await this.client.propfind(creds, rootPath, 1);
const documents: RemoteDocument[] = [];
const hrefs = new Map<string, string>();
for (const child of listing.children) {
// Sub-folders are not documents and are not descended into: Depth:1 is
// one request per run whatever the folder holds, and a recursive walk
// would trade that for one request per directory.
if (child.isCollection) continue;
const document = documentOf(child, rootDecoded);
documents.push(document);
hrefs.set(document.remoteId, child.href);
}
this.rememberHrefs(conn, scope, hrefs);
return docOk({
documents,
cursor: listing.self.etag || null,
cursorUnchanged: false,
truncated: listing.truncated,
});
} catch (err: unknown) {
if (err instanceof WebdavError && err.status === 404) {
return docFail('scope_missing', err.detail ?? err.message, 404);
}
return failureOf(err);
}
}
async fetch(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<FetchResult>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
try {
const href = await this.hrefFor(conn, scope, resolved.data, remoteId);
if (!href) return docFail('not_found', `${remoteId} is no longer in this folder`);
const download = await this.client.get(resolved.data.creds, href);
return docOk({
body: download.body,
size: download.size,
mimeType: download.mimeType,
remoteVersion: download.etag,
});
} catch (err: unknown) {
return failureOf(err);
}
}
async push(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
req: PushRequest,
): Promise<DocResult<PushResult>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
const { creds, rootPath } = resolved.data;
const name = sanitizeName(req.fileName);
if (!name) return docFail('provider_error', 'The file name has no characters the provider accepts');
try {
let target: string;
if (req.remoteId) {
const href = await this.hrefFor(conn, scope, resolved.data, req.remoteId);
// Replacing something that is gone is not the same as creating it: the
// core records the document as missing and asks a human, rather than
// resurrecting a file somebody deleted upstream on purpose.
if (!href) return docFail('not_found', `${req.remoteId} is no longer in this folder`);
target = href;
} else {
target = `${rootPath}/${encodeURIComponent(name)}`;
}
const result = await this.client.put(creds, target, req.body, {
size: req.size,
mimeType: req.mimeType,
// Without this the file TREK just uploaded looks freshly changed
// upstream on the next run, and the two sides push it back and forth.
mtimeSeconds: req.mtimeSeconds,
sha256: req.sha256,
ifMatch: req.expectedRemoteVersion,
});
// Best effort, and deliberately not fatal: the bytes are already stored,
// so failing here would make the core upload them again on every run. The
// anchors are a recovery aid, not part of the document's identity.
try {
await this.client.proppatch(creds, target, {
[TREK_PROP_DOC_UID]: req.trekDocUid,
[TREK_PROP_TRIP_UID]: req.trekTripUid,
});
} catch (err: unknown) {
if (!(err instanceof WebdavError)) throw err;
}
let { fileId, etag } = result;
// A reverse proxy that drops the OC-* headers leaves the upload done and
// unidentifiable, so ask for what it swallowed.
if (!fileId || !etag) {
const stored = await this.client.propfind(creds, target, 0);
fileId ??= stored.self.fileId;
etag ||= stored.self.etag;
}
if (!fileId || !etag) {
return docFail('provider_error', 'The upload succeeded but the instance never named the stored file');
}
this.rememberHrefs(conn, scope, new Map([[fileId, target]]));
return docOk({
remoteId: fileId,
remoteVersion: etag,
remoteModifiedAt: new Date(req.mtimeSeconds * 1000).toISOString(),
// Neither product de-duplicates by content; a second upload of the same
// bytes is a second file.
deduplicated: false,
});
} catch (err: unknown) {
return failureOf(err);
}
}
async rename(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
name: string,
): Promise<DocResult<{ remoteVersion: string }>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
const safeName = sanitizeName(name);
if (!safeName) return docFail('provider_error', 'The new name has no characters the provider accepts');
try {
const href = await this.hrefFor(conn, scope, resolved.data, remoteId);
if (!href) return docFail('not_found', `${remoteId} is no longer in this folder`);
// Within the same parent: a rename moves a document inside the trip's
// folder, it never moves it out of the scope the trip is bound to.
const destination = `${parentOf(href)}/${encodeURIComponent(safeName)}`;
const moved = await this.client.move(resolved.data.creds, href, destination);
this.rememberHrefs(conn, scope, new Map([[remoteId, destination]]));
if (moved.etag) return docOk({ remoteVersion: moved.etag });
const renamed = await this.client.propfind(resolved.data.creds, destination, 0);
return docOk({ remoteVersion: renamed.self.etag });
} catch (err: unknown) {
return failureOf(err);
}
}
async trash(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
remoteId: string,
): Promise<DocResult<void>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
try {
const href = await this.hrefFor(conn, scope, resolved.data, remoteId);
// Already gone is the state the caller asked for.
if (!href) return docOk(undefined);
await this.client.delete(resolved.data.creds, href);
this.forgetHref(conn, scope, remoteId);
return docOk(undefined);
} catch (err: unknown) {
return failureOf(err);
}
}
/**
* Subscribe to the four file events, one subscription each.
*
* A failure here is never a broken connection: the endpoint is admin-only, so
* the ordinary case of a non-admin app password is a plain "no", the link
* stays on polling, and `probe` already reported `webhook-manual` for exactly
* this account. Partial registrations are rolled back so a retry does not
* leave a pile of orphaned subscriptions behind.
*
* Two things a self-hoster has to know, neither of which this call can fix:
* Nextcloud delivers webhooks through its background-job queue, so the
* notification is as prompt as that instance's cron and no prompter, and
* `allow_local_remote_servers` must be on or a callback to a LAN address is
* refused at delivery time while the subscription still looks healthy.
*/
async registerWebhook(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
callbackUrl: string,
secret: string,
): Promise<DocResult<{ subscriptionId: string }>> {
const resolved = this.resolve(conn, scope);
if (docFailed(resolved)) return resolved;
const { creds } = resolved.data;
if (creds.flavor === 'opencloud') {
return docFail('forbidden', 'OpenCloud has no webhook API a trip admin can subscribe to');
}
const created: number[] = [];
try {
if (!(await this.client.canRegisterWebhooks(creds))) {
return docFail(
'forbidden',
'This Nextcloud account may not manage webhooks, so the link keeps polling instead',
403,
);
}
// Deliberately unscoped: the obvious `eventFilter` on the changed path
// needs `$regex`, which the app's query engine does not implement, and the
// exception it throws stops delivery for every other webhook on the same
// event. A subscription that says "look now" about a folder this trip does
// not care about costs one listing; the filter costs the whole channel.
for (const event of WEBHOOK_EVENTS) {
created.push(
await this.client.createWebhook(creds, {
uri: callbackUrl,
event,
secretHeader: WEBHOOK_SECRET_HEADER,
secret,
}),
);
}
return docOk({ subscriptionId: created.join(',') });
} catch (err: unknown) {
for (const id of created) {
await this.client.deleteWebhook(creds, id).catch(() => undefined);
}
return failureOf(err);
}
}
async unregisterWebhook(conn: DocumentConnectionRef, subscriptionId: string): Promise<DocResult<void>> {
const creds = credsOf(conn);
if (!creds) return docFail('unauthorized', 'The connection is missing a URL, a username or a password');
try {
for (const part of subscriptionId.split(',')) {
const id = Number(part.trim());
if (Number.isInteger(id) && id > 0) {
await this.client.deleteWebhook(creds, id);
}
}
return docOk(undefined);
} catch (err: unknown) {
return failureOf(err);
}
}
/** Connection plus scope key turned into an addressable root, or the reason it is not one. */
private resolve(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
): DocResult<ResolvedScope> {
const creds = credsOf(conn);
if (!creds) return docFail('unauthorized', 'The connection is missing a URL, a username or a password');
const scopeId = scopeIdOf(scope.scopeKey, creds.flavor);
if (!scopeId) {
return docFail('scope_missing', `"${scope.scopeKey}" is not a scope key this provider issued`);
}
if (creds.flavor === 'nextcloud') {
const userPath = scope.remoteRootPath ? normalizePath(scope.remoteRootPath) : null;
if (!userPath || userPath === '/') {
return docFail('scope_missing', 'The binding has no folder path, and a file id cannot be addressed over WebDAV');
}
const rootPath = `${filesRoot(creds)}${encodePath(userPath)}`;
return docOk({ creds, rootPath, rootDecoded: `${filesRoot(creds)}${userPath}`, scopeId });
}
// The stored root is the space's own webDavUrl. Only its path is used, and
// it is re-based on the connection's origin: the URL came out of a provider
// response, and following the host in it would hand an attacker who can
// answer for that instance a way past the SSRF guard.
const path = scope.remoteRootPath ? davPathOf(scope.remoteRootPath) : null;
const rootPath = path ?? `/dav/spaces/${encodeURIComponent(scopeId)}`;
let decoded: string;
try {
decoded = normalizePath(decodeURIComponent(rootPath));
} catch {
// A stored path with a stray percent sign is still usable as-is; failing
// the whole binding over a decoding quirk would be worse than the quirk.
decoded = rootPath;
}
return docOk({ creds, rootPath, rootDecoded: decoded, scopeId });
}
private cacheKey(conn: DocumentConnectionRef, scope: DocumentScopeRef): string {
return `${conn.connectionId}:${scope.scopeKey}`;
}
private rememberHrefs(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
hrefs: Map<string, string>,
): void {
const key = this.cacheKey(conn, scope);
const existing = this.hrefCache.get(key);
if (existing) {
for (const [id, href] of hrefs) existing.set(id, href);
return;
}
if (this.hrefCache.size >= MAX_CACHED_SCOPES) {
// Map iterates in insertion order, so the first key is the oldest scope.
const oldest = this.hrefCache.keys().next();
if (!oldest.done) this.hrefCache.delete(oldest.value);
}
this.hrefCache.set(key, new Map(hrefs));
}
private forgetHref(conn: DocumentConnectionRef, scope: DocumentScopeRef, remoteId: string): void {
this.hrefCache.get(this.cacheKey(conn, scope))?.delete(remoteId);
}
/** The href for a remote id, re-walking the folder once when the cache cannot say. */
private async hrefFor(
conn: DocumentConnectionRef,
scope: DocumentScopeRef,
resolved: ResolvedScope,
remoteId: string,
): Promise<string | null> {
if (remoteId.startsWith(PATH_ID_PREFIX)) {
const relative = remoteId.slice(PATH_ID_PREFIX.length);
return relative ? `${resolved.rootPath}/${encodePath(relative)}` : null;
}
const cached = this.hrefCache.get(this.cacheKey(conn, scope))?.get(remoteId);
if (cached) return cached;
const listing = await this.client.propfind(resolved.creds, resolved.rootPath, 1);
const hrefs = new Map<string, string>();
for (const child of listing.children) {
if (child.isCollection) continue;
hrefs.set(child.fileId ?? `${PATH_ID_PREFIX}${relativeOf(child, resolved.rootDecoded)}`, child.href);
}
this.rememberHrefs(conn, scope, hrefs);
return hrefs.get(remoteId) ?? null;
}
}
/**
* The two registrations.
*
* They carry an id and nothing else. The registry maps a connection's
* `provider_id` onto a provider instance and Nest maps a class onto a token, so
* two product ids need two classes — but both run the implementation above, and
* that implementation never asks which one it is.
*
* The constructors are spelled out rather than inherited: Nest reads
* `design:paramtypes` off the concrete class, and a subclass without its own
* constructor is emitted without that metadata and resolved with no arguments.
*/
@Injectable()
export class NextcloudDocumentProvider extends WebdavDocumentProvider {
override readonly id = 'nextcloud';
constructor(client: WebdavClient) {
super(client);
}
}
@Injectable()
export class OpencloudDocumentProvider extends WebdavDocumentProvider {
override readonly id = 'opencloud';
constructor(client: WebdavClient) {
super(client);
}
}
+335
View File
@@ -0,0 +1,335 @@
/**
* Document sync e2e — drives /api/trips/:tripId/docsync through the REAL
* AddonGuard, JwtAuthGuard and TripAccessGuard, over the real DI services
* against a temp SQLite carrying the full schema.
*
* Two things are replaced and nothing else:
*
* - `AddonsService.isAddonEnabled`, because the gate is what is under test;
* - every provider adapter, because a test must never open a socket to
* somebody's document archive. Each one is a spy that fails loudly if a
* route this file exercises turns out to reach a provider after all.
*
* What is pinned here is the access model, which is the part of this feature
* that would hurt most if it drifted: the addon gate answering 404 before 401,
* a stranger being unable to see that a trip exists, and a plain member being
* able to READ where their documents go while only the owner may change it.
* That last split is unusual for TREK — most trip routes treat members alike —
* so it is worth a test that fails if someone "tidies" it away.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec('PRAGMA foreign_keys = ON');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: number | string, userId: number) =>
db
.prepare(
'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)',
)
.get(userId, tripId, userId),
isOwner: () => false,
}));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
/**
* The SSRF guard resolves DNS, and `paperless.example.com` does not exist — in
* CI that is a lookup failure, which the guard correctly reports as "not
* allowed". The guard is not what this file is testing, and a suite that needs
* a working resolver is a suite that fails on someone's train.
*/
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/ssrfGuard')>();
return {
...actual,
checkSsrf: vi.fn(async (url: string) => ({
allowed: !url.includes('blocked.invalid'),
isPrivate: false,
error: url.includes('blocked.invalid') ? 'blocked in test' : undefined,
})),
};
});
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createTrip, createUser } from '../helpers/factories';
import { DocSyncModule } from '../../src/nest/doc-sync/doc-sync.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { DOCUMENT_PROVIDERS } from '../../src/nest/doc-sync/document-provider';
import { PaperlessDocumentProvider } from '../../src/nest/doc-sync/providers/paperless.provider';
import { PapraProvider } from '../../src/nest/doc-sync/providers/papra.provider';
import { SynologyDriveProvider } from '../../src/nest/doc-sync/providers/synology-drive.provider';
import { NextcloudDocumentProvider, OpencloudDocumentProvider } from '../../src/nest/doc-sync/providers/webdav.provider';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
/** A provider that answers plausibly but never opens a socket. */
function fakeProvider(id: string) {
const caps = {
push: 'none' as const,
stableId: true,
remoteTrash: true,
replaceInPlace: true,
contentHashInListing: true,
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
return {
id,
capabilities: () => caps,
probe: vi.fn(async () => ({ success: true as const, data: { account: `${id}-account`, capabilities: caps } })),
listScopes: vi.fn(async () => ({
success: true as const,
data: [{ scopeKey: `tag:1`, label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' }],
})),
createScope: vi.fn(async (_c: unknown, name: string) => ({
success: true as const,
data: { scopeKey: `tag:2`, label: name, remoteRootId: '2', remoteRootPath: `/TREK/${name}` },
})),
resolveScope: vi.fn(async () => ({
success: true as const,
data: { scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' },
})),
list: vi.fn(async () => ({ success: true as const, data: { documents: [], cursor: 'c1', cursorUnchanged: false, truncated: false } })),
fetch: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
push: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
rename: vi.fn(async () => ({ success: true as const, data: { remoteVersion: 'v2' } })),
trash: vi.fn(async () => ({ success: true as const, data: undefined })),
};
}
describe('Document sync e2e (real guards + real services + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let ownerId: number;
let memberId: number;
let strangerId: number;
let tripId: number;
async function build() {
const providers = [
[PaperlessDocumentProvider, 'paperless'],
[PapraProvider, 'papra'],
[NextcloudDocumentProvider, 'nextcloud'],
[OpencloudDocumentProvider, 'opencloud'],
[SynologyDriveProvider, 'synologydrive'],
] as const;
let builder = Test.createTestingModule({ imports: [DatabaseModule, DocSyncModule] })
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled });
const fakes = providers.map(([, id]) => fakeProvider(id));
providers.forEach(([token], i) => {
builder = builder.overrideProvider(token).useValue(fakes[i]);
});
builder = builder.overrideProvider(DOCUMENT_PROVIDERS).useValue(fakes);
const moduleRef = await builder.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db as never);
runMigrations(db as never);
ownerId = createUser(db as never, { username: 'owner', email: 'owner@test.local' }).user.id;
memberId = createUser(db as never, { username: 'member', email: 'member@test.local' }).user.id;
strangerId = createUser(db as never, { username: 'stranger', email: 'stranger@test.local' }).user.id;
tripId = createTrip(db as never, ownerId, { title: 'Japan', start_date: '2026-10-01', end_date: '2026-10-07' }).id;
db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, memberId);
// Providers are off by default, exactly as a fresh install ships them.
db.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud')").run();
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app?.close();
db.close();
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
});
it('answers 404 rather than 401 when the addon is off, to everyone alike', async () => {
isAddonEnabled.mockReturnValue(false);
await request(server).get(`/api/trips/${tripId}/docsync/status`).expect(404);
await request(server)
.get(`/api/trips/${tripId}/docsync/status`)
.set('Cookie', sessionCookie(ownerId))
.expect(404);
});
it('refuses an unauthenticated caller', async () => {
await request(server).get(`/api/trips/${tripId}/docsync/status`).expect(401);
});
it('hides the trip from a stranger behind a 404', async () => {
await request(server)
.get(`/api/trips/${tripId}/docsync/status`)
.set('Cookie', sessionCookie(strangerId))
.expect(404);
});
it('lists only the providers an admin switched on', async () => {
const res = await request(server)
.get(`/api/trips/${tripId}/docsync/providers`)
.set('Cookie', sessionCookie(ownerId))
.expect(200);
expect(res.body.map((p: { id: string }) => p.id).sort()).toEqual(['nextcloud', 'paperless']);
// Every listed provider carries its form fields, so the client renders the
// connection form from data rather than from a hard-coded list.
expect(res.body[0].fields.length).toBeGreaterThan(0);
});
it('lets a plain member read the status but not change the binding', async () => {
await request(server)
.get(`/api/trips/${tripId}/docsync/status`)
.set('Cookie', sessionCookie(memberId))
.expect(200);
await request(server)
.put(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(memberId))
.send({ providerId: 'paperless', baseUrl: 'https://paperless.example.com', credentials: { api_token: 'x' } })
.expect(403);
});
it('refuses a provider the admin has not switched on', async () => {
await request(server)
.put(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId))
.send({ providerId: 'papra', baseUrl: 'https://papra.example.com', credentials: { api_key: 'x', organization_id: 'org_1' } })
.expect(400);
});
it('rejects an unknown provider id at the contract, not in the handler', async () => {
await request(server)
.put(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId))
.send({ providerId: 'dropbox', baseUrl: 'https://example.com', credentials: {} })
.expect(400);
});
it('stores a connection and never gives the secret back', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId))
.send({
providerId: 'paperless',
baseUrl: 'https://paperless.example.com/',
credentials: { api_token: 'super-secret-token' },
})
.expect(200);
expect(res.body.baseUrl).toBe('https://paperless.example.com');
expect(res.body.secrets.api_token).toBe('••••••••');
expect(JSON.stringify(res.body)).not.toContain('super-secret-token');
// And it is encrypted at rest, not merely hidden on the way out.
const row = db.prepare('SELECT secrets FROM document_connections WHERE trip_id = ?').get(tripId) as { secrets: string };
expect(row.secrets).toMatch(/^enc:v1:/);
expect(row.secrets).not.toContain('super-secret-token');
});
it('keeps the stored secret when the form comes back with a blank field', async () => {
await request(server)
.put(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId))
.send({ providerId: 'paperless', baseUrl: 'https://paperless.example.com', credentials: { api_token: '' } })
.expect(200);
const row = db.prepare('SELECT secrets FROM document_connections WHERE trip_id = ?').get(tripId) as { secrets: string };
expect(row.secrets).toMatch(/^enc:v1:/);
});
it('binds the trip to a scope and reports it to every member', async () => {
const conns = await request(server)
.get(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId))
.expect(200);
const connectionId = conns.body[0].id;
await request(server)
.post(`/api/trips/${tripId}/docsync/links`)
.set('Cookie', sessionCookie(ownerId))
.send({ connectionId, scopeKey: 'tag:1', remoteLabel: 'Japan 2026', direction: 'both', deletePolicy: 'unlink', conflictPolicy: 'manual', syncEnabled: true })
.expect(200);
const asMember = await request(server)
.get(`/api/trips/${tripId}/docsync/links`)
.set('Cookie', sessionCookie(memberId))
.expect(200);
expect(asMember.body).toHaveLength(1);
expect(asMember.body[0].remoteLabel).toBe('Japan 2026');
// The shared secret behind the webhook URL is never handed out, not even to
// the owner: it exists so a provider can prove itself, not to be read.
expect(asMember.body[0].webhookSecret).toBe('••••••••');
});
it('refuses a binding whose connection belongs to another trip', async () => {
const otherTripId = createTrip(db as never, ownerId, { title: 'Other' }).id;
const conns = await request(server)
.get(`/api/trips/${tripId}/docsync/connections`)
.set('Cookie', sessionCookie(ownerId));
await request(server)
.post(`/api/trips/${otherTripId}/docsync/links`)
.set('Cookie', sessionCookie(ownerId))
.send({ connectionId: conns.body[0].id, scopeKey: 'tag:1' })
.expect(400);
});
it('answers 200 with a verdict in the body when a probe fails, not an error status', async () => {
// The settings form needs a field to render, not an exception — the same
// contract the photo providers pin as CRITICAL in their own e2e suite.
const res = await request(server)
.post(`/api/trips/${tripId}/docsync/connections/test`)
.set('Cookie', sessionCookie(ownerId))
.send({ providerId: 'paperless', baseUrl: 'https://blocked.invalid', credentials: {} })
.expect(200);
expect(res.body.connected).toBe(false);
expect(typeof res.body.error).toBe('string');
});
it('unbinds without deleting anything', async () => {
const links = await request(server)
.get(`/api/trips/${tripId}/docsync/links`)
.set('Cookie', sessionCookie(ownerId));
const linkId = links.body[0].id;
const res = await request(server)
.delete(`/api/trips/${tripId}/docsync/links/${linkId}`)
.set('Cookie', sessionCookie(ownerId))
.expect(200);
expect(res.body.documentsKept).toBe(true);
expect(db.prepare('SELECT COUNT(*) AS n FROM trip_document_links WHERE id = ?').get(linkId)).toEqual({ n: 0 });
});
it('accepts an unknown webhook token without revealing that it is unknown', async () => {
// A 404 here would let anyone enumerate which bindings exist.
await request(server).post('/api/docsync/webhook/definitely-not-a-token').expect(200);
});
});
@@ -0,0 +1,528 @@
/**
* The configuration half of document sync: the connection a trip admin creates
* and the binding that points it at a folder or a tag.
*
* The database here is real in-memory SQLite with the full schema, and the
* at-rest crypto is real too. Both are deliberate. Almost everything this
* service owns is either a credential or a decision about one, and a spy on
* `db.run` would only prove that some strings were handed along — reading the
* row back is the assertion. A stubbed `maybe_encrypt_api_key` would let a
* regression that stores a Paperless token in plaintext pass every case below.
*
* Only the SSRF guard is faked, because it resolves DNS: the guard is not what
* is under test, and a suite that needs a working resolver is a suite that
* fails on someone's train.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
},
};
});
vi.mock('../../../../src/db/database', () => dbMock);
// The whole guard surface, not only `checkSsrf`: modules on the import graph
// pull other names off it, and a factory mock that omits one throws on access
// rather than falling back to the real export.
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
vi.mock('../../../../src/utils/ssrfGuard', () => ({
checkSsrf,
safeFetch: vi.fn(),
safeFetchFollow: vi.fn(),
safeFetchAdminConfigured: vi.fn(),
safeFetchLlm: vi.fn(),
createPinnedDispatcher: vi.fn(() => ({})),
SsrfBlockedError: class extends Error {},
}));
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { resetTestDb } from '../../../helpers/test-db';
import { createTrip, createUser } from '../../../helpers/factories';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import {
DocSyncConfigService,
type ConnectionRow,
type LinkRow,
} from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
// `if (!result.success)` does not narrow in this workspace — strictNullChecks is
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
const svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry);
const TOKEN = 'paperless-token-1234';
let OWNER = 0;
let MEMBER = 0;
let TRIP = 0;
function connectionInput(over: Partial<DocsyncConnectionInput> = {}): DocsyncConnectionInput {
return {
providerId: 'paperless',
baseUrl: 'https://paperless.example.com',
credentials: { api_token: TOKEN },
allowInsecureTls: false,
...over,
};
}
async function connect(over: Partial<DocsyncConnectionInput> = {}, userId = OWNER): Promise<ConnectionRow> {
const result = await svc.upsertConnection(TRIP, userId, connectionInput(over));
if (docFailed(result)) throw new Error(`fixture connection refused: ${JSON.stringify(result.error)}`);
return result.data;
}
function linkInput(connectionId: number, over: Partial<DocsyncLinkInput> = {}): DocsyncLinkInput {
return {
connectionId,
scopeKey: 'tag:1',
remoteLabel: 'Japan 2026',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
...over,
};
}
function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): LinkRow {
const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over), null);
if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`);
return result.data;
}
function storedSecret(connectionId: number): string | undefined {
const row = svc.getConnection(connectionId) as ConnectionRow;
return svc.toRef(row).secrets.api_token;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
// The document_* tables are not in resetTestDb's list, and they reference
// trips and users — so they go first, before the rows they hang off vanish.
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;');
resetTestDb(testDb);
vi.clearAllMocks();
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '203.0.113.10' });
OWNER = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user.id;
MEMBER = createUser(testDb, { username: 'member', email: 'member@test.local' }).user.id;
TRIP = createTrip(testDb, OWNER, { title: 'Japan' }).id;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
});
afterAll(() => {
testDb.close();
});
describe('upsertConnection secrets', () => {
it('keeps the stored credential when the form comes back with the field blank', async () => {
const created = await connect();
await connect({ credentials: { api_token: '' } });
expect(storedSecret(created.id)).toBe(TOKEN);
});
it('keeps it when the form posts back the mask it was shown', async () => {
// This is what makes a GET-then-PUT round trip safe: the client renders the
// form from the masked view and never has to hold the real credential.
const created = await connect();
await connect({ credentials: { api_token: DOCSYNC_SECRET_MASK } });
expect(storedSecret(created.id)).toBe(TOKEN);
});
it('keeps it when the field is missing from the payload altogether', async () => {
const created = await connect();
await connect({ credentials: {} });
expect(storedSecret(created.id)).toBe(TOKEN);
});
it('replaces it when a new credential actually arrives', async () => {
const created = await connect();
await connect({ credentials: { api_token: 'rotated-token' } });
expect(storedSecret(created.id)).toBe('rotated-token');
});
it('updates the trip\'s connection in place rather than adding a second one', async () => {
const first = await connect();
const second = await connect({ baseUrl: 'https://paperless.example.com/', credentials: {} });
expect(second.id).toBe(first.id);
const count = testDb
.prepare('SELECT COUNT(*) AS n FROM document_connections WHERE trip_id = ?')
.get(TRIP) as { n: number };
expect(count.n).toBe(1);
});
it('carries a non-secret setting forward when a later save omits it', async () => {
const created = await svc.upsertConnection(TRIP, OWNER, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'anna', app_password: 'app-pw', base_path: '/TREK' },
allowInsecureTls: false,
});
expect(created.success).toBe(true);
await svc.upsertConnection(TRIP, OWNER, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'anna' },
allowInsecureTls: false,
});
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'nextcloud') as ConnectionRow;
expect(svc.toRef(row).settings).toMatchObject({ login_name: 'anna', base_path: '/TREK' });
expect(svc.toRef(row).secrets.app_password).toBe('app-pw');
});
});
describe('publicConnection', () => {
it('reports that a secret is set without ever carrying its value', async () => {
const created = await connect();
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
expect(view.secrets).toEqual({ api_token: DOCSYNC_SECRET_MASK });
expect(JSON.stringify(view)).not.toContain(TOKEN);
});
it('leaves a secret it has no value for out of the map entirely', async () => {
// Masking an unset field would tell the client "already configured" and the
// connection form would render an optional credential as one that is done.
const created = await svc.upsertConnection(TRIP, OWNER, {
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw' },
allowInsecureTls: false,
});
expect(created.success).toBe(true);
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'synologydrive') as ConnectionRow;
expect(svc.publicConnection(row).secrets).toEqual({ password: DOCSYNC_SECRET_MASK });
});
it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => {
const created = await connect();
testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id);
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
expect(view.capabilities).toBeNull();
});
});
describe('validateBaseUrl', () => {
it('stores a private address, because a self-hosted instance is the point', async () => {
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: true, error: 'private network' });
const verdict = await svc.validateBaseUrl('https://paperless.fritz.box');
expect(verdict).toEqual({ success: true, data: { url: 'https://paperless.fritz.box', isPrivate: true } });
const created = await connect({ baseUrl: 'https://paperless.fritz.box' });
expect(created.base_url).toBe('https://paperless.fritz.box');
});
it('refuses an address the guard rejects for any other reason, and writes nothing', async () => {
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false, error: 'Could not resolve hostname (ENOTFOUND)' });
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ baseUrl: 'https://nope.invalid' }));
expect(result.success).toBe(false);
expect(result.success === false && result.error.code).toBe('ssrf_blocked');
expect(svc.listConnections(TRIP)).toHaveLength(0);
});
it('strips trailing slashes so the adapter can append its own path', async () => {
const created = await connect({ baseUrl: 'https://paperless.example.com///' });
expect(created.base_url).toBe('https://paperless.example.com');
});
it('refuses an empty address before it asks the guard anything', async () => {
const result = await svc.validateBaseUrl(' ');
expect(result.success).toBe(false);
expect(checkSsrf).not.toHaveBeenCalled();
});
});
describe('upsertConnection required fields', () => {
it('names the missing credential instead of storing a connection that cannot authenticate', async () => {
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ credentials: {} }));
expect(result.success).toBe(false);
expect(result.success === false && result.error.detail).toContain('api_token');
expect(svc.listConnections(TRIP)).toHaveLength(0);
});
it('refuses a missing required field that is not a secret either', async () => {
const result = await svc.upsertConnection(TRIP, OWNER, {
providerId: 'papra',
baseUrl: 'https://papra.example.com',
credentials: { api_key: 'ppapi_x' },
allowInsecureTls: false,
});
expect(result.success).toBe(false);
expect(result.success === false && result.error.detail).toContain('organization_id');
});
it('accepts a provider whose optional credential is left blank', async () => {
// Synology's OTP is a one-time code, not a stored credential; demanding it
// would make every save after the first one impossible.
const result = await svc.upsertConnection(TRIP, OWNER, {
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw', otp_code: '' },
allowInsecureTls: false,
});
expect(result.success).toBe(true);
});
});
describe('enabledProviderIds', () => {
it('names only the providers an admin switched on, in the order the shelf shows them', () => {
// Every document provider ships OFF, so an empty answer on a fresh install
// is the correct one and a non-empty one would be a policy change.
expect(svc.enabledProviderIds()).toEqual([]);
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run();
expect(svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']);
});
});
describe('recordProbe', () => {
it('keeps what the last good probe learned when a later one fails', async () => {
// A token that expired overnight must not make TREK forget that this
// instance can accept uploads; the capabilities describe the provider, not
// the state of the credential.
const conn = await connect();
svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' });
svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null);
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
expect(view).toMatchObject({
lastProbeState: 'failed',
lastProbeError: '401 Unauthorized',
capabilities: { push: 'multipart' },
});
});
});
describe('deleteConnection', () => {
it('drops the bindings that hung off it and leaves the other connection alone', async () => {
const doomed = await connect();
const kept = await connect({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'anna', app_password: 'app-pw' },
});
const doomedLink = link(doomed.id, { scopeKey: 'tag:1' });
const keptLink = link(kept.id, { scopeKey: 'folder:1' });
testDb
.prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)')
.run(doomedLink.id, TRIP, 'uid-a');
svc.deleteConnection(doomed.id);
expect(svc.getConnection(doomed.id)).toBeUndefined();
expect(svc.listLinks(TRIP).map((l) => l.id)).toEqual([keptLink.id]);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 });
});
});
describe('updateLink', () => {
it('changes only the fields the patch names', async () => {
const conn = await connect();
const created = link(conn.id, { direction: 'both', conflictPolicy: 'manual' });
const patched = svc.updateLink(created.id, { direction: 'pull', syncEnabled: false });
expect(patched).toMatchObject({
direction: 'pull',
sync_enabled: 0,
conflict_policy: 'manual',
remote_label: created.remote_label,
});
});
it('hands back the binding untouched when the patch is empty', async () => {
const conn = await connect();
const created = link(conn.id);
expect(svc.updateLink(created.id, {})).toEqual(created);
});
});
describe('toScopeRef', () => {
it('hands the adapter the anchor and the cursor of that binding, each in its own field', async () => {
// The adapters take a ref object rather than positional arguments because
// an id and a path swapped compile cleanly and then read the wrong folder.
const conn = await connect();
const created = svc.createLink(TRIP, OWNER, linkInput(conn.id, { scopeKey: 'fileid:437' }), {
scopeKey: 'fileid:437',
label: 'Japan 2026',
remoteRootId: '437',
remoteRootPath: '/TREK/japan',
});
expect(created.success).toBe(true);
const row = created.success ? created.data : ({} as LinkRow);
testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id);
expect(svc.toScopeRef(svc.getLink(row.id) as LinkRow)).toEqual({
linkId: row.id,
tripId: TRIP,
scopeKey: 'fileid:437',
remoteRootId: '437',
remoteRootPath: '/TREK/japan',
cursor: 'etag-9',
});
});
});
describe('createLink', () => {
it('returns the binding that already exists rather than making a second one', async () => {
const conn = await connect();
const first = link(conn.id);
const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }), null);
expect(again.success && again.data.id).toBe(first.id);
expect(svc.listLinks(TRIP)).toHaveLength(1);
});
it('binds a second scope of the same connection separately', async () => {
const conn = await connect();
const first = link(conn.id, { scopeKey: 'tag:1' });
const second = link(conn.id, { scopeKey: 'tag:2' });
expect(second.id).not.toBe(first.id);
expect(svc.listLinks(TRIP)).toHaveLength(2);
});
it('refuses a connection that belongs to somebody else\'s trip', async () => {
const otherTrip = createTrip(testDb, MEMBER, { title: 'Not yours' }).id;
const conn = await connect();
const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id), null);
expect(result.success).toBe(false);
expect(result.success === false && result.error.code).toBe('not_found');
expect(svc.listLinks(otherTrip)).toHaveLength(0);
});
it('refuses a connection id that does not exist at all', async () => {
const result = svc.createLink(TRIP, OWNER, linkInput(9999), null);
expect(result.success).toBe(false);
});
it('takes the anchor from the scope the user picked, not from what the client sent', async () => {
const conn = await connect();
const result = svc.createLink(
TRIP,
OWNER,
linkInput(conn.id, { remoteRootId: 'stale', remoteRootPath: '/stale', remoteLabel: 'stale' }),
{ scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '17', remoteRootPath: '/TREK/japan' },
);
expect(result.success && result.data).toMatchObject({
remote_root_id: '17',
remote_root_path: '/TREK/japan',
remote_label: 'Japan 2026',
});
});
it('gives every binding its own webhook token and keeps the secret out of the public view', async () => {
// A leaked webhook URL has to identify exactly one binding, so revoking one
// cannot be used to poke another.
const conn = await connect();
const first = link(conn.id, { scopeKey: 'tag:1' });
const second = link(conn.id, { scopeKey: 'tag:2' });
expect(first.webhook_token).toBeTruthy();
expect(second.webhook_token).not.toBe(first.webhook_token);
expect(svc.getLinkByToken(first.webhook_token as string)?.id).toBe(first.id);
const secret = svc.webhookSecret(first);
expect(secret).toBeTruthy();
const view = svc.publicLink(first, 'https://trek.example.com');
expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK);
expect(JSON.stringify(view)).not.toContain(secret);
});
});
describe('deleteLink', () => {
it('takes the pairing rows with it and leaves the other binding\'s alone', async () => {
const conn = await connect();
const doomed = link(conn.id, { scopeKey: 'tag:1' });
const kept = link(conn.id, { scopeKey: 'tag:2' });
const insert = testDb.prepare(
'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)',
);
insert.run(doomed.id, TRIP, 'uid-a');
insert.run(doomed.id, TRIP, 'uid-b');
insert.run(kept.id, TRIP, 'uid-c');
svc.deleteLink(doomed.id);
expect(svc.getLink(doomed.id)).toBeUndefined();
expect(svc.getLink(kept.id)).toBeDefined();
const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[];
expect(rows.map((r) => r.link_id)).toEqual([kept.id]);
});
it('leaves the connection behind, so unbinding one scope does not log the trip out', async () => {
const conn = await connect();
svc.deleteLink(link(conn.id).id);
expect(svc.getConnection(conn.id)).toBeDefined();
});
});
describe('markOrphanedLinks', () => {
async function bind(ownerUserId: number, providerId: 'paperless' | 'nextcloud'): Promise<LinkRow> {
const credentials =
providerId === 'paperless' ? { api_token: TOKEN } : { login_name: 'anna', app_password: 'app-pw' };
const conn = await connect({ providerId, credentials, baseUrl: `https://${providerId}.example.com` }, ownerUserId);
return link(conn.id, { scopeKey: `scope:${providerId}` });
}
it('disables the binding whose credential owner left the trip', async () => {
const orphan = await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 });
});
it('counts the trip owner as being on the trip, although no membership row says so', async () => {
// TREK's owner has no trip_members row; reading membership without the
// owner would orphan every binding the trip admin created, which is most
// of them.
const mine = await bind(OWNER, 'paperless');
expect(svc.markOrphanedLinks()).toBe(0);
expect(svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
it('leaves a still-valid binding alone while orphaning the one beside it', async () => {
const orphan = await bind(MEMBER, 'paperless');
const kept = await bind(OWNER, 'nextcloud');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.getLink(orphan.id)?.last_sync_state).toBe('orphaned');
expect(svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => {
await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.markOrphanedLinks()).toBe(0);
});
it('does not touch a binding the user had already switched off', async () => {
const paused = await bind(MEMBER, 'paperless');
svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(0);
expect(svc.getLink(paused.id)?.last_sync_state).toBe('never');
});
});
@@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import crypto from 'crypto';
import type { Request } from 'express';
// Nothing here touches SQLite; the stub keeps the import chain from opening a
// database and running 238 migrations for a handler that never queries one.
vi.mock('../../../../src/db/database', () => ({
db: {},
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
}));
import { DocSyncWebhookController } from '../../../../src/nest/doc-sync/doc-sync-webhook.controller';
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
/**
* The webhook endpoint, with both services stubbed.
*
* It is the only route in TREK a stranger on the internet can reach with a
* guessed URL, so what matters is what it does NOT do: it never says whether a
* token exists, never says whether a secret matched, and never acts on the
* payload. Every case below therefore asserts two things — the answer, which is
* always the same, and whether a run was scheduled, which is the only place the
* decision is visible at all.
*
* No timers and no clock: the signature is computed here from a fixed id and
* timestamp, exactly as the provider would.
*/
const SECRET = 'M7dQ2vLp5rTn8kYw1xZc4bJh';
const link = (over: Partial<LinkRow> = {}): LinkRow => ({
id: 4,
trip_id: 1,
connection_id: 2,
provider_id: 'papra',
remote_scope_key: 'tag:1',
remote_root_id: '1',
remote_root_path: '/TREK/japan',
remote_label: 'Japan 2026',
direction: 'both',
delete_policy: 'unlink',
conflict_policy: 'manual',
sync_enabled: 1,
webhook_token: 'tok-live',
webhook_secret: 'enc:v1:whatever',
webhook_subscription_id: null,
remote_cursor: null,
last_sync_at: null,
last_sync_state: 'never',
last_sync_error: null,
failure_count: 0,
next_attempt_at: null,
...over,
});
const config = {
getLinkByToken: vi.fn((token: string) => (token === 'tok-live' ? link() : undefined)),
webhookSecret: vi.fn(() => SECRET),
};
const sync = {
syncLink: vi.fn(async (_link: LinkRow) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
};
const controller = new DocSyncWebhookController(
config as unknown as DocSyncConfigService,
sync as unknown as DocSyncService,
);
/** Only what the handler reads: headers, the parsed body, and the raw bytes. */
function makeReq(headers: Record<string, string> = {}, opts: { body?: unknown; rawBody?: Buffer | undefined } = {}): Request {
return {
headers,
body: opts.body,
rawBody: opts.rawBody,
get: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
}
const WEBHOOK_ID = 'msg_2f8a';
const WEBHOOK_TS = '1758200000';
function sign(payload: string, secret = SECRET, id = WEBHOOK_ID, ts = WEBHOOK_TS): string {
return crypto.createHmac('sha256', Buffer.from(secret)).update(`${id}.${ts}.${payload}`).digest('base64');
}
function papraReq(payload: string, signature: string, sent = payload): Request {
return makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${signature}` },
{ rawBody: Buffer.from(sent, 'utf8') },
);
}
beforeEach(() => {
vi.clearAllMocks();
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
config.webhookSecret.mockReturnValue(SECRET);
});
describe('an unknown token', () => {
it('answers as if it were known, so nobody can enumerate which tokens exist', () => {
expect(controller.nudge('tok-guessed', makeReq())).toEqual({ received: true });
});
it('schedules no run', () => {
controller.nudge('tok-guessed', makeReq());
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a binding whose sync is switched off', () => {
it('is left alone, and says nothing about it', () => {
config.getLinkByToken.mockReturnValue(link({ sync_enabled: 0 }));
expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a shared-secret header', () => {
it('triggers the run when it matches', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 });
});
it('triggers nothing when it is wrong, and the answer looks identical', () => {
const wrong = `${SECRET.slice(0, -1)}X`;
expect(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': wrong }))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the header is missing altogether', () => {
controller.nudge('tok-live', makeReq());
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('is not demanded from a binding that carries no secret — the token alone authenticates there', () => {
config.webhookSecret.mockReturnValue('');
controller.nudge('tok-live', makeReq());
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});
describe('a Papra standard-webhooks signature', () => {
const payload = JSON.stringify({ event: 'document.created', documentId: 'doc_1' });
it('triggers the run when it covers the bytes that arrived', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload)));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('triggers nothing when the payload was altered after it was signed', () => {
const tampered = JSON.stringify({ event: 'document.created', documentId: 'doc_999' });
expect(controller.nudge('tok-live', papraReq(payload, sign(payload), tampered))).toEqual({ received: true });
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the signature was made with another secret', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload, 'someone-elses-secret-value')));
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('triggers nothing when the id or timestamp does not match what was signed', () => {
const req = makeReq(
{ 'webhook-id': 'msg_other', 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(payload)}` },
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('accepts a header carrying several signatures, as a key rotation sends', () => {
const req = makeReq(
{
'webhook-id': WEBHOOK_ID,
'webhook-timestamp': WEBHOOK_TS,
'webhook-signature': `v1,${sign(payload, 'the-previous-secret')} v1,${sign(payload)}`,
},
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('verifies against the parsed body when a mount left no raw bytes behind', () => {
const body = { event: 'document.created', documentId: 'doc_1' };
const req = makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-timestamp': WEBHOOK_TS, 'webhook-signature': `v1,${sign(JSON.stringify(body))}` },
{ body },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('is ignored when the timestamp header is missing, rather than verified without it', () => {
const req = makeReq(
{ 'webhook-id': WEBHOOK_ID, 'webhook-signature': `v1,${sign(payload)}` },
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('a credential of the wrong length', () => {
// crypto.timingSafeEqual throws on buffers of unequal length, so a one-byte
// header would turn into a 500 that tells a prober the secret is longer than
// what they sent.
it('is rejected rather than thrown over, whether it arrives as a header or as a signature', () => {
expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': 'a' }))).not.toThrow();
expect(() =>
controller.nudge('tok-live', papraReq('{}', 'short')),
).not.toThrow();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('is rejected the same way when it is longer than the stored secret', () => {
expect(() => controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': `${SECRET}extra` }))).not.toThrow();
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,667 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
/**
* DocSyncController built with `new`, against a real in-memory SQLite.
*
* The guards are e2e's job (tests/e2e/doc-sync.e2e.test.ts drives the real
* AddonGuard, JwtAuthGuard and TripAccessGuard). What is left here is what the
* handler itself decides once a request is through the door, and all of it is
* the kind that would be expensive to notice late: who counts as the owner, a
* form field that must not lose a secret it never saw, and a webhook whose
* callback URL is derived from the request rather than from configuration.
*
* The config service and the provider registry are the real ones — the merge
* this file pins runs through actual encryption and actual provider-field rows,
* and a stub of either would only be a restatement of the controller. The
* reconciler is a spy: a run is a side effect here, not the subject.
*/
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => undefined,
isOwner: () => false,
},
};
});
vi.mock('../../../../src/db/database', () => dbMock);
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
/**
* The SSRF guard resolves DNS, and none of these hostnames exist. It is not
* what this file tests, and a suite that needs a resolver is a suite that fails
* on a train.
*/
const { checkSsrf } = vi.hoisted(() => ({
checkSsrf: vi.fn(async (url: string) => ({
allowed: !url.includes('blocked.invalid'),
isPrivate: false,
error: url.includes('blocked.invalid') ? 'blocked in test' : undefined,
})),
}));
vi.mock('../../../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/utils/ssrfGuard')>()),
checkSsrf,
}));
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import { DocSyncController } from '../../../../src/nest/doc-sync/doc-sync.controller';
import { DocSyncConfigService } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
import type {
DocumentConnectionRef,
DocumentProvider,
DocumentScopeRef,
} from '../../../../src/nest/doc-sync/document-provider';
import type { LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import type {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
DocsyncLinkDto,
} from '../../../../src/nest/doc-sync/doc-sync.dto';
import type { User } from '../../../../src/types';
const CAPS = {
push: 'webhook-self-registered' as const,
stableId: true,
remoteTrash: true,
replaceInPlace: true,
contentHashInListing: true,
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
function fakeProvider(id: string) {
return {
id,
capabilities: () => CAPS,
probe: vi.fn(async (_conn: DocumentConnectionRef) => ({ success: true as const, data: { account: `${id}-account`, capabilities: CAPS } })),
listScopes: vi.fn(async (_conn: DocumentConnectionRef, _query?: string) => ({ success: true as const, data: [] })),
createScope: vi.fn(async () => ({ success: false as const, error: { code: 'provider_error' as const } })),
resolveScope: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
list: vi.fn(async () => ({ success: true as const, data: { documents: [], cursor: null, cursorUnchanged: false, truncated: false } })),
fetch: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
push: vi.fn(async () => ({ success: false as const, error: { code: 'not_found' as const } })),
rename: vi.fn(async () => ({ success: true as const, data: { remoteVersion: 'v2' } })),
trash: vi.fn(async () => ({ success: true as const, data: undefined })),
registerWebhook: vi.fn(async (_conn: DocumentConnectionRef, _scope: DocumentScopeRef, _callbackUrl: string, _secret: string) => ({
success: true as const,
data: { subscriptionId: 'sub-7' },
})),
unregisterWebhook: vi.fn(async (_conn: DocumentConnectionRef, _subscriptionId: string) => ({ success: true as const, data: undefined })),
};
}
const paperless = fakeProvider('paperless');
const nextcloud = fakeProvider('nextcloud');
const sync = {
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
status: vi.fn(() => ({ links: [], items: {} })),
resolveConflict: vi.fn(async () => true),
};
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
const dbs = new DatabaseService(testDb);
const config = new DocSyncConfigService(dbs, registry);
const controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs);
/** Only what publicOrigin reads: header lookup plus the connection's own scheme. */
function makeReq(headers: Record<string, string> = {}, protocol = 'http'): Request {
return {
protocol,
headers,
get: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
}
function connBody(over: Partial<DocsyncConnectionDto> = {}): DocsyncConnectionDto {
return {
providerId: 'paperless',
baseUrl: 'https://paperless.example.com',
credentials: {},
allowInsecureTls: false,
...over,
} as DocsyncConnectionDto;
}
function linkBody(connectionId: number, over: Partial<DocsyncLinkDto> = {}): DocsyncLinkDto {
return {
connectionId,
scopeKey: 'tag:1',
remoteRootId: '1',
remoteRootPath: '/TREK/japan',
remoteLabel: 'Japan 2026',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
...over,
} as DocsyncLinkDto;
}
async function thrown(fn: () => unknown): Promise<HttpException> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
return err as HttpException;
}
throw new Error('expected the handler to throw');
}
let owner: User;
let member: User;
let admin: User;
let tripId: number;
let otherTripId: number;
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user;
const m = createUser(testDb, { username: 'member', email: 'member@test.local' }).user;
const a = createUser(testDb, { username: 'admin', email: 'admin@test.local', role: 'admin' }).user;
owner = { id: o.id, role: 'user' } as User;
member = { id: m.id, role: 'user' } as User;
admin = { id: a.id, role: 'admin' } as User;
tripId = createTrip(testDb, o.id, { title: 'Japan' }).id;
otherTripId = createTrip(testDb, m.id, { title: 'Norway' }).id;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, m.id);
});
afterAll(() => {
testDb.close();
});
beforeEach(() => {
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections; DELETE FROM trip_files');
// Providers ship switched off; these three are what the cases below need.
testDb.prepare("UPDATE document_providers SET enabled = 0").run();
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('paperless', 'nextcloud', 'papra')").run();
vi.clearAllMocks();
});
async function storedPaperless(credentials: Record<string, string> = { api_token: 'stored-token' }) {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({ credentials }));
if ('error' in res) throw new Error(`fixture failed: ${res.error.code} ${res.error.detail ?? ''}`);
return res.data;
}
describe('who may change a trip binding', () => {
it('lets the trip owner through', async () => {
await expect(controller.resolve(String(tripId), '1', owner, { keep: 'trek' })).resolves.toEqual({ success: true });
});
it('lets an instance admin through on a trip they neither own nor joined', async () => {
await expect(controller.resolve(String(otherTripId), '1', admin, { keep: 'trek' })).resolves.toEqual({ success: true });
});
it('refuses a plain member with the message the client renders', async () => {
const err = await thrown(() => controller.resolve(String(tripId), '1', member, { keep: 'trek' }));
expect(err.getStatus()).toBe(403);
expect(err.message).toBe('Only the trip owner can change document sync');
});
it('answers 404 for a trip that does not exist, rather than the 403 that would confirm it', async () => {
const err = await thrown(() => controller.resolve('999999', '1', member, { keep: 'trek' }));
expect(err.getStatus()).toBe(404);
expect(err.message).toBe('Trip not found');
});
});
describe('providers', () => {
it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', () => {
const rows = controller.providers() as Array<{ id: string; available: boolean }>;
const byId = Object.fromEntries(rows.map((r) => [r.id, r.available]));
// papra is switched on in the DB, but no adapter is registered in this suite.
expect(byId.papra).toBe(false);
expect(byId.paperless).toBe(true);
expect(byId.nextcloud).toBe(true);
});
it('hands the form flags to the client as booleans rather than the 0/1 the column stores', () => {
const rows = controller.providers() as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>;
const fields = rows.find((r) => r.id === 'paperless')!.fields;
const token = fields.find((f) => f.field_key === 'api_token')!;
expect(token.secret).toBe(true);
expect(token.required).toBe(true);
expect(fields.find((f) => f.field_key === 'allow_insecure_tls')!.secret).toBe(false);
});
});
describe('storing a connection', () => {
it('hands the stored connection back with the secret masked instead of echoed', async () => {
const res = (await controller.upsertConnection(
String(tripId),
owner,
connBody({ credentials: { api_token: 'super-secret' } }),
)) as { id: number; secrets: Record<string, string> };
expect(res.secrets.api_token).not.toContain('super-secret');
const listed = controller.listConnections(String(tripId)) as Array<{ id: number; secrets: Record<string, string> }>;
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(res.id);
// The list is what the form renders from, so the secret must not survive
// the round trip either.
expect(listed[0].secrets.api_token).not.toContain('super-secret');
});
it('refuses a base URL the SSRF guard turns down', async () => {
const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' })));
expect(err.getStatus()).toBe(400);
expect(config.listConnections(tripId)).toHaveLength(0);
});
it('refuses a provider the instance admin has not switched on, and names it', async () => {
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'papra'").run();
const err = await thrown(() =>
controller.upsertConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k', organization_id: 'org_1' } })),
);
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Provider: "papra" is not enabled, contact server administrator');
});
});
describe('probing form values', () => {
it('keeps the stored secret when the form leaves the field blank', async () => {
await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'stored-token' } });
});
it('takes a freshly typed secret over the stored one', async () => {
await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'typed-now' } }) as DocsyncConnectionTestDto);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ secrets: { api_token: 'typed-now' } });
});
it('keeps a stored non-secret setting the form did not resend', async () => {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
}));
expect('error' in res).toBe(false);
await controller.testConnection(String(tripId), owner, connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { app_password: '' },
}) as DocsyncConnectionTestDto);
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({
settings: { login_name: 'alice', base_path: '/TREK' },
secrets: { app_password: 'app-pw' },
});
});
it('takes a freshly typed setting over the stored one', async () => {
const res = await config.upsertConnection(tripId, Number(owner.id), connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'alice', app_password: 'app-pw' },
}));
expect('error' in res).toBe(false);
await controller.testConnection(String(tripId), owner, connBody({
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'bob' },
}) as DocsyncConnectionTestDto);
expect(nextcloud.probe.mock.calls[0][0]).toMatchObject({ settings: { login_name: 'bob' } });
});
it('answers with a body instead of throwing when no adapter is registered', async () => {
await expect(
controller.testConnection(String(tripId), owner, connBody({ providerId: 'papra', credentials: { api_key: 'k' } }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'unknown_provider' });
});
it('answers with a body instead of throwing when the URL is refused', async () => {
await expect(
controller.testConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'ssrf_blocked' });
expect(paperless.probe).not.toHaveBeenCalled();
});
it('answers with a body instead of throwing when the provider rejects the credentials', async () => {
paperless.probe.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized', detail: 'token expired' } } as never);
await expect(
controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'nope' } }) as DocsyncConnectionTestDto),
).resolves.toEqual({ connected: false, error: 'unauthorized', detail: 'token expired' });
});
it('writes a successful probe onto the stored connection, so the status panel has something to show', async () => {
const conn = await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const row = config.getConnection(conn.id)!;
expect(row.last_probe_state).toBe('ok');
expect(JSON.parse(row.capabilities!)).toMatchObject({ push: 'webhook-self-registered' });
});
it('leaves no probe record behind when the form values belong to no stored connection yet', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
expect(config.listConnections(tripId)).toHaveLength(0);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ connectionId: 0, secrets: { api_token: 'first-try' } });
});
});
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', () => {
controller.status(String(tripId));
expect(sync.status).toHaveBeenCalledWith(tripId);
});
});
describe('removing a connection', () => {
it('unbinds without touching the documents that came through it', async () => {
const conn = await storedPaperless();
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).toEqual({ success: true });
expect(config.getConnection(conn.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
it('refuses a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getConnection(conn.id)).toBeTruthy();
});
});
describe('the scope picker', () => {
it('passes the search text to the adapter and hands back what it offers', async () => {
const conn = await storedPaperless();
paperless.listScopes.mockResolvedValueOnce({
success: true,
data: [{ scopeKey: 'tag:1', label: 'Japan 2026', remoteRootId: '1', remoteRootPath: '/TREK/japan' }],
} as never);
const res = await controller.listScopes(String(tripId), String(conn.id), owner, 'japan');
expect(paperless.listScopes.mock.calls[0][1]).toBe('japan');
expect(res).toMatchObject({ scopes: [{ scopeKey: 'tag:1' }] });
});
it('reports a provider failure in the body, so the picker can say why it is empty', async () => {
const conn = await storedPaperless();
paperless.listScopes.mockResolvedValueOnce({ success: false, error: { code: 'unauthorized' } } as never);
await expect(controller.listScopes(String(tripId), String(conn.id), owner)).resolves.toEqual({ scopes: [], error: 'unauthorized' });
});
it('refuses a connection whose provider has no adapter on this instance', async () => {
const id = Number(
testDb.prepare('INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url) VALUES (?, ?, ?, ?)')
.run(tripId, 'papra', Number(owner.id), 'https://papra.example.com').lastInsertRowid,
);
const err = await thrown(() => controller.listScopes(String(tripId), String(id), owner));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Provider not available');
});
it('refuses a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() => controller.listScopes(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(paperless.listScopes).not.toHaveBeenCalled();
});
it('hands back the container the adapter created', async () => {
const conn = await storedPaperless();
paperless.createScope.mockResolvedValueOnce({
success: true,
data: { scopeKey: 'tag:9', label: 'Norway', remoteRootId: '9', remoteRootPath: '/TREK/norway' },
} as never);
await expect(controller.createScope(String(tripId), String(conn.id), owner, { connectionId: conn.id, name: 'Norway' }))
.resolves.toMatchObject({ scopeKey: 'tag:9', label: 'Norway' });
});
it('turns a refused creation into a 400 carrying the provider code', async () => {
const conn = await storedPaperless();
paperless.createScope.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
const err = await thrown(() => controller.createScope(String(tripId), String(conn.id), owner, { connectionId: conn.id, name: 'Norway' }));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('forbidden');
});
});
describe('creating a binding', () => {
it('subscribes at the provider and remembers the subscription id', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'trek.example' }),
)) as { id: number };
const row = config.getLink(link.id)!;
expect(row.webhook_subscription_id).toBe('sub-7');
expect(paperless.registerWebhook.mock.calls[0][2]).toBe(`https://trek.example/api/docsync/webhook/${row.webhook_token}`);
// The secret handed to the provider is the plaintext one, not the stored blob.
expect(paperless.registerWebhook.mock.calls[0][3]).toBe(config.webhookSecret(row));
});
it('keeps the binding when the provider refuses the subscription, because polling still carries it', async () => {
const conn = await storedPaperless();
paperless.registerWebhook.mockResolvedValueOnce({ success: false, error: { code: 'forbidden' } } as never);
const link = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ 'x-forwarded-host': 'trek.example' }),
)) as { id: number; syncEnabled: boolean };
const row = config.getLink(link.id)!;
expect(row.webhook_subscription_id).toBeNull();
expect(row.sync_enabled).toBe(1);
});
it('does not offer a webhook when no host reaches TREK', async () => {
const conn = await storedPaperless();
const link = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { webhookUrl: string | null };
expect(paperless.registerWebhook).not.toHaveBeenCalled();
expect(link.webhookUrl).toBeNull();
});
it('starts a first run at once instead of letting the user wait out a poll interval', async () => {
const conn = await storedPaperless();
await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq({ host: 'trek.example' }));
expect(sync.syncLink).toHaveBeenCalledTimes(1);
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
});
it('refuses a binding for a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() =>
controller.createLink(String(otherTripId), admin, linkBody(conn.id), makeReq({ host: 'trek.example' })),
);
expect(err.getStatus()).toBe(400);
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('changing a binding', () => {
it('writes only the fields the request carried', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const res = controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq()) as {
syncEnabled: boolean;
direction: string;
remoteLabel: string;
};
expect(res.syncEnabled).toBe(false);
expect(res.direction).toBe('both');
expect(res.remoteLabel).toBe('Japan 2026');
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const err = await thrown(() => controller.updateLink(String(otherTripId), String(created.id), admin, { syncEnabled: false }, makeReq()));
expect(err.getStatus()).toBe(404);
expect(config.getLink(created.id)!.sync_enabled).toBe(1);
});
});
describe('a manual run', () => {
it('passes the full flag through to the reconciler', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.syncLink.mockClear();
await controller.syncNow(String(tripId), String(created.id), { full: true });
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: created.id });
expect(sync.syncLink.mock.calls[0][1]).toEqual({ full: true });
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.syncLink.mockClear();
const err = await thrown(() => controller.syncNow(String(otherTripId), String(created.id), { full: false }));
expect(err.getStatus()).toBe(404);
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
describe('the document list', () => {
async function seedItems() {
const conn = await storedPaperless();
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
const linkId = Number(
testDb.prepare(
`INSERT INTO trip_document_links (trip_id, connection_id, provider_id, remote_scope_key)
VALUES (?, ?, ?, ?)`,
).run(tripId, conn.id, 'paperless', 'tag:1').lastInsertRowid,
);
const insert = testDb.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)');
insert.run(linkId, tripId, fileId, 'uid-1', 'synced');
insert.run(linkId, tripId, null, 'uid-2', 'conflict');
// Another trip's row, which must never appear in this trip's list.
insert.run(linkId, otherTripId, null, 'uid-3', 'conflict');
}
it('carries the local file name, so the list reads as documents rather than as ids', async () => {
await seedItems();
const rows = controller.items(String(tripId)) as Array<{ trek_doc_uid: string; file_name: string | null }>;
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.trek_doc_uid === 'uid-1')!.file_name).toBe('boarding.pdf');
});
it('narrows to one state when asked, and never leaves the trip', async () => {
await seedItems();
const rows = controller.items(String(tripId), 'conflict') as Array<{ trek_doc_uid: string }>;
expect(rows.map((r) => r.trek_doc_uid)).toEqual(['uid-2']);
});
});
describe('resolving a conflict', () => {
it('refuses an item that is not in conflict rather than pretending it did something', async () => {
sync.resolveConflict.mockResolvedValueOnce(false);
const err = await thrown(() => controller.resolve(String(tripId), '5', owner, { keep: 'provider' }));
expect(err.getStatus()).toBe(400);
expect(err.message).toBe('Item is not in conflict');
});
});
describe('removing a binding', () => {
it('unsubscribes at the provider and keeps both copies of the documents', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(
String(tripId),
owner,
linkBody(conn.id),
makeReq({ host: 'trek.example' }),
)) as { id: number };
const fileId = Number(
testDb.prepare('INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, ?, ?)')
.run(tripId, 'stored.pdf', 'boarding.pdf').lastInsertRowid,
);
testDb
.prepare('INSERT INTO document_sync_items (link_id, trip_id, file_id, trek_doc_uid, state) VALUES (?, ?, ?, ?, ?)')
.run(created.id, tripId, fileId, 'uid-1', 'synced');
const res = await controller.deleteLink(String(tripId), String(created.id), owner);
expect(res).toEqual({ success: true, documentsKept: true });
expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7');
expect(config.getLink(created.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
it('refuses a link that belongs to a different trip', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getLink(created.id)).toBeTruthy();
});
});
describe('the callback origin a provider is given', () => {
async function linkedTrip() {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
return config.getLink(created.id)!;
}
it('builds the callback URL from the forwarded proto and host', async () => {
const row = await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toBe(`https://docs.example/api/docsync/webhook/${row.webhook_token}`);
});
it('takes the first hop when a proxy chain appends its own protocol', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^https:\/\/docs\.example\//);
});
it('falls back to the Host header and the connection scheme when nothing is forwarded', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http')) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^http:\/\/trek\.lan:3001\//);
});
it('offers no callback URL when a proxy strips the host', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq()) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBeNull();
});
});
@@ -0,0 +1,313 @@
import { describe, expect, it } from 'vitest';
import {
backoffSeconds,
isAllowedByOperator,
isBlockedName,
planReconcile,
sanitizeIncomingName,
snapshotHash,
type LocalDocument,
type SyncItemState,
} from '../../../../src/nest/doc-sync/doc-sync.helpers';
import type { RemoteDocument } from '../../../../src/nest/doc-sync/document-provider';
/**
* The planner decides everything that matters about two-way sync, and it does
* it without a database, a container or a network. That is the point of having
* it: the cases that are painful to reproduce against a live provider — an
* unmounted share, a rename with no stable id, TREK's own write echoing back —
* are cheap to state here as data.
*/
const remote = (over: Partial<RemoteDocument> = {}): RemoteDocument => ({
remoteId: 'r1',
name: 'boarding.pdf',
size: 1024,
mimeType: 'application/pdf',
remoteVersion: 'v1',
contentHash: null,
remoteModifiedAt: '2026-09-18T10:00:00Z',
isDeleted: false,
...over,
});
const local = (over: Partial<LocalDocument> = {}): LocalDocument => ({
fileId: 1,
name: 'boarding.pdf',
size: 1024,
mimeType: 'application/pdf',
sha256: 'aaa',
deletedAt: null,
...over,
});
const item = (over: Partial<SyncItemState> = {}): SyncItemState => ({
id: 10,
fileId: 1,
trekDocUid: 'uid-1',
remoteId: 'r1',
remoteVersion: 'v1',
remoteName: 'boarding.pdf',
contentSha256: 'aaa',
pushedSha256: null,
state: 'synced',
attempts: 0,
remoteMissingAt: null,
...over,
});
const plan = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
planReconcile({
items: [],
remote: [],
local: [],
direction: 'both',
remoteTruncated: false,
stableRemoteIds: true,
...over,
});
describe('planReconcile', () => {
it('pulls a document that only exists at the provider', () => {
const p = plan({ remote: [remote()] });
expect(p.actions).toEqual([{ kind: 'pull', remote: remote(), itemId: null }]);
});
it('pushes a document that only exists in TREK', () => {
const p = plan({ local: [local()] });
expect(p.actions).toEqual([{ kind: 'push', local: local(), itemId: null, remoteId: null }]);
});
it('does nothing when both sides match', () => {
const p = plan({ items: [item()], remote: [remote()], local: [local()] });
expect(p.actions).toEqual([{ kind: 'touch', itemId: 10, remote: remote() }]);
});
it('pulls an update when only the provider moved on', () => {
const p = plan({ items: [item()], remote: [remote({ remoteVersion: 'v2' })], local: [local()] });
expect(p.actions[0].kind).toBe('pull_update');
});
it('pushes an update when only TREK moved on', () => {
const p = plan({ items: [item()], remote: [remote()], local: [local({ sha256: 'bbb' })] });
expect(p.actions[0]).toMatchObject({ kind: 'push_update', remoteId: 'r1' });
});
it('reports a conflict when both sides moved on', () => {
const p = plan({
items: [item()],
remote: [remote({ remoteVersion: 'v2' })],
local: [local({ sha256: 'bbb' })],
});
expect(p.actions[0].kind).toBe('conflict');
});
/**
* The echo guard. A provider reports TREK's own upload as a change, and
* without this the file would be pulled back down, re-uploaded, and bounce
* forever. It compares content rather than timestamps on purpose: a time
* window misfires on any clock skew between TREK and a NAS.
*/
it('treats a change whose content matches TREK\'s own push as no change', () => {
const p = plan({
items: [item({ pushedSha256: 'aaa' })],
remote: [remote({ remoteVersion: 'v2', contentHash: 'aaa' })],
local: [local()],
});
expect(p.actions[0].kind).toBe('touch');
});
it('still sees a real foreign change when the content differs from what was pushed', () => {
const p = plan({
items: [item({ pushedSha256: 'aaa' })],
remote: [remote({ remoteVersion: 'v2', contentHash: 'ccc' })],
local: [local()],
});
expect(p.actions[0].kind).toBe('pull_update');
});
/**
* An unmounted share and a revoked token both answer with a short listing.
* Reading that as a mass deletion would empty a trip, so the run refuses
* everything rather than acting on a listing it cannot trust.
*/
it('refuses the run when most known documents vanish at once', () => {
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
const p = plan({ items, remote: [remote({ remoteId: 'r0' })], local: [] });
expect(p.massDeleteGuardTripped).toBe(true);
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(0);
});
it('records a single disappearance without acting on it', () => {
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
const stillThere = items.slice(1).map((i) => remote({ remoteId: i.remoteId as string }));
const p = plan({ items, remote: stillThere, local: [] });
expect(p.massDeleteGuardTripped).toBe(false);
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(1);
});
it('never marks anything missing from a truncated listing', () => {
const items = Array.from({ length: 10 }, (_, i) => item({ id: i, remoteId: `r${i}`, fileId: i }));
const p = plan({ items, remote: [], local: [], remoteTruncated: true });
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(0);
});
/**
* Synology FileStation has no stable file id, so a rename upstream looks like
* a deletion plus a new file. Re-pairing on content avoids downloading the
* same bytes again and leaving a phantom behind.
*/
it('re-pairs a renamed document when the provider has no stable ids', () => {
const p = plan({
items: [item({ remoteId: '/trek/old.pdf', contentSha256: 'hash-x' })],
remote: [remote({ remoteId: '/trek/new.pdf', contentHash: 'hash-x' })],
local: [local()],
stableRemoteIds: false,
});
expect(p.actions.some((a) => a.kind === 'pull')).toBe(false);
expect(p.actions.some((a) => a.kind === 'touch')).toBe(true);
});
/**
* Which side renamed is decided against the name both sides last agreed on.
* Reading a `both` binding as "TREK always wins" renamed the provider's copy
* back every time somebody tidied a folder, which is the opposite of what a
* two-way sync is for.
*/
it('renames at the provider when TREK is the side that renamed', () => {
const p = plan({
items: [item({ remoteName: 'old.pdf' })],
remote: [remote({ name: 'old.pdf' })],
local: [local({ name: 'new.pdf' })],
});
expect(p.actions[0]).toMatchObject({ kind: 'rename_remote', name: 'new.pdf' });
});
it('renames in TREK when the provider is the side that renamed', () => {
const p = plan({
items: [item({ remoteName: 'old.pdf' })],
remote: [remote({ name: 'new-upstream.pdf' })],
local: [local({ name: 'old.pdf' })],
});
expect(p.actions[0]).toMatchObject({ kind: 'rename_local', name: 'new-upstream.pdf' });
});
it('asks a person when both sides renamed', () => {
const p = plan({
items: [item({ remoteName: 'old.pdf' })],
remote: [remote({ name: 'theirs.pdf' })],
local: [local({ name: 'mine.pdf' })],
});
expect(p.actions[0].kind).toBe('conflict');
});
it('does nothing about a name difference it cannot arbitrate', () => {
// No stored name: a row from before the column existed, or a pairing made
// in the same run. Guessing here would rename somebody's file.
const p = plan({
items: [item({ remoteName: null })],
remote: [remote({ name: 'a.pdf' })],
local: [local({ name: 'b.pdf' })],
});
expect(p.actions[0].kind).toBe('touch');
});
it('honours a pull-only binding by never pushing', () => {
const p = plan({ local: [local()], remote: [], direction: 'pull' });
expect(p.actions).toHaveLength(0);
});
it('honours a push-only binding by never pulling', () => {
const p = plan({ remote: [remote()], direction: 'push' });
expect(p.actions).toHaveLength(0);
});
it('does not re-upload a document whose remote copy is merely unlisted', () => {
// The row has a remoteId but the listing does not contain it. Re-pushing
// here would duplicate the whole trip upstream every time a listing fails.
const p = plan({ items: [item()], remote: [], local: [local()] });
expect(p.actions.some((a) => a.kind === 'push')).toBe(false);
});
it('reports a local deletion instead of acting on it', () => {
const p = plan({ items: [item()], remote: [remote()], local: [local({ deletedAt: '2026-09-18' })] });
expect(p.actions[0]).toMatchObject({ kind: 'local_deleted', remoteId: 'r1' });
});
it('ignores a provider-side tombstone rather than pulling it', () => {
const p = plan({ remote: [remote({ isDeleted: true })] });
expect(p.actions).toHaveLength(0);
});
});
describe('sanitizeIncomingName', () => {
it('strips directory traversal from both path flavours', () => {
expect(sanitizeIncomingName('../../etc/passwd')).toBe('passwd');
expect(sanitizeIncomingName('C:\\Windows\\system32\\evil.pdf')).toBe('evil.pdf');
});
it('removes control characters and leading dots', () => {
expect(sanitizeIncomingName('.hidden\u0000.pdf')).toBe('hidden.pdf');
});
it('replaces characters that are illegal in a filename', () => {
expect(sanitizeIncomingName('a:b*c?.pdf')).toBe('a_b_c_.pdf');
});
it('never returns an empty name', () => {
expect(sanitizeIncomingName('...')).toBe('document');
expect(sanitizeIncomingName('')).toBe('document');
});
});
describe('isBlockedName', () => {
it('blocks the extensions TREK serves inline', () => {
// A download is served with a Content-Type derived from the extension, so
// these would be stored XSS if a provider folder could introduce them.
expect(isBlockedName('map.svg')).toBe(true);
expect(isBlockedName('page.html')).toBe(true);
expect(isBlockedName('run.exe')).toBe(true);
});
it('allows ordinary documents', () => {
expect(isBlockedName('boarding.pdf')).toBe(false);
});
});
describe('isAllowedByOperator', () => {
it('honours the wildcard', () => {
expect(isAllowedByOperator('x.pdf', '*')).toBe(true);
});
it('matches case-insensitively and tolerates dots and spaces in the list', () => {
expect(isAllowedByOperator('x.PDF', ' .pdf , jpg ')).toBe(true);
});
it('refuses a file with no extension even under the wildcard', () => {
expect(isAllowedByOperator('README', '*')).toBe(false);
});
});
describe('snapshotHash', () => {
it('is stable for the same field order and differs when a field changes', () => {
expect(snapshotHash(['a', 1, null])).toBe(snapshotHash(['a', 1, null]));
expect(snapshotHash(['a', 1, null])).not.toBe(snapshotHash(['a', 2, null]));
});
it('does not collide when field boundaries shift', () => {
// Joining without a separator would make ['ab','c'] and ['a','bc'] equal.
expect(snapshotHash(['ab', 'c'])).not.toBe(snapshotHash(['a', 'bc']));
});
});
describe('backoffSeconds', () => {
it('saturates at the end of the curve instead of running off it', () => {
expect(backoffSeconds([60, 300], 1)).toBe(60);
expect(backoffSeconds([60, 300], 2)).toBe(300);
expect(backoffSeconds([60, 300], 99)).toBe(300);
});
it('treats a zero failure count as the first step', () => {
expect(backoffSeconds([60, 300], 0)).toBe(60);
});
});
@@ -0,0 +1,284 @@
/**
* The scheduling shell around document sync, built with `new` and plain mocks:
* no container, no timer, no provider.
*
* What the job itself decides is small, and all of it is the kind that breaks
* silently. The addon gate and the kill switch are re-read per tick, which is
* the promise the docblock makes an admin ("toggle it and it takes effect")
* and the only thing that makes the difference between this job and the
* Dawarich one visible. The interval is typed by a human into app_settings, so
* '5' and '999999' both have to land somewhere the cron parser accepts. And one
* unreachable NAS must not take the other trips' bindings down with it.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const log = vi.hoisted(() => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
logDebug: vi.fn(),
}));
vi.mock('../../../../src/nest/audit/audit-log.logger', () => log);
import { ADDON_IDS } from '../../../../src/addons';
import { DocSyncJob } from '../../../../src/nest/doc-sync/doc-sync.job';
import { SETTING_POLL_INTERVAL, SETTING_SYNC_ENABLED } from '../../../../src/nest/doc-sync/doc-sync.constants';
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
import type { DatabaseService } from '../../../../src/nest/database/database.service';
import type { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
const link = (id: number): LinkRow => ({ id, provider_id: 'paperless' } as LinkRow);
const RUN = { state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
interface Setup {
/** Absent means no app_settings row at all, which is the shipped state. */
interval?: string;
killSwitch?: string;
addonOn: boolean;
registrarEnabled: boolean;
links: LinkRow[];
orphaned: number;
}
function makeJob(over: Partial<Setup> = {}) {
const setup: Setup = { addonOn: true, registrarEnabled: true, links: [], orphaned: 0, ...over };
const settings = new Map<string, string>();
if (setup.interval !== undefined) settings.set(SETTING_POLL_INTERVAL, setup.interval);
if (setup.killSwitch !== undefined) settings.set(SETTING_SYNC_ENABLED, setup.killSwitch);
// Shaped like the real `get<T>(sql, ...params)` so the stub cannot drift from
// the signature the job calls, and so a case can tell the two keys apart.
const db = {
get: vi.fn((_sql: string, key?: unknown) => {
const value = settings.get(String(key));
return value === undefined ? undefined : { value };
}),
};
let onTick: (() => void | Promise<void>) | undefined;
const registrar = {
isEnabled: vi.fn(() => setup.registrarEnabled),
register: vi.fn((_name: string, _expression: string, cb: () => void | Promise<void>) => {
onTick = cb;
return setup.registrarEnabled;
}),
unregister: vi.fn(),
};
// Both stubs carry the real signatures, so a case can read back which link a
// run was asked for instead of only that some run happened.
const sync = {
dueLinks: vi.fn((_limit?: number) => setup.links),
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => RUN),
};
const config = { markOrphanedLinks: vi.fn(() => setup.orphaned) };
const addons = { isAddonEnabled: vi.fn(() => setup.addonOn) };
const job = new DocSyncJob(
db as unknown as DatabaseService,
sync as unknown as DocSyncService,
config as unknown as DocSyncConfigService,
addons as unknown as AddonsService,
registrar as unknown as CronRegistrarService,
);
return { job, db, registrar, sync, config, addons, takeTick: () => onTick };
}
beforeEach(() => vi.clearAllMocks());
describe('DocSyncJob bootstrap', () => {
it('schedules nothing, reads nothing and logs nothing while the registrar is off', () => {
// The test gate. A job that registered past it would have every suite boot
// start polling whatever document store sits in the fixture database.
const { job, registrar, db } = makeJob({ registrarEnabled: false });
job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
expect(log.logInfo).not.toHaveBeenCalled();
});
it('registers one cron under a name of its own and announces the cadence', () => {
const { job, registrar } = makeJob();
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('docsync', '*/5 * * * *', expect.any(Function));
expect(log.logInfo).toHaveBeenCalledWith('Document sync: scheduled every 5m');
});
it('reads the interval from its own app_settings key', () => {
const { job, db } = makeJob({ interval: '600' });
job.onApplicationBootstrap();
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL);
});
it('clamps a hand-typed interval to between a minute and an hour', () => {
for (const [setting, minutes] of [
[undefined, 5], // no row: the 300s default
['', 5],
['not-a-number', 5],
['0', 1], // below the floor, and a `*/0` expression the parser would reject
['10', 1],
['60', 1], // the floor itself
['600', 10],
['3600', 60], // the ceiling itself
['86400', 60],
['-300', 1], // a minus sign parses fine and must not become a negative cron
] as const) {
vi.clearAllMocks();
const { job, registrar } = makeJob({ interval: setting });
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('docsync', `*/${minutes} * * * *`, expect.any(Function));
}
});
it('rounds a sub-minute remainder to the nearest whole minute rather than down to nothing', () => {
const { job, registrar } = makeJob({ interval: '90' });
job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('docsync', '*/2 * * * *', expect.any(Function));
});
it('hands the registrar the tick itself, so a fired cron reaches the sync', async () => {
const { job, sync, takeTick } = makeJob({ links: [link(1)] });
job.onApplicationBootstrap();
const tick = takeTick();
expect(tick).toBeTypeOf('function');
await tick?.();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('does not decide at bootstrap whether the addon is on', () => {
// Asking here would freeze the answer for the life of the process, and the
// bug would read as "the documents toggle needs a restart".
const { job, addons } = makeJob();
job.onApplicationBootstrap();
expect(addons.isAddonEnabled).not.toHaveBeenCalled();
});
});
describe('DocSyncJob tick', () => {
it('does nothing at all while the documents addon is off', async () => {
const { job, sync, config, db } = makeJob({ addonOn: false, links: [link(1)] });
await job.tick();
expect(sync.dueLinks).not.toHaveBeenCalled();
expect(sync.syncLink).not.toHaveBeenCalled();
expect(config.markOrphanedLinks).not.toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('asks the addon gate again on every tick, so switching it on needs no restart', async () => {
const { job, addons, sync } = makeJob({ addonOn: false, links: [link(1)] });
await job.tick();
expect(sync.syncLink).not.toHaveBeenCalled();
addons.isAddonEnabled.mockReturnValue(true);
await job.tick();
expect(addons.isAddonEnabled).toHaveBeenCalledTimes(2);
expect(addons.isAddonEnabled).toHaveBeenLastCalledWith(ADDON_IDS.DOCUMENTS);
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('stops the run on the kill switch, without touching the bindings', async () => {
const { job, sync, config, db } = makeJob({ killSwitch: 'false', links: [link(1)] });
await job.tick();
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_SYNC_ENABLED);
expect(config.markOrphanedLinks).not.toHaveBeenCalled();
expect(sync.dueLinks).not.toHaveBeenCalled();
});
it('keeps syncing on anything that is not exactly the string false', async () => {
// The setting is absent by default, so an unrecognised value has to mean ON.
// Treating '0' or 'off' as a stop would silently disable sync for anyone who
// typed the switch by hand into app_settings.
for (const value of [undefined, '', '0', 'off', 'no', 'FALSE', 'true']) {
vi.clearAllMocks();
const { job, sync } = makeJob({ killSwitch: value, links: [link(1)] });
await job.tick();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
}
});
it('re-reads the kill switch per tick instead of remembering the first answer', async () => {
const { job, db, sync } = makeJob({ links: [link(1)] });
await job.tick();
await job.tick();
expect(db.get).toHaveBeenCalledTimes(2);
expect(sync.syncLink).toHaveBeenCalledTimes(2);
});
it('sweeps bindings whose credential owner left the trip before it syncs anything', async () => {
const { job, config, sync } = makeJob({ orphaned: 2, links: [link(1)] });
await job.tick();
expect(config.markOrphanedLinks).toHaveBeenCalledTimes(1);
expect(config.markOrphanedLinks.mock.invocationCallOrder[0]).toBeLessThan(
sync.dueLinks.mock.invocationCallOrder[0],
);
expect(log.logInfo).toHaveBeenCalledWith('Document sync: 2 link(s) orphaned, owner no longer on the trip');
});
it('stays quiet when the sweep found nothing', async () => {
const { job, config } = makeJob({ orphaned: 0 });
await job.tick();
expect(config.markOrphanedLinks).toHaveBeenCalledTimes(1);
expect(log.logInfo).not.toHaveBeenCalled();
});
it('runs the remaining bindings after one of them throws', async () => {
// An unreachable NAS on one trip is not a reason to skip a Paperless
// binding on another, and the two are ordinary neighbours in one list.
const { job, sync } = makeJob({ links: [link(1), link(2), link(3)] });
sync.syncLink.mockRejectedValueOnce(new Error('EHOSTUNREACH'));
await expect(job.tick()).resolves.toBeUndefined();
expect(sync.syncLink).toHaveBeenCalledTimes(3);
expect(log.logError).toHaveBeenCalledWith('Document sync: link 1 failed: EHOSTUNREACH');
});
it('logs a non-Error rejection by value rather than as an empty message', async () => {
const { job, sync } = makeJob({ links: [link(7)] });
sync.syncLink.mockRejectedValueOnce('ECONNREFUSED');
await job.tick();
expect(log.logError).toHaveBeenCalledWith('Document sync: link 7 failed: ECONNREFUSED');
});
it('never rejects, so a failure inside it cannot escape into the scheduler', async () => {
const { job, sync } = makeJob();
sync.dueLinks.mockImplementation(() => {
throw new Error('database is locked');
});
await expect(job.tick()).resolves.toBeUndefined();
expect(log.logError).toHaveBeenCalledWith('Document sync tick failed: database is locked');
});
it('logs a failure thrown as a bare value by what it printed, not as an empty message', async () => {
const { job, addons } = makeJob();
addons.isAddonEnabled.mockImplementation(() => {
throw 'SQLITE_BUSY';
});
await expect(job.tick()).resolves.toBeUndefined();
expect(log.logError).toHaveBeenCalledWith('Document sync tick failed: SQLITE_BUSY');
});
it('recovers on the next tick after a failed one', async () => {
const { job, sync } = makeJob({ links: [link(1)] });
sync.dueLinks.mockImplementationOnce(() => {
throw new Error('down');
});
await job.tick();
await job.tick();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
expect(log.logError).toHaveBeenCalledTimes(1);
});
it('leaves the choice of what is due to the sync service', async () => {
// The job passes the rows through untouched: the backoff, the circuit
// breaker and the ordering all live in dueLinks, and a filter copied up
// here would be a second place to keep them in step with.
const links = [link(4), link(5)];
const { job, sync } = makeJob({ links });
await job.tick();
expect(sync.syncLink.mock.calls.map((c) => c[0])).toEqual(links);
});
});
@@ -0,0 +1,231 @@
/**
* The MCP half of document sync, built with `new` and plain mocks: no registry,
* no MCP server, no container.
*
* The tool bodies are three lines each, and what is worth pinning about them is
* not the plumbing but the decisions around it. The access check is the whole
* security story — there is no guard in front of an MCP tool, so a dropped
* `verifyTripAccess` is a stranger reading where somebody's documents live. The
* absence of a connect tool is a decision too: setting a connection up means
* handing over a credential that usually reaches a person's entire document
* archive, and a decision nobody asserts is one somebody re-adds by accident.
* And the refusal for an unconnected trip is the only thing standing between an
* assistant and answering "nothing to sync" to a question about someone's
* boarding pass.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { z, type ZodRawShape } from 'zod';
import { ADDON_IDS } from '../../../../src/addons';
import { DocSyncMcp } from '../../../../src/nest/doc-sync/doc-sync.mcp';
import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
import type { FilesService } from '../../../../src/nest/files/files.service';
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
const link = (id: number, providerId = 'paperless'): LinkRow =>
({ id, provider_id: providerId, trip_id: 3 } as LinkRow);
const RUN = { state: 'ok', pulled: 2, pushed: 1, conflicts: 0, missing: 0 };
interface Setup {
/** `undefined` is what canAccessTrip answers for a trip the user cannot see. */
access: boolean;
links: LinkRow[];
status: Record<string, unknown>;
issues: Array<Record<string, unknown>>;
addonOn: boolean;
}
function makeMcp(over: Partial<Setup> = {}) {
const setup: Setup = { access: true, links: [], status: {}, issues: [], addonOn: true, ...over };
const files = { verifyTripAccess: vi.fn(() => (setup.access ? { id: 3, user_id: 7 } : undefined)) };
const config = { listLinks: vi.fn(() => setup.links) };
const sync = {
status: vi.fn(() => setup.status),
issues: vi.fn(() => setup.issues),
syncLink: vi.fn(async () => RUN),
};
const addons = { isAddonEnabled: vi.fn(() => setup.addonOn) };
const mcp = new DocSyncMcp(
config as unknown as DocSyncConfigService,
sync as unknown as DocSyncService,
files as unknown as FilesService,
addons as unknown as AddonsService,
);
return { mcp, files, config, sync, addons };
}
/** A success is one JSON text block and carries no error flag. */
function payload(result: McpTextResult): Record<string, unknown> {
expect(result.isError).toBeUndefined();
return JSON.parse(result.content[0].text) as Record<string, unknown>;
}
function refusal(result: McpTextResult): string {
expect(result.isError).toBe(true);
return result.content[0].text;
}
function toolOptions(method: string): ToolOptions {
const entry = getEntry(DocSyncMcp as unknown as ClassRef, method);
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is not a registered MCP tool`);
return entry.options;
}
/**
* Read off the prototype rather than listed here, so a fourth tool fails the
* set assertion below instead of quietly slipping past a table this file keeps.
*/
function registeredMethods(): string[] {
return Object.getOwnPropertyNames(DocSyncMcp.prototype)
.filter((name) => getEntry(DocSyncMcp as unknown as ClassRef, name) !== undefined)
.sort();
}
beforeEach(() => vi.clearAllMocks());
describe('DocSyncMcp surface', () => {
it('offers three tools, none of which can create or change a connection', () => {
expect(registeredMethods()).toEqual(['getTripDocumentSync', 'listIssues', 'syncNow']);
expect(registeredMethods().map((m) => toolOptions(m).name).sort()).toEqual([
'get_trip_document_sync',
'list_trip_document_sync_issues',
'sync_trip_documents',
]);
});
it('asks for nothing but a trip id and a full-run flag', () => {
// The other half of the same decision: a credential surface could arrive
// bolted onto an existing schema rather than as a tool of its own.
const fields = registeredMethods().flatMap((m) => Object.keys(toolOptions(m).inputSchema as ZodRawShape));
expect([...new Set(fields)].sort()).toEqual(['full', 'tripId']);
});
it.each(registeredMethods())('%s disappears while the documents addon is off', (method) => {
const { mcp, addons } = makeMcp({ addonOn: false });
const { when } = toolOptions(method);
expect(typeof when).toBe('function');
expect(when?.(ctx, mcp)).toBe(false);
expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.DOCUMENTS);
addons.isAddonEnabled.mockReturnValue(true);
expect(when?.(ctx, mcp)).toBe(true);
});
it.each([
['getTripDocumentSync', 'read'],
['listIssues', 'read'],
['syncNow', 'write'],
])('%s claims files:%s, so a read-scoped token cannot move files', (method, mode) => {
expect(toolOptions(method).access).toEqual({ group: 'files', mode });
});
it('advertises the run as a write even though it creates nothing itself', () => {
// It moves bytes in both directions, and a host that trusts readOnlyHint
// would otherwise call it unattended.
expect(toolOptions('syncNow').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
expect(toolOptions('getTripDocumentSync').annotations).toMatchObject({ readOnlyHint: true });
expect(toolOptions('listIssues').annotations).toMatchObject({ readOnlyHint: true });
});
it('refuses a trip id that is not a positive integer before a handler sees it', () => {
const schema = z.object(toolOptions('getTripDocumentSync').inputSchema as ZodRawShape);
expect(schema.safeParse({ tripId: 3 }).success).toBe(true);
expect(schema.safeParse({ tripId: 0 }).success).toBe(false);
expect(schema.safeParse({ tripId: -1 }).success).toBe(false);
expect(schema.safeParse({ tripId: 1.5 }).success).toBe(false);
});
});
describe('DocSyncMcp access', () => {
it.each([
['get_trip_document_sync', (m: DocSyncMcp) => m.getTripDocumentSync({ tripId: 3 }, ctx)],
['list_trip_document_sync_issues', (m: DocSyncMcp) => m.listIssues({ tripId: 3 }, ctx)],
['sync_trip_documents', (m: DocSyncMcp) => m.syncNow({ tripId: 3 }, ctx)],
])('%s tells a user who cannot see the trip nothing beyond that', async (_name, call) => {
const { mcp, files } = makeMcp({ access: false, links: [link(1)] });
const result = await call(mcp);
expect(refusal(result as McpTextResult)).toBe('Trip not found or access denied.');
expect(files.verifyTripAccess).toHaveBeenCalledWith(3, ctx.userId);
});
it('does not reach the sync service at all for a trip the user cannot see', async () => {
const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] });
await mcp.syncNow({ tripId: 3 }, ctx);
mcp.listIssues({ tripId: 3 }, ctx);
mcp.getTripDocumentSync({ tripId: 3 }, ctx);
expect(sync.syncLink).not.toHaveBeenCalled();
expect(sync.status).not.toHaveBeenCalled();
expect(sync.issues).not.toHaveBeenCalled();
expect(config.listLinks).not.toHaveBeenCalled();
});
});
describe('get_trip_document_sync', () => {
it('hands back the trip status as the service reports it', () => {
const status = { links: [{ id: 1, remoteLabel: 'Japan 2026' }], items: { synced: 4, conflict: 1 } };
const { mcp, sync } = makeMcp({ status });
expect(payload(mcp.getTripDocumentSync({ tripId: 3 }, ctx))).toEqual(status);
expect(sync.status).toHaveBeenCalledWith(3);
});
});
describe('list_trip_document_sync_issues', () => {
it('says the trip is not configured rather than answering with an empty list', () => {
// An empty list and no connection at all read identically to an assistant,
// and "everything is in step" is the wrong answer to give about documents
// that were never being synced.
const { mcp, sync } = makeMcp({ links: [] });
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] });
expect(sync.issues).not.toHaveBeenCalled();
});
it('reports an empty list for a configured trip with nothing to decide', () => {
const { mcp } = makeMcp({ links: [link(1)], issues: [] });
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] });
});
it('passes the open issues through for a configured trip', () => {
const issues = [{ id: 9, state: 'conflict', remote_name: 'boarding.pdf' }];
const { mcp, sync } = makeMcp({ links: [link(1)], issues });
expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues });
expect(sync.issues).toHaveBeenCalledWith(3);
});
});
describe('sync_trip_documents', () => {
it('refuses an unconnected trip with the step that would fix it', async () => {
const { mcp, sync } = makeMcp({ links: [] });
const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx));
expect(text).toContain('not connected to a document store');
expect(text).toContain('file manager');
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('runs every binding of the trip and reports each one separately', async () => {
// A trip can be bound to more than one store, and collapsing the runs into
// one total would hide which of them failed.
const links = [link(1, 'paperless'), link(2, 'nextcloud')];
const { mcp, sync } = makeMcp({ links });
const body = payload(await mcp.syncNow({ tripId: 3 }, ctx));
expect(sync.syncLink).toHaveBeenCalledTimes(2);
expect(body.runs).toEqual([
{ linkId: 1, provider: 'paperless', ...RUN },
{ linkId: 2, provider: 'nextcloud', ...RUN },
]);
});
it('asks for the cheap incremental run unless the caller says otherwise', async () => {
const { mcp, sync } = makeMcp({ links: [link(1)] });
await mcp.syncNow({ tripId: 3 }, ctx);
expect(sync.syncLink).toHaveBeenCalledWith(link(1), { full: false });
await mcp.syncNow({ tripId: 3, full: true }, ctx);
expect(sync.syncLink).toHaveBeenLastCalledWith(link(1), { full: true });
});
});
@@ -0,0 +1,917 @@
/**
* DocSyncService against a real in-memory database.
*
* The planner is tested as plain data next door. This file is about the half
* that cannot be: what the reconciler writes, what it refuses to do, and what
* it never asks a provider for. Those are statements about SQL and about call
* order, so the schema comes from the real migration array and every row is
* read back with plain SQL rather than through the service that wrote it.
*
* The provider is a fake because a unit test must not open a socket. Storage
* and the files domain are fakes because the bytes are not what is under test —
* but the fake files service writes real `trip_files` rows, so "nothing was
* downloaded" is asserted against the table the file manager reads, not against
* a spy alone.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../../src/db/database', () => dbMock);
import type { DocsyncErrorCode } from '@trek/shared';
import { createTables } from '../../../../src/db/schema';
import { runMigrations } from '../../../../src/db/migrations';
import { createTrip, createUser } from '../../../helpers/factories';
import { DatabaseService } from '../../../../src/nest/database/database.service';
import { AllowedFileTypesService } from '../../../../src/nest/files/allowed-file-types.service';
import { MAX_FILE_SIZE } from '../../../../src/nest/files/files.constants';
import { DocSyncConfigService, type LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service';
import { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
import { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service';
import { LINK_CIRCUIT_OPEN_AFTER, MAX_TRANSFERS_PER_RUN } from '../../../../src/nest/doc-sync/doc-sync.constants';
import type {
DocFailure,
DocResult,
DocumentConnectionRef,
DocumentProvider,
DocumentProviderCapabilities,
DocumentScopeOption,
DocumentScopeRef,
FetchResult,
PushRequest,
PushResult,
RemoteDocument,
} from '../../../../src/nest/doc-sync/document-provider';
import type { FilesService } from '../../../../src/nest/files/files.service';
import type { StorageService } from '../../../../src/nest/storage/storage.service';
import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service';
// ── Fixtures ─────────────────────────────────────────────────────────────────
const FILE_BYTES = Buffer.from('trek-document-bytes');
const FILE_SHA = crypto.createHash('sha256').update(FILE_BYTES).digest('hex');
type Listing = { documents: RemoteDocument[]; cursor: string | null; cursorUnchanged: boolean; truncated: boolean };
const ok = <T>(data: T): DocResult<T> => ({ success: true, data });
const fail = (code: DocsyncErrorCode, status?: number): DocFailure => ({ success: false, error: { code, status } });
const SCOPE: DocumentScopeOption = {
scopeKey: 'tag:1',
label: 'Japan 2026',
remoteRootId: '1',
remoteRootPath: '/TREK/japan',
};
const BASE_CAPABILITIES: DocumentProviderCapabilities = {
push: 'none',
stableId: true,
remoteTrash: true,
replaceInPlace: true,
contentHashInListing: true,
maxUploadBytes: null,
acceptedMimeTypes: null,
canCreateScope: true,
};
let capabilities: DocumentProviderCapabilities = { ...BASE_CAPABILITIES };
const remoteDoc = (over: Partial<RemoteDocument> = {}): RemoteDocument => ({
remoteId: 'r1',
name: 'boarding.pdf',
size: 1024,
mimeType: 'application/pdf',
remoteVersion: 'v1',
contentHash: null,
remoteModifiedAt: '2026-09-18T10:00:00Z',
isDeleted: false,
...over,
});
const listing = (documents: RemoteDocument[] = [], over: Partial<Listing> = {}): Listing => ({
documents,
cursor: 'cursor-1',
cursorUnchanged: false,
truncated: false,
...over,
});
const provider = {
id: 'paperless',
capabilities: vi.fn((): DocumentProviderCapabilities => capabilities),
probe: vi.fn(async (): Promise<DocResult<{ account: string; capabilities: DocumentProviderCapabilities }>> =>
ok({ account: 'test', capabilities })),
listScopes: vi.fn(async (): Promise<DocResult<DocumentScopeOption[]>> => ok([SCOPE])),
createScope: vi.fn(async (): Promise<DocResult<DocumentScopeOption>> => ok(SCOPE)),
resolveScope: vi.fn(async (): Promise<DocResult<DocumentScopeOption>> => ok(SCOPE)),
list: vi.fn(async (): Promise<DocResult<Listing>> => ok(listing())),
fetch: vi.fn(async (): Promise<DocResult<FetchResult>> =>
ok({ body: Readable.from([FILE_BYTES]), size: FILE_BYTES.length, mimeType: 'application/pdf', remoteVersion: 'v9' })),
push: vi.fn(async (
_conn: DocumentConnectionRef,
_scope: DocumentScopeRef,
_req: PushRequest,
): Promise<DocResult<PushResult>> =>
ok({ remoteId: 'r-pushed', remoteVersion: 'v1', remoteModifiedAt: null, deduplicated: false })),
rename: vi.fn(async (): Promise<DocResult<{ remoteVersion: string }>> => ok({ remoteVersion: 'v2' })),
trash: vi.fn(async (): Promise<DocResult<void>> => ok(undefined)),
};
const storage = {
spoolDirFor: vi.fn((): string => spoolDir),
put: vi.fn(async (): Promise<void> => undefined),
// A fresh stream per call: the push path reads the object twice, once to hash
// and once to send, and a shared Readable would arrive already consumed.
getStream: vi.fn(async () => ({ stream: Readable.from([FILE_BYTES]), stat: { size: FILE_BYTES.length } })),
} as unknown as StorageService;
const files = {
getFileById: vi.fn((id: string | number, tripId: string | number) =>
testDb.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId)),
createFile: vi.fn((
tripId: string | number,
file: { filename: string; originalname: string; size: number; mimetype: string },
uploadedBy: number,
) => {
const info = testDb
.prepare(
`INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(tripId, file.filename, file.originalname, file.size, file.mimetype, uploadedBy || null);
return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(info.lastInsertRowid);
}),
} as unknown as FilesService;
const realtime = { broadcast: vi.fn() } as unknown as RealtimeService;
let spoolDir: string;
let ownerId: number;
let tripId: number;
let connectionId: number;
let service: DocSyncService;
let config: DocSyncConfigService;
// ── Row builders ─────────────────────────────────────────────────────────────
let scopeSeq = 0;
function makeLink(over: {
direction?: string;
deletePolicy?: string;
syncEnabled?: number;
failureCount?: number;
nextAttemptAt?: string | null;
} = {}): LinkRow {
scopeSeq += 1;
const info = testDb
.prepare(
`INSERT INTO trip_document_links
(trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path, remote_label,
direction, delete_policy, conflict_policy, sync_enabled, failure_count, next_attempt_at, created_by)
VALUES (?, ?, 'paperless', ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, 'manual', ?, ?, ?, ?)`,
)
.run(
tripId, connectionId, `tag:${scopeSeq}`,
over.direction ?? 'both', over.deletePolicy ?? 'unlink',
over.syncEnabled ?? 1, over.failureCount ?? 0, over.nextAttemptAt ?? null, ownerId,
);
return config.getLink(Number(info.lastInsertRowid));
}
let uidSeq = 0;
function seedItem(link: LinkRow, over: {
remoteId?: string;
remoteName?: string;
remoteVersion?: string;
fileId?: number;
state?: string;
contentSha256?: string;
pushedSha256?: string;
} = {}): number {
uidSeq += 1;
const info = testDb
.prepare(
`INSERT INTO document_sync_items
(link_id, trip_id, file_id, trek_doc_uid, remote_id, remote_name, remote_version,
content_sha256, pushed_sha256, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
link.id, link.trip_id, over.fileId ?? null, `uid-${uidSeq}`,
over.remoteId ?? null, over.remoteName ?? null, over.remoteVersion ?? null,
over.contentSha256 ?? null, over.pushedSha256 ?? null, over.state ?? 'synced',
);
return Number(info.lastInsertRowid);
}
function makeFile(over: {
name?: string;
storageKey?: string;
mime?: string;
messageId?: number;
noteId?: number;
deletedAt?: string;
} = {}): number {
const info = testDb
.prepare(
`INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, uploaded_by, message_id, note_id, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
tripId, over.storageKey ?? `key-${over.name ?? 'boarding.pdf'}`, over.name ?? 'boarding.pdf',
FILE_BYTES.length, over.mime ?? 'application/pdf', ownerId,
over.messageId ?? null, over.noteId ?? null, over.deletedAt ?? null,
);
return Number(info.lastInsertRowid);
}
const itemRow = (id: number) =>
testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(id) as Record<string, unknown>;
const itemRows = () =>
testDb.prepare('SELECT * FROM document_sync_items ORDER BY id').all() as Array<Record<string, unknown>>;
const linkRow = (id: number) =>
testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as Record<string, unknown>;
const fileRows = () =>
testDb.prepare('SELECT * FROM trip_files ORDER BY id').all() as Array<Record<string, unknown>>;
/** SQLite's own clock and format, so a comparison against CURRENT_TIMESTAMP means something. */
const sqlTime = (modifier: string): string =>
(testDb.prepare("SELECT datetime('now', ?) AS t").get(modifier) as { t: string }).t;
// ── Suite ────────────────────────────────────────────────────────────────────
describe('DocSyncService', () => {
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-'));
ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync.test' }).user.id;
tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id;
const info = testDb
.prepare(
`INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings)
VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`,
)
.run(tripId, ownerId);
connectionId = Number(info.lastInsertRowid);
const dbs = new DatabaseService(testDb);
const registry = new DocumentProviderRegistry([provider as unknown as DocumentProvider]);
config = new DocSyncConfigService(dbs, registry);
service = new DocSyncService(dbs, config, registry, storage, files, new AllowedFileTypesService(dbs), realtime);
});
afterAll(() => {
testDb.close();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
beforeEach(() => {
vi.resetAllMocks();
capabilities = { ...BASE_CAPABILITIES };
testDb.prepare('DELETE FROM document_sync_items').run();
testDb.prepare('DELETE FROM trip_document_links').run();
testDb.prepare('DELETE FROM trip_files').run();
testDb.prepare('DELETE FROM collab_messages').run();
testDb.prepare('DELETE FROM collab_notes').run();
for (const entry of fs.readdirSync(spoolDir)) fs.rmSync(path.join(spoolDir, entry), { force: true });
});
describe('dueLinks', () => {
it('offers only bindings that are switched on, uncircuited and actually due', () => {
const dueNow = makeLink();
const overdue = makeLink({ nextAttemptAt: sqlTime('-1 hour') });
makeLink({ nextAttemptAt: sqlTime('+1 hour') });
makeLink({ syncEnabled: 0 });
makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER });
const ids = service.dueLinks().map((l) => l.id).sort((a, b) => a - b);
expect(ids).toEqual([dueNow.id, overdue.id].sort((a, b) => a - b));
});
it('lets a binding back in one failure before the circuit opens', () => {
const nearly = makeLink({ failureCount: LINK_CIRCUIT_OPEN_AFTER - 1 });
expect(service.dueLinks().map((l) => l.id)).toEqual([nearly.id]);
});
});
describe('syncLink', () => {
it('answers busy instead of running the same binding twice at once', async () => {
const link = makeLink();
let release: () => void;
provider.resolveScope.mockImplementationOnce(
() => new Promise((resolve) => { release = () => resolve(ok(SCOPE)); }),
);
const first = service.syncLink(link);
const second = await service.syncLink(link);
expect(second.state).toBe('busy');
release();
await first;
expect(provider.list).toHaveBeenCalledTimes(1);
});
it('parks a binding whose scope is gone as scope_lost rather than as a failure', async () => {
const link = makeLink();
provider.resolveScope.mockResolvedValueOnce(fail('scope_missing'));
const res = await service.syncLink(link);
expect(res.state).toBe('scope_lost');
expect(res.errorCode).toBe('scope_missing');
expect(linkRow(link.id).last_sync_state).toBe('scope_lost');
// Nothing is enumerated once the container is known to be gone, so a
// deleted folder cannot be re-created and refilled.
expect(provider.list).not.toHaveBeenCalled();
});
it('treats a not-found scope the same as a missing one', async () => {
const link = makeLink();
provider.resolveScope.mockResolvedValueOnce(fail('not_found'));
const res = await service.syncLink(link);
expect(res.state).toBe('scope_lost');
expect(res.errorCode).toBe('scope_missing');
});
it('asks for fresh credentials when the listing comes back unauthorized', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(fail('unauthorized', 401));
const res = await service.syncLink(link);
expect(res.state).toBe('needs_reauth');
const row = linkRow(link.id);
expect(row.last_sync_state).toBe('needs_reauth');
expect(row.last_sync_error).toBe('unauthorized');
});
it('does not mistake an unreachable instance for a credential problem', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(fail('unreachable'));
const res = await service.syncLink(link);
expect(res.state).toBe('failed');
expect(linkRow(link.id).last_sync_state).toBe('failed');
});
/**
* The most expensive mistake this domain could make. An unmounted share, a
* revoked token and a moved folder all answer with a short listing, and
* acting on it would empty a trip. The run is abandoned whole: not the
* vanished documents only, but the parts of the listing that look fine too,
* because a listing that cannot be trusted cannot be trusted in pieces.
*/
it('abandons the entire run when most known documents vanish from one listing', async () => {
const link = makeLink();
const itemIds = Array.from({ length: 10 }, (_, i) =>
seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' }));
makeFile({ name: 'local-only.pdf' });
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r0', name: 'doc-0.pdf' })])));
const res = await service.syncLink(link);
expect(res.state).toBe('partial');
expect(res.errorCode).toBe('mass_delete_guard');
expect(res.missing).toBe(9);
expect(provider.fetch).not.toHaveBeenCalled();
expect(provider.push).not.toHaveBeenCalled();
expect(provider.trash).not.toHaveBeenCalled();
expect(provider.rename).not.toHaveBeenCalled();
expect(files.createFile).not.toHaveBeenCalled();
for (const id of itemIds) {
const row = itemRow(id);
expect(row.state).toBe('synced');
expect(row.remote_missing_at).toBeNull();
}
expect(fileRows()).toHaveLength(1);
expect(linkRow(link.id).last_sync_error).toBe('mass_delete_guard');
});
/**
* TREK serves a download inline with a Content-Type derived from its
* extension, so an .svg or .html out of somebody's Nextcloud folder would
* be stored XSS. It becomes a visible row rather than a silent skip, and
* the bytes are never asked for.
*/
it('refuses a document whose extension TREK would serve inline', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r-svg', name: 'floorplan.svg' }),
remoteDoc({ remoteId: 'r-html', name: 'itinerary.html' }),
])));
const res = await service.syncLink(link);
expect(provider.fetch).not.toHaveBeenCalled();
expect(fileRows()).toHaveLength(0);
expect(itemRows().map((r) => [r.remote_id, r.state, r.error_code])).toEqual([
['r-svg', 'rejected_type', 'unsupported_type'],
['r-html', 'rejected_type', 'unsupported_type'],
]);
expect(res.state).toBe('partial');
});
it('refuses a document larger than an upload would be allowed to be', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r-big', name: 'scan.pdf', size: MAX_FILE_SIZE + 1 }),
])));
await service.syncLink(link);
expect(provider.fetch).not.toHaveBeenCalled();
const row = itemRows()[0];
expect(row.state).toBe('too_large');
expect(row.error_code).toBe('too_large');
});
/**
* Paperless answers 400 for anything outside its parser list. Asking the
* capabilities first turns a failure that repeats every run into one row
* that says why.
*/
it('never uploads a document whose type the provider has said it will not take', async () => {
const link = makeLink();
capabilities = { ...BASE_CAPABILITIES, acceptedMimeTypes: ['application/pdf'] };
const fileId = makeFile({ name: 'sunset.png', mime: 'image/png' });
await service.syncLink(link);
expect(provider.push).not.toHaveBeenCalled();
const row = itemRows()[0];
expect(row.file_id).toBe(fileId);
expect(row.state).toBe('rejected_type');
expect(row.error_code).toBe('unsupported_type');
});
/**
* The echo guard, end to end through the service rather than through the
* planner. A provider reports TREK's own upload as a change on the next
* run; without the hash it pushed, the file would be pulled back down,
* re-uploaded, and bounce forever.
*/
it('does not pull back the bytes it uploaded itself', async () => {
const link = makeLink();
makeFile({ name: 'boarding.pdf' });
provider.list.mockResolvedValueOnce(ok(listing([])));
const firstRun = await service.syncLink(link);
expect(firstRun.pushed).toBe(1);
const afterPush = itemRows()[0];
expect(afterPush.pushed_sha256).toBe(FILE_SHA);
expect(afterPush.content_sha256).toBe(FILE_SHA);
expect(afterPush.remote_id).toBe('r-pushed');
// Same bytes back, under a new version marker — which is what a provider
// reports for TREK's own write.
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r-pushed', name: 'boarding.pdf', remoteVersion: 'v2', contentHash: FILE_SHA }),
])));
const secondRun = await service.syncLink(link);
expect(provider.fetch).not.toHaveBeenCalled();
expect(provider.push).toHaveBeenCalledTimes(1);
expect(secondRun.pulled).toBe(0);
expect(fileRows()).toHaveLength(1);
expect(itemRows()[0].state).toBe('synced');
});
it('counts a failure and puts the binding off until later', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(fail('unreachable'));
await service.syncLink(link);
const after = testDb
.prepare('SELECT failure_count, next_attempt_at > CURRENT_TIMESTAMP AS in_future FROM trip_document_links WHERE id = ?')
.get(link.id) as { failure_count: number; in_future: number };
expect(after.failure_count).toBe(1);
expect(after.in_future).toBe(1);
provider.list.mockResolvedValueOnce(fail('unreachable'));
await service.syncLink(config.getLink(link.id));
expect(linkRow(link.id).failure_count).toBe(2);
});
it('clears the failure count and the wait as soon as a run succeeds', async () => {
const link = makeLink({ failureCount: 3, nextAttemptAt: sqlTime('+1 hour') });
const res = await service.syncLink(link);
expect(res.state).toBe('ok');
const row = linkRow(link.id);
expect(row.failure_count).toBe(0);
expect(row.next_attempt_at).toBeNull();
expect(row.remote_cursor).toBe('cursor-1');
});
/**
* A chat screenshot and a note attachment live in `trip_files` like every
* other document, but they belong to a conversation rather than to the
* trip's paperwork — pushing them would put someone's chat into a shared
* Paperless.
*/
it('leaves chat and note attachments out of the upload entirely', async () => {
const link = makeLink();
const messageId = Number(testDb
.prepare("INSERT INTO collab_messages (trip_id, user_id, text) VALUES (?, ?, 'hi')")
.run(tripId, ownerId).lastInsertRowid);
const noteId = Number(testDb
.prepare("INSERT INTO collab_notes (trip_id, user_id, title) VALUES (?, ?, 'note')")
.run(tripId, ownerId).lastInsertRowid);
makeFile({ name: 'boarding.pdf' });
makeFile({ name: 'chat-screenshot.pdf', storageKey: 'key-chat', messageId });
makeFile({ name: 'note-attachment.pdf', storageKey: 'key-note', noteId });
await service.syncLink(link);
expect(provider.push).toHaveBeenCalledTimes(1);
expect(provider.push.mock.calls[0][2].fileName).toBe('boarding.pdf');
expect(itemRows()).toHaveLength(1);
});
/**
* The other half of the mass-delete rule, below the threshold: a document
* that is gone upstream is written down and left to a person. An unmounted
* share answers with an empty listing, and a trip is not a cache.
*/
it('records a document that vanished upstream without removing anything locally', async () => {
const link = makeLink();
const fileId = makeFile({ name: 'boarding.pdf' });
const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' });
const res = await service.syncLink(link);
expect(res.missing).toBe(1);
const row = itemRow(itemId);
expect(row.state).toBe('remote_missing');
expect(row.remote_missing_at).not.toBeNull();
expect(fileRows().map((f) => f.id)).toEqual([fileId]);
expect(provider.trash).not.toHaveBeenCalled();
});
it('moves a deleted document to the provider recycle bin only when the binding says trash', async () => {
const trashing = makeLink({ deletePolicy: 'trash' });
const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' });
const itemId = seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' });
provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })])));
await service.syncLink(trashing);
expect(provider.trash).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1');
expect(itemRow(itemId).state).toBe('local_deleted');
const unlinking = makeLink({ deletePolicy: 'unlink' });
const otherFileId = makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' });
seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' });
provider.trash.mockClear();
await service.syncLink(unlinking);
expect(provider.trash).not.toHaveBeenCalled();
});
it('carries a rename made in TREK over to the provider', async () => {
const link = makeLink();
const fileId = makeFile({ name: 'new.pdf' });
const itemId = seedItem(link, {
remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced',
});
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })])));
await service.syncLink(link);
expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'new.pdf');
const row = itemRow(itemId);
expect(row.remote_name).toBe('new.pdf');
expect(row.remote_version).toBe('v2');
});
it('carries a rename made at the provider over to the trip file', async () => {
const link = makeLink();
const fileId = makeFile({ name: 'old.pdf' });
const itemId = seedItem(link, {
remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced',
});
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'new-upstream.pdf' })])));
await service.syncLink(link);
expect(provider.rename).not.toHaveBeenCalled();
expect(fileRows()[0].original_name).toBe('new-upstream.pdf');
expect(itemRow(itemId).remote_name).toBe('new-upstream.pdf');
expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'file:updated', expect.anything());
});
it('hands a document both sides renamed to a person instead of picking one', async () => {
const link = makeLink();
const fileId = makeFile({ name: 'mine.pdf' });
const itemId = seedItem(link, {
remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced',
});
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'theirs.pdf' })])));
const res = await service.syncLink(link);
expect(res.conflicts).toBe(1);
expect(provider.rename).not.toHaveBeenCalled();
expect(itemRow(itemId).state).toBe('conflict');
expect(service.issues(tripId).map((r) => r.id)).toContain(itemId);
});
/**
* The size a listing claims is not a promise. The transfer is cut off at
* the cap while it is still being written, so an oversized body can never
* become a `trip_files` row, and the half-written spool file goes with it.
*/
it('cuts off a download that outgrows the cap the listing promised to stay under', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r-lying', name: 'scan.pdf', size: null })])));
provider.fetch.mockResolvedValueOnce(ok({
body: Readable.from([Buffer.allocUnsafe(MAX_FILE_SIZE + 1)]),
size: null,
mimeType: 'application/pdf',
remoteVersion: 'v1',
}));
await service.syncLink(link);
expect(storage.put).not.toHaveBeenCalled();
expect(fileRows()).toHaveLength(0);
expect(itemRows()[0].state).toBe('too_large');
expect(fs.readdirSync(spoolDir)).toHaveLength(0);
});
it('turns a failed download into a visible error row rather than losing it', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc()])));
provider.fetch.mockResolvedValueOnce(fail('timeout'));
const res = await service.syncLink(link);
expect(res.state).toBe('partial');
const row = itemRows()[0];
expect(row.state).toBe('error');
expect(row.error_code).toBe('timeout');
expect(row.attempts).toBe(1);
expect(row.file_id).toBeNull();
expect(fileRows()).toHaveLength(0);
expect(service.issues(tripId)).toHaveLength(1);
});
/**
* Pins what happens today, and it is wrong. The failed pull already wrote
* the provider's version marker into the pairing, so the next run sees no
* change and emits `touch` — and `touch` promotes `error` to `synced`. The
* document is then booked as synced with no `file_id`, out of the issues
* list and never retried. Reported rather than fixed here; this is the test
* that turns red once it is.
*/
it('keeps a failed download visible as an error instead of booking it as synced', async () => {
// The failure path used to write the provider's version marker, so the
// next run saw no change, produced a `touch`, and touch lifted `error` to
// `synced`. The document then had no bytes in TREK, was gone from the
// issues list, and nothing ever fetched it again.
const link = makeLink();
provider.list.mockResolvedValue(ok(listing([remoteDoc()])));
provider.fetch.mockResolvedValueOnce(fail('timeout'));
await service.syncLink(link);
await service.syncLink(link);
const row = itemRows()[0];
expect(row.state).toBe('error');
expect(row.file_id).toBeNull();
expect(service.issues(tripId)).toHaveLength(1);
});
it('creates no trip file when storage refuses the finished download', async () => {
const link = makeLink();
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc()])));
(storage.put as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('disk full'));
await service.syncLink(link);
expect(files.createFile).not.toHaveBeenCalled();
expect(fileRows()).toHaveLength(0);
expect(itemRows()[0].state).toBe('error');
});
it('keeps the document anchor when an upload fails, so the retry is not a second document', async () => {
const link = makeLink();
makeFile({ name: 'boarding.pdf' });
provider.push.mockResolvedValueOnce(fail('quota_exceeded'));
const res = await service.syncLink(link);
expect(res.errorCode).toBe('quota_exceeded');
const row = itemRows()[0];
expect(row.state).toBe('error');
expect(row.error_code).toBe('quota_exceeded');
expect(row.trek_doc_uid).toBeTruthy();
});
it('refuses an upload the provider has told TREK is over its size limit', async () => {
const link = makeLink();
capabilities = { ...BASE_CAPABILITIES, maxUploadBytes: 10 };
makeFile({ name: 'boarding.pdf' });
await service.syncLink(link);
expect(provider.push).not.toHaveBeenCalled();
expect(itemRows()[0].state).toBe('too_large');
});
it('records an upload whose bytes are missing from storage instead of throwing', async () => {
const link = makeLink();
makeFile({ name: 'boarding.pdf' });
(storage.getStream as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('gone'));
const res = await service.syncLink(link);
expect(res.errorCode).toBe('provider_error');
expect(provider.push).not.toHaveBeenCalled();
expect(itemRows()[0].state).toBe('error');
});
/**
* One enormous trip must not hold the worker while every other binding on
* the instance waits. The rest is simply left for the next run.
*/
it('stops after the per-run transfer budget and leaves the rest for next time', async () => {
const link = makeLink();
const many = Array.from({ length: MAX_TRANSFERS_PER_RUN + 5 }, (_, i) =>
remoteDoc({ remoteId: `r${i}`, name: `doc-${i}.pdf` }));
provider.list.mockResolvedValueOnce(ok(listing(many)));
const res = await service.syncLink(link);
expect(res.pulled).toBe(MAX_TRANSFERS_PER_RUN);
expect(provider.fetch).toHaveBeenCalledTimes(MAX_TRANSFERS_PER_RUN);
expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN);
});
it('records an unexpected crash as a failed run instead of letting it escape', async () => {
const link = makeLink();
provider.list.mockImplementationOnce(() => { throw new Error('adapter blew up'); });
const res = await service.syncLink(link);
expect(res.state).toBe('failed');
expect(res.errorCode).toBe('unknown');
expect(linkRow(link.id).failure_count).toBe(1);
});
it('fails a binding for a provider this build does not have', async () => {
const link = makeLink();
testDb.prepare("UPDATE trip_document_links SET provider_id = 'papra' WHERE id = ?").run(link.id);
const res = await service.syncLink(config.getLink(link.id));
expect(res.state).toBe('failed');
expect(res.errorCode).toBe('provider_error');
expect(provider.resolveScope).not.toHaveBeenCalled();
});
});
describe('status', () => {
it('reports the trip bindings together with a count per item state', () => {
const link = makeLink();
seedItem(link, { remoteId: 'r1', state: 'synced' });
seedItem(link, { remoteId: 'r2', state: 'synced' });
seedItem(link, { remoteId: 'r3', state: 'conflict' });
const status = service.status(tripId);
expect(status.links).toHaveLength(1);
expect(status.items).toEqual({ synced: 2, conflict: 1 });
});
});
describe('resolveConflict', () => {
/** A conflicted pairing: the provider moved on, and TREK holds its own copy. */
function seedConflict(link: LinkRow): { itemId: number; fileId: number } {
const fileId = makeFile({ name: 'boarding.pdf' });
const itemId = seedItem(link, {
remoteId: 'r1',
remoteName: 'boarding.pdf',
remoteVersion: 'v1',
fileId,
state: 'conflict',
contentSha256: 'agreed-hash',
});
return { itemId, fileId };
}
it('keeps both copies and gives each its own pairing when the user asks for both', async () => {
const link = makeLink();
const { itemId, fileId } = seedConflict(link);
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }),
])));
expect(await service.resolveConflict(itemId, 'both')).toBe(true);
// The provider's copy arrives as a second TREK document, and TREK's copy
// goes up as a second provider document. Neither side loses anything.
expect(provider.fetch).toHaveBeenCalledTimes(1);
expect(provider.push).toHaveBeenCalledTimes(1);
const filesAfter = fileRows();
expect(filesAfter).toHaveLength(2);
expect(filesAfter.map((f) => f.id)).toContain(fileId);
expect(itemRows()).toHaveLength(2);
});
/**
* `keep` names the side that becomes the agreed state, and the run that
* resolveConflict triggers is where that takes effect. The two tests below
* pin what the code does TODAY, and it is the wrong way round: 'trek'
* clears `content_sha256`, which is the only record that TREK's copy ever
* moved, so the run pulls the provider's version instead of sending TREK's.
* Reported rather than fixed here — these are the tests that turn red when
* the two branches are put back.
*/
it('does not pull the provider copy when the conflict was resolved in TREK favour', async () => {
// Keeping TREK's copy means forgetting the provider's version marker, not
// forgetting the agreed bytes: clearing the latter reads as "TREK never
// agreed to this", and the provider's copy comes down over the one the
// user just chose to keep.
const link = makeLink();
const { itemId } = seedConflict(link);
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }),
])));
await service.resolveConflict(itemId, 'trek');
expect(provider.fetch).not.toHaveBeenCalled();
expect(fileRows()).toHaveLength(1);
});
it('pulls the provider copy when the conflict was resolved in its favour', async () => {
const link = makeLink();
const { itemId } = seedConflict(link);
provider.list.mockResolvedValueOnce(ok(listing([
remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }),
])));
await service.resolveConflict(itemId, 'provider');
expect(provider.fetch).toHaveBeenCalledTimes(1);
expect(itemRow(itemId).state).toBe('synced');
});
it('refuses to resolve a pairing that is not in conflict', async () => {
const link = makeLink();
const itemId = seedItem(link, { remoteId: 'r1', state: 'synced' });
expect(await service.resolveConflict(itemId, 'both')).toBe(false);
expect(provider.list).not.toHaveBeenCalled();
});
});
describe('issues', () => {
it('lists only the rows a person has to decide about, never the ones still waiting', () => {
const link = makeLink();
const decidable = ['conflict', 'rejected_type', 'too_large', 'remote_missing', 'error'];
const waiting = ['pending', 'synced', 'local_deleted'];
for (const state of [...decidable, ...waiting]) {
seedItem(link, { remoteId: `r-${state}`, state });
}
const states = service.issues(tripId).map((r) => r.state as string).sort();
expect(states).toEqual([...decidable].sort());
});
});
});
@@ -0,0 +1,905 @@
/**
* The Paperless-ngx adapter — PAPERLESS-001..095.
*
* Two things are pinned here. What the client puts on the wire: the pinned API
* version (an unpinned Accept header is how the `checksum` field silently moved
* out of the document serializer between two `docker pull`s), the token header,
* the `original=true` on every download, and the one JSON object the custom
* fields have to arrive in. And what the adapter makes of the answers: the
* error code for each way a self-hosted instance disappoints, the newest
* revision's hash rather than the root one, and the document id surviving a
* replace although the consume task names a different one.
*
* `safeFetch` and the capped readers are mocked: the SSRF guard does a real DNS
* lookup and the reader wants a real stream. The readCappedJson stub still
* parses the bytes it is handed, so "Paperless answered an HTML login page"
* stays a genuine parse failure instead of a mock returning undefined on
* command. The behaviour asserted here was measured against a live
* Paperless-ngx 3.1.3 first; see BEFUND-paperless.md in the test lab.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Readable } from 'node:stream';
const {
safeFetchMock,
readCappedJsonMock,
readCappedTextMock,
discardBodyMock,
FakeSsrfBlockedError,
} = vi.hoisted(() => {
// Declared inside the hoisted block: a class at module level is not hoisted
// with the vi.mock factory and would be read before it exists.
class FakeSsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return {
safeFetchMock: vi.fn(),
readCappedJsonMock: vi.fn(),
readCappedTextMock: vi.fn(),
discardBodyMock: vi.fn(),
FakeSsrfBlockedError,
};
});
vi.mock('../../../../src/utils/ssrfGuard', () => ({
safeFetch: safeFetchMock,
SsrfBlockedError: FakeSsrfBlockedError,
}));
vi.mock('../../../../src/utils/cappedFetch', () => ({
readCappedJson: readCappedJsonMock,
readCappedText: readCappedTextMock,
discardBody: discardBodyMock,
}));
import {
PaperlessClient,
PaperlessError,
apiBase,
buildMultipart,
currentVersion,
sha256OrNull,
DOCUMENT_PAGE_SIZE,
} from '../../../../src/nest/doc-sync/providers/paperless.client';
import {
PaperlessDocumentProvider,
PAPERLESS_WEBHOOK_SECRET_HEADER,
TRIP_UID_FIELD_NAME,
documentFileName,
normalizeMime,
parseScopeKey,
titleFromFileName,
} from '../../../../src/nest/doc-sync/providers/paperless.provider';
import { PROVIDER_MAX_PAGES } from '../../../../src/nest/doc-sync/doc-sync.constants';
import type {
DocResult,
DocumentConnectionRef,
DocumentScopeRef,
PushRequest,
} from '../../../../src/nest/doc-sync/document-provider';
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
const TOKEN = 'ba17f0c4d2e94f118a6c0d3b7e5a9c21f4d6b8e0';
const BASE = 'https://papers.example.org';
const CONN: DocumentConnectionRef = {
connectionId: 4,
ownerId: 9,
baseUrl: BASE,
secrets: { api_token: TOKEN },
settings: {},
allowInsecureTls: false,
};
const SCOPE: DocumentScopeRef = {
linkId: 7,
tripId: 42,
scopeKey: 'tag:3',
remoteRootId: '3',
remoteRootPath: '/documents?tags__id__all=3',
cursor: null,
};
const HASH_A = 'a'.repeat(64);
const HASH_B = 'b'.repeat(64);
interface Reply {
ok: boolean;
status: number;
headers: Headers;
body: ReadableStream<Uint8Array> | null;
text(): Promise<string>;
}
interface Recorded {
url: URL;
init: RequestInit;
options?: { rejectUnauthorized?: boolean };
body: string;
}
let calls: Recorded[] = [];
let routes: Map<string, Reply[]>;
function reply(body: unknown, status = 200, headers: Record<string, string> = {}): Reply {
const raw = typeof body === 'string' ? body : JSON.stringify(body);
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
body: null,
text: async () => raw,
};
}
/** A streaming answer, for the download path. */
function streamReply(bytes: string, headers: Record<string, string> = {}): Reply {
return {
ok: true,
status: 200,
headers: new Headers(headers),
body: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(bytes));
controller.close();
},
}),
text: async () => bytes,
};
}
/**
* Stub one route. Several replies are consumed in order and the last one
* repeats, which is what the pagination cases want.
*/
function on(route: string, ...replies: Reply[]): void {
routes.set(route, replies);
}
function requests(route: string): Recorded[] {
return calls.filter((call) => `${(call.init.method ?? 'GET').toUpperCase()} ${call.url.pathname}` === route);
}
function lastRequest(route: string): Recorded {
const matching = requests(route);
const last = matching[matching.length - 1];
if (last === undefined) throw new Error(`no request was made to ${route}`);
return last;
}
function headerOf(call: Recorded, name: string): string | undefined {
const headers = call.init.headers as Record<string, string> | undefined;
return headers?.[name];
}
function tagRow(id: number, name: string, documentCount = 0): unknown {
return { id, name, slug: name, color: '#a6cee3', document_count: documentCount, owner: 2 };
}
function docRow(over: Record<string, unknown> = {}): unknown {
return {
id: 11,
title: 'Hotelrechnung Kyoto',
tags: [3],
mime_type: 'application/pdf',
original_file_name: 'rechnung.pdf',
modified: '2026-09-18T17:30:00.000000+02:00',
added: '2026-09-18T17:29:00.000000+02:00',
deleted_at: null,
custom_fields: [{ field: 1, value: 'trip-uid-42' }],
versions: [{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', is_root: true }],
...over,
};
}
function page(results: unknown[], next: string | null = null, count?: number): unknown {
return { count: count ?? results.length, next, previous: null, results };
}
function taskRow(over: Record<string, unknown> = {}): unknown {
return {
id: 3,
task_id: 'task-uuid-1',
status: 'success',
result_data: { document_id: 11 },
related_document_ids: [11],
...over,
};
}
function pushRequest(over: Partial<PushRequest> = {}): PushRequest {
const bytes = Buffer.from('%PDF-1.4 hotel\n');
return {
body: Readable.from(bytes),
fileName: 'rechnung.pdf',
mimeType: 'application/pdf',
size: bytes.length,
sha256: HASH_A,
mtimeSeconds: 1_789_000_000,
trekDocUid: 'doc-uid-1',
trekTripUid: 'trip-uid-42',
...over,
};
}
// `!result.success` does not narrow: the server workspace compiles with
// `strict: false`, so a boolean discriminant stops discriminating. docFailed is
// the predicate the production code uses for the same reason.
function expectFail(result: DocResult<unknown>): { code: string; detail?: string; status?: number } {
if (!docFailed(result)) throw new Error('expected a failure, got success');
return result.error;
}
function expectOk<T>(result: DocResult<T>): T {
if (docFailed(result)) {
throw new Error(`expected success, got ${result.error.code}: ${result.error.detail ?? ''}`);
}
return result.data;
}
let client: PaperlessClient;
let provider: PaperlessDocumentProvider;
beforeEach(() => {
calls = [];
routes = new Map();
client = new PaperlessClient();
provider = new PaperlessDocumentProvider(client);
safeFetchMock.mockReset();
readCappedJsonMock.mockReset();
readCappedTextMock.mockReset();
discardBodyMock.mockReset();
readCappedJsonMock.mockImplementation(async (res: { text(): Promise<string> }) => {
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
});
readCappedTextMock.mockImplementation(async (res: { text(): Promise<string> }) => ({
text: await res.text(),
truncated: false,
}));
safeFetchMock.mockImplementation(
async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
const parsed = new URL(url);
const method = (init.method ?? 'GET').toUpperCase();
const body = typeof init.body === 'string'
? init.body
: Buffer.isBuffer(init.body)
? init.body.toString('latin1')
: '';
calls.push({ url: parsed, init, options, body });
const queue = routes.get(`${method} ${parsed.pathname}`);
if (queue === undefined || queue.length === 0) {
throw new Error(`no stubbed reply for ${method} ${parsed.pathname}`);
}
return (queue.length > 1 ? queue.shift() : queue[0]) as unknown as Response;
},
);
});
describe('PaperlessClient — the request it builds', () => {
it('PAPERLESS-001: appends /api to whatever the operator pasted', () => {
expect(apiBase(BASE)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/`)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/api`)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/api/`)).toBe(`${BASE}/api`);
expect(apiBase(' https://home.example.org/paperless/ ')).toBe('https://home.example.org/paperless/api');
expect(apiBase('http://192.168.178.9:8000')).toBe('http://192.168.178.9:8000/api');
});
it('PAPERLESS-002: sends the token as a Token header and pins the API version', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
await provider.probe(CONN);
const call = lastRequest('GET /api/ui_settings/');
expect(headerOf(call, 'Authorization')).toBe(`Token ${TOKEN}`);
expect(headerOf(call, 'Accept')).toBe('application/json; version=10');
});
it('PAPERLESS-003: never puts the token in the query string', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
on('GET /api/tags/', reply(page([tagRow(3, 'japan')])));
on('GET /api/documents/', reply(page([docRow()])));
await provider.probe(CONN);
await provider.listScopes(CONN);
await provider.list(CONN, SCOPE);
expect(calls.length).toBeGreaterThanOrEqual(3);
for (const call of calls) {
expect(call.url.search).not.toContain(TOKEN);
expect(call.url.search).not.toMatch(/token|api_key/i);
}
});
it('PAPERLESS-004: maps allowInsecureTls onto rejectUnauthorized and bounds the call', async () => {
on('GET /api/ui_settings/', reply({ user: { username: 'admin' }, settings: {}, permissions: [] }));
await provider.probe(CONN);
await provider.probe({ ...CONN, allowInsecureTls: true });
expect(calls[0]?.options).toEqual({ rejectUnauthorized: true });
expect(calls[1]?.options).toEqual({ rejectUnauthorized: false });
expect(calls[0]?.init.signal).toBeInstanceOf(AbortSignal);
});
it('PAPERLESS-005: reads the profile that does not echo the token back', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
settings: { version: '3.1.3' },
permissions: ['add_workflow', 'add_document'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.account).toBe('admin');
expect(requests('GET /api/profile/')).toHaveLength(0);
});
});
describe('PaperlessProvider — probing a connection', () => {
it('PAPERLESS-010: reports the self-registering webhook when the token may create workflows', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
settings: { version: '3.1.3' },
permissions: ['add_workflow'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.capabilities.push).toBe('webhook-self-registered');
expect(probed.capabilities.stableId).toBe(true);
expect(probed.capabilities.contentHashInListing).toBe(true);
expect(probed.capabilities.replaceInPlace).toBe(true);
expect(probed.capabilities.remoteTrash).toBe(true);
});
it('PAPERLESS-011: downgrades to a manual webhook when the token may not create one', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'reisender' },
settings: { version: '3.1.3' },
permissions: ['view_document', 'add_document'],
}));
const probed = expectOk(await provider.probe(CONN));
expect(probed.capabilities.push).toBe('webhook-manual');
// The capability the CORE reads stays the provider's own answer; only the
// probe reflects this token's narrower rights.
expect(provider.capabilities(CONN).push).toBe('webhook-self-registered');
});
it('PAPERLESS-012: a wrong token is unauthorized, a forbidden one is forbidden', async () => {
on('GET /api/ui_settings/', reply({ detail: 'Invalid token.' }, 401));
expect(expectFail(await provider.probe(CONN)).code).toBe('unauthorized');
on('GET /api/ui_settings/', reply({ detail: 'no' }, 403));
expect(expectFail(await provider.probe(CONN)).code).toBe('forbidden');
});
it('PAPERLESS-013: an HTML login page is a provider error, not a crash', async () => {
on('GET /api/ui_settings/', reply('<!doctype html><title>Sign in</title>'));
const error = expectFail(await provider.probe(CONN));
expect(error.code).toBe('provider_error');
expect(error.detail).toContain('Paperless itself');
});
it('PAPERLESS-014: a connection without a token never reaches the network', async () => {
const error = expectFail(await provider.probe({ ...CONN, secrets: {} }));
expect(error.code).toBe('unauthorized');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-015: the SSRF guard, a timeout and a self-signed certificate each get their own code', async () => {
safeFetchMock.mockImplementationOnce(async () => {
throw new FakeSsrfBlockedError('Requests to loopback and link-local addresses are not allowed');
});
expect(expectFail(await provider.probe(CONN)).code).toBe('ssrf_blocked');
safeFetchMock.mockImplementationOnce(async () => {
const err = new Error('The operation was aborted due to timeout');
err.name = 'TimeoutError';
throw err;
});
expect(expectFail(await provider.probe(CONN)).code).toBe('timeout');
safeFetchMock.mockImplementationOnce(async () => {
const cause = Object.assign(new Error('self-signed certificate'), {
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
});
throw Object.assign(new TypeError('fetch failed'), { cause });
});
expect(expectFail(await provider.probe(CONN)).code).toBe('tls_untrusted');
safeFetchMock.mockImplementationOnce(async () => {
throw Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }),
});
});
expect(expectFail(await provider.probe(CONN)).code).toBe('unreachable');
});
});
describe('PaperlessProvider — scopes are tags', () => {
it('PAPERLESS-020: a tag becomes a scope key, a label and the instance filter URL', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026', 4), tagRow(9, 'privat')])));
const options = expectOk(await provider.listScopes(CONN));
expect(options).toEqual([
{ scopeKey: 'tag:3', label: 'japan-2026', remoteRootId: '3', remoteRootPath: '/documents?tags__id__all=3' },
{ scopeKey: 'tag:9', label: 'privat', remoteRootId: '9', remoteRootPath: '/documents?tags__id__all=9' },
]);
});
it('PAPERLESS-021: follows the pages of a long tag list', async () => {
on(
'GET /api/tags/',
reply(page([tagRow(1, 'a')], 'https://papers.example.org/api/tags/?page=2', 2)),
reply(page([tagRow(2, 'b')], null, 2)),
);
const options = expectOk(await provider.listScopes(CONN));
expect(options.map((option) => option.scopeKey)).toEqual(['tag:1', 'tag:2']);
expect(requests('GET /api/tags/')).toHaveLength(2);
expect(requests('GET /api/tags/')[1]?.url.searchParams.get('page')).toBe('2');
});
it('PAPERLESS-022: a search narrows server-side, an empty one is not sent at all', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026')])));
await provider.listScopes(CONN, ' japan ');
await provider.listScopes(CONN, ' ');
expect(requests('GET /api/tags/')[0]?.url.searchParams.get('name__icontains')).toBe('japan');
expect(requests('GET /api/tags/')[1]?.url.searchParams.has('name__icontains')).toBe(false);
});
it('PAPERLESS-023: creating a tag that exists is a conflict, never a silent adoption', async () => {
on('POST /api/tags/', reply({ error: 'Object violates owner / name unique constraint' }, 400));
expect(expectFail(await provider.createScope(CONN, 'japan-2026')).code).toBe('conflict');
});
it('PAPERLESS-024: a tag that is gone is scope_missing, not a bare 404', async () => {
on('GET /api/tags/3/', reply({ detail: 'No Tag matches the given query.' }, 404));
const error = expectFail(await provider.resolveScope(CONN, SCOPE));
expect(error.code).toBe('scope_missing');
expect(error.status).toBe(404);
});
it('PAPERLESS-025: another provider’s scope key is refused before any request', async () => {
for (const scopeKey of ['path:/trek', 'tag:', 'tag:abc', 'tag:-1', 'fileid:437']) {
const error = expectFail(await provider.resolveScope(CONN, { ...SCOPE, scopeKey }));
expect(error.code).toBe('scope_missing');
}
expect(calls).toHaveLength(0);
expect(parseScopeKey('tag:12')).toBe(12);
});
});
describe('PaperlessProvider — listing a scope', () => {
it('PAPERLESS-030: maps a document onto the core’s shape', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents).toEqual([
{
remoteId: '11',
name: 'Hotelrechnung Kyoto.pdf',
size: null,
mimeType: 'application/pdf',
remoteVersion: '2026-09-18T17:30:00.000000+02:00',
contentHash: HASH_A,
remoteModifiedAt: '2026-09-18T17:30:00.000000+02:00',
isDeleted: false,
},
]);
const call = lastRequest('GET /api/documents/');
expect(call.url.searchParams.get('tags__id__all')).toBe('3');
expect(call.url.searchParams.get('ordering')).toBe('id');
expect(call.url.searchParams.get('page_size')).toBe(String(DOCUMENT_PAGE_SIZE));
});
it('PAPERLESS-031: a checksum that is not sha256 is reported as no hash at all', async () => {
on('GET /api/documents/', reply(page([
docRow({ versions: [{ id: 1, checksum: 'a91fe3f8e2cf4b2b5560b2222d65d60c', added: null, is_root: true }] }),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents[0]?.contentHash).toBeNull();
expect(sha256OrNull('A'.repeat(64))).toBe('a'.repeat(64));
expect(sha256OrNull('zz')).toBeNull();
});
it('PAPERLESS-032: after a replace the hash is the newest revision, not the root one', async () => {
on('GET /api/documents/', reply(page([
docRow({
versions: [
{ id: 12, checksum: HASH_B, added: '2026-09-18T15:40:00.000000Z', is_root: false },
{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', is_root: true },
],
}),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents[0]?.contentHash).toBe(HASH_B);
// Independent of the order the instance happens to send them in.
expect(currentVersion({
id: 1,
title: '',
originalFileName: null,
mimeType: null,
modified: null,
added: null,
deletedAt: null,
tagIds: [],
versions: [
{ id: 11, checksum: HASH_A, added: '2026-09-18T15:29:00.000000Z', isRoot: true },
{ id: 12, checksum: HASH_B, added: '2026-09-18T15:40:00.000000Z', isRoot: false },
],
})?.checksum).toBe(HASH_B);
});
it('PAPERLESS-033: a listing that hits the page cap says so and claims nothing about the cursor', async () => {
const endless = reply(page([docRow()], 'https://papers.example.org/api/documents/?page=2', 9999));
on('GET /api/documents/', endless);
const listed = expectOk(await provider.list(CONN, { ...SCOPE, cursor: 'v1:9999:x' }));
expect(listed.truncated).toBe(true);
expect(listed.cursorUnchanged).toBe(false);
expect(requests('GET /api/documents/')).toHaveLength(PROVIDER_MAX_PAGES);
});
it('PAPERLESS-034: an unchanged scope is recognised, a changed one is not', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const first = expectOk(await provider.list(CONN, SCOPE));
expect(first.cursorUnchanged).toBe(false);
const second = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(second.cursorUnchanged).toBe(true);
// A document leaving the tag moves no timestamp — only the count notices.
on('GET /api/documents/', reply(page([], null, 0)));
const third = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(third.cursorUnchanged).toBe(false);
expect(third.documents).toHaveLength(0);
});
it('PAPERLESS-035: a document without a title still gets a usable name', async () => {
on('GET /api/documents/', reply(page([
docRow({ id: 5, title: '', original_file_name: 'scan_2026.PDF' }),
docRow({ id: 6, title: '', original_file_name: null, mime_type: 'image/png' }),
docRow({ id: 7, title: 'Bordkarte.pdf', original_file_name: 'bordkarte.pdf' }),
])));
const listed = expectOk(await provider.list(CONN, SCOPE));
expect(listed.documents.map((doc) => doc.name)).toEqual([
'scan_2026.PDF',
'document-6.png',
// An extension the title already carries is not doubled.
'Bordkarte.pdf',
]);
expect(documentFileName({
id: 8,
title: 'Notiz',
originalFileName: null,
mimeType: 'text/plain',
modified: null,
added: null,
deletedAt: null,
tagIds: [],
versions: [],
})).toBe('Notiz.txt');
});
it('PAPERLESS-036: an answer that is not a listing is a provider error', async () => {
on('GET /api/documents/', reply({ unexpected: true }));
expect(expectFail(await provider.list(CONN, SCOPE)).code).toBe('provider_error');
});
});
describe('PaperlessProvider — fetching bytes', () => {
it('PAPERLESS-040: asks for the original file and hands back the stream with its version', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel', {
'content-length': '14',
'content-type': 'application/pdf',
}));
const fetched = expectOk(await provider.fetch(CONN, SCOPE, '11'));
expect(lastRequest('GET /api/documents/11/download/').url.searchParams.get('original')).toBe('true');
expect(fetched.size).toBe(14);
expect(fetched.mimeType).toBe('application/pdf');
expect(fetched.remoteVersion).toBe('2026-09-18T17:30:00.000000+02:00');
const chunks: Buffer[] = [];
for await (const chunk of fetched.body) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe('%PDF-1.4 hotel');
});
it('PAPERLESS-041: a missing content-length is an unknown size, never zero', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel'));
const fetched = expectOk(await provider.fetch(CONN, SCOPE, '11'));
expect(fetched.size).toBeNull();
});
it('PAPERLESS-042: a deleted document is not_found, and a nonsense id never leaves the process', async () => {
on('GET /api/documents/11/', reply({ detail: 'No Document matches the given query.' }, 404));
expect(expectFail(await provider.fetch(CONN, SCOPE, '11')).code).toBe('not_found');
const before = calls.length;
expect(expectFail(await provider.fetch(CONN, SCOPE, '../../etc/passwd')).code).toBe('not_found');
expect(calls).toHaveLength(before);
});
});
describe('PaperlessProvider — pushing bytes', () => {
function stubSuccessfulCreate(): void {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
}
it('PAPERLESS-050: an unaccepted type is refused before a single byte is uploaded', async () => {
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({
fileName: 'tour.gpx',
mimeType: 'application/gpx+xml',
})));
expect(error.code).toBe('unsupported_type');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-050b: a stored Content-Type with parameters is still the type it names', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow({ mime_type: 'text/plain' })));
expectOk(await provider.push(CONN, SCOPE, pushRequest({
fileName: 'notiz.txt',
mimeType: 'Text/Plain; charset=utf-8',
})));
expect(lastRequest('POST /api/documents/post_document/').body).toContain('Content-Type: text/plain\r\n');
expect(normalizeMime('application/pdf')).toBe('application/pdf');
});
it('PAPERLESS-051: an upload carries the tag, the title and the custom field in one request', async () => {
stubSuccessfulCreate();
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(pushed).toEqual({
remoteId: '11',
remoteVersion: '2026-09-18T17:30:00.000000+02:00',
remoteModifiedAt: '2026-09-18T17:30:00.000000+02:00',
// Paperless stores duplicate bytes twice, so nothing here may claim a
// deduplication happened.
deduplicated: false,
});
const upload = lastRequest('POST /api/documents/post_document/');
expect(headerOf(upload, 'Content-Type')).toMatch(/^multipart\/form-data; boundary=/);
// Content-Length is undici's business; setting it makes it refuse the call.
expect(headerOf(upload, 'Content-Length')).toBeUndefined();
expect(upload.body).toContain('name="tags"\r\n\r\n3');
expect(upload.body).toContain('name="title"\r\n\r\nrechnung');
expect(upload.body).toContain('name="custom_fields"\r\n\r\n{"1":"trip-uid-42"}');
expect(upload.body).toContain('filename="rechnung.pdf"');
expect(upload.body).toContain('%PDF-1.4 hotel');
});
it('PAPERLESS-052: the trip anchor field is created when the instance has none', async () => {
on('GET /api/custom_fields/', reply(page([])));
on('POST /api/custom_fields/', reply({ id: 4, name: TRIP_UID_FIELD_NAME, data_type: 'string' }));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(lastRequest('POST /api/custom_fields/').body).toContain(TRIP_UID_FIELD_NAME);
expect(lastRequest('POST /api/documents/post_document/').body).toContain('{"4":"trip-uid-42"}');
});
it('PAPERLESS-053: a custom field list from a build that ignores the filter is not trusted', async () => {
on('GET /api/custom_fields/', reply(page([
{ id: 8, name: 'rechnungsnummer', data_type: 'string' },
{ id: 9, name: 'TREK_TRIP_ID', data_type: 'string' },
])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow()));
expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(lastRequest('POST /api/documents/post_document/').body).toContain('{"9":"trip-uid-42"}');
expect(requests('POST /api/custom_fields/')).toHaveLength(0);
});
it('PAPERLESS-054: a consume that fails is a provider error, a duplicate one a conflict', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({
status: 'failure',
related_document_ids: [],
result_data: { error_type: 'ConsumerError', error_message: 'kaputt.pdf: InputFileError' },
})])));
const failed = expectFail(await provider.push(CONN, SCOPE, pushRequest()));
expect(failed.code).toBe('provider_error');
expect(failed.detail).toContain('InputFileError');
on('GET /api/tasks/', reply(page([taskRow({
status: 'FAILURE',
related_document_ids: [],
result_data: { error_message: 'It is a duplicate of Rechnung (#4)' },
})])));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('conflict');
});
it('PAPERLESS-055: the task filter is not trusted either — a foreign task is ignored', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'someone-elses-task', status: 'success' })])));
vi.spyOn(client, 'awaitConsume');
// The only way out is the poll budget, so the client is asked directly with
// a short one rather than holding the test for two minutes.
const creds = { baseUrl: BASE, token: TOKEN, allowInsecureTls: false };
await expect(
client.awaitConsume(creds, 'task-uuid-1', { intervalMs: 1, timeoutMs: 5 }),
).rejects.toMatchObject({ code: 'timeout' });
});
it('PAPERLESS-056: an upload whose task outlives the budget is found again by its checksum', async () => {
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
// One document with the same bytes in another tag, one in ours: the tagged
// one is the upload we just made.
on('GET /api/documents/', reply(page([
docRow({ id: 21, tags: [99] }),
docRow({ id: 11, tags: [3] }),
])));
on('GET /api/documents/11/', reply(docRow()));
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest()));
expect(pushed.remoteId).toBe('11');
expect(lastRequest('GET /api/documents/').url.searchParams.get('checksum__iexact')).toBe(HASH_A);
});
it('PAPERLESS-057: when the checksum finds nothing either, the timeout is reported', async () => {
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/documents/', reply(page([])));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('timeout');
});
it('PAPERLESS-058: a replace keeps the id it was given, not the one the task names', async () => {
on('POST /api/documents/11/update_version/', reply('"task-uuid-2"'));
// update_version files the new revision under a fresh internal id that
// answers 404 on its own. Adopting it would break the mapping.
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'task-uuid-2', related_document_ids: [77] })])));
on('GET /api/documents/11/', reply(docRow({
modified: '2026-09-18T18:00:00.000000+02:00',
versions: [
{ id: 12, checksum: HASH_A, added: '2026-09-18T16:00:00.000000Z', is_root: false },
{ id: 11, checksum: HASH_B, added: '2026-09-18T15:29:00.000000Z', is_root: true },
],
})));
const pushed = expectOk(await provider.push(CONN, SCOPE, pushRequest({ remoteId: '11' })));
expect(pushed.remoteId).toBe('11');
expect(pushed.remoteVersion).toBe('2026-09-18T18:00:00.000000+02:00');
expect(requests('POST /api/documents/post_document/')).toHaveLength(0);
expect(requests('GET /api/documents/77/')).toHaveLength(0);
});
it('PAPERLESS-059: a file larger than it claimed is refused without being uploaded', async () => {
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ size: 4 })));
expect(error.code).toBe('too_large');
expect(requests('POST /api/documents/post_document/')).toHaveLength(0);
});
it('PAPERLESS-060: a type the instance refuses comes back as the same code as the local check', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply({ document: ['File type text/xml not supported'] }, 400));
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('unsupported_type');
});
it('PAPERLESS-061: bytes that arrived differently are reported and the new document is trashed', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow()])));
on('GET /api/documents/11/', reply(docRow({
versions: [{ id: 11, checksum: HASH_B, added: '2026-09-18T15:29:00.000000Z', is_root: true }],
})));
on('DELETE /api/documents/11/', reply('', 204));
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ sha256: HASH_A })));
expect(error.code).toBe('checksum_mismatch');
expect(requests('DELETE /api/documents/11/')).toHaveLength(1);
});
it('PAPERLESS-062: a mismatch after a replace leaves the existing document alone', async () => {
on('POST /api/documents/11/update_version/', reply('"task-uuid-2"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'task-uuid-2' })])));
on('GET /api/documents/11/', reply(docRow({
versions: [{ id: 12, checksum: HASH_B, added: '2026-09-18T16:00:00.000000Z', is_root: false }],
})));
const error = expectFail(await provider.push(CONN, SCOPE, pushRequest({ remoteId: '11', sha256: HASH_A })));
expect(error.code).toBe('checksum_mismatch');
expect(requests('DELETE /api/documents/11/')).toHaveLength(0);
});
});
describe('PaperlessProvider — renaming and trashing', () => {
it('PAPERLESS-070: renaming sets the title without the extension it would add back', async () => {
on('PATCH /api/documents/11/', reply(docRow({ modified: '2026-09-18T19:00:00.000000+02:00' })));
const renamed = expectOk(await provider.rename(CONN, SCOPE, '11', ' Hotelrechnung Kyoto.pdf '));
expect(lastRequest('PATCH /api/documents/11/').body).toBe('{"title":"Hotelrechnung Kyoto"}');
expect(renamed.remoteVersion).toBe('2026-09-18T19:00:00.000000+02:00');
// An extension this adapter would never have added stays part of the name.
expect(titleFromFileName('Vertrag 2026.2')).toBe('Vertrag 2026.2');
expect(titleFromFileName('bordkarte.PDF')).toBe('bordkarte');
});
it('PAPERLESS-071: renaming a document that is gone is not_found', async () => {
on('PATCH /api/documents/11/', reply({ detail: 'No Document matches the given query.' }, 404));
expect(expectFail(await provider.rename(CONN, SCOPE, '11', 'neu.pdf')).code).toBe('not_found');
});
it('PAPERLESS-080: trashing is a soft delete, and doing it twice is still success', async () => {
on('DELETE /api/documents/11/', reply('', 204), reply({ detail: 'No Document matches the given query.' }, 404));
expectOk(await provider.trash(CONN, SCOPE, '11'));
expectOk(await provider.trash(CONN, SCOPE, '11'));
expect(requests('DELETE /api/documents/11/')).toHaveLength(2);
});
});
describe('PaperlessProvider — the self-registered webhook', () => {
it('PAPERLESS-090: subscribes both triggers, filtered on the tag, with the secret in a header', async () => {
on('POST /api/workflows/', reply({ id: 6 }, 201));
const registered = expectOk(
await provider.registerWebhook(CONN, SCOPE, 'https://trek.example.org/api/docsync/hook/7', 'sh4red'),
);
expect(registered.subscriptionId).toBe('6');
const sent = JSON.parse(lastRequest('POST /api/workflows/').body) as {
triggers: Array<{ type: number; filter_has_tags: number[]; sources: number[] }>;
actions: Array<{ type: number; webhook: Record<string, unknown> }>;
};
expect(sent.triggers.map((trigger) => trigger.type)).toEqual([2, 3]);
for (const trigger of sent.triggers) {
expect(trigger.filter_has_tags).toEqual([3]);
// Including the web UI, or a document dropped in by hand fires nothing.
expect(trigger.sources).toEqual([1, 2, 3, 4]);
}
expect(sent.actions[0]?.type).toBe(4);
expect(sent.actions[0]?.webhook).toMatchObject({
url: 'https://trek.example.org/api/docsync/hook/7',
use_params: true,
as_json: true,
include_document: false,
params: { linkId: '7', tripId: '42' },
headers: { [PAPERLESS_WEBHOOK_SECRET_HEADER]: 'sh4red' },
});
// The secret travels in a header, never in the URL or the body.
expect(lastRequest('POST /api/workflows/').url.search).not.toContain('sh4red');
expect(JSON.stringify(sent.actions[0]?.webhook.params)).not.toContain('sh4red');
});
it('PAPERLESS-091: a callback URL Paperless cannot store is refused here, not upstream', async () => {
const long = `https://trek.example.org/api/docsync/hook/${'x'.repeat(240)}`;
const error = expectFail(await provider.registerWebhook(CONN, SCOPE, long, 's'));
expect(error.code).toBe('provider_error');
expect(error.detail).toContain('limit 256');
expect(calls).toHaveLength(0);
});
it('PAPERLESS-092: unsubscribing twice is success, and a nonsense id never leaves the process', async () => {
on('DELETE /api/workflows/6/', reply('', 204), reply({ detail: 'Not found.' }, 404));
expectOk(await provider.unregisterWebhook(CONN, '6'));
expectOk(await provider.unregisterWebhook(CONN, '6'));
const before = calls.length;
expect(expectFail(await provider.unregisterWebhook(CONN, 'workflow-6')).code).toBe('not_found');
expect(calls).toHaveLength(before);
});
});
describe('the multipart body', () => {
it('PAPERLESS-095: a file name cannot break out of the Content-Disposition header', () => {
const { body, contentType } = buildMultipart(
[{ name: 'tags', value: '3' }],
{
fileName: 'a";\r\nX-Injected: 1\r\n\r\nevil.pdf',
mimeType: 'application/pdf',
bytes: Buffer.from('bytes'),
},
);
const text = body.toString('latin1');
expect(text).not.toContain('X-Injected: 1\r\n');
// Quote, CR and LF each become an underscore; everything else survives, so
// the name stays recognisable in the archive.
expect(text).toContain('filename="a_;__X-Injected: 1____evil.pdf"');
const boundary = /boundary=(.+)$/.exec(contentType)?.[1] ?? '';
expect(boundary.length).toBeGreaterThan(16);
expect(text.endsWith(`--${boundary}--\r\n`)).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,881 @@
/**
* The WebDAV adapter for Nextcloud and OpenCloud: what it puts on the wire, and
* what it makes of what comes back.
*
* The cases here are the ones that cost real debugging against the two
* products, so they are pinned rather than described: Nextcloud's `OC-FileId`
* header is not its `oc:fileid` and a naive adapter re-uploads every file
* forever; `OC-Checksum: SHA256` is fine on Nextcloud and a hard 400 on
* OpenCloud; a PROPFIND answers one propstat block per status and the 404 block
* is not data; an OpenCloud drive describes its own WebDAV URL on a host the
* admin never entered.
*
* `safeFetch` and the capped readers are mocked, for the reason
* dawarich.client.test.ts gives: the SSRF guard does a real DNS lookup and the
* readers want a real stream. The responses are genuine `Response` objects, so
* headers, status and body behave as they do in production.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
vi.mock('../../../../src/utils/ssrfGuard', () => ({
safeFetch: safeFetchMock,
SsrfBlockedError: class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
},
}));
vi.mock('../../../../src/utils/cappedFetch', () => ({
readCappedText: async (res: Response) => {
const text = await res.text();
return { text: text.slice(0, capBytes), truncated: text.length > capBytes };
},
readCappedJson: async (res: Response) => {
try {
return JSON.parse(await res.text()) as unknown;
} catch {
return undefined;
}
},
}));
import { Readable } from 'node:stream';
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
import {
NextcloudDocumentProvider,
OpencloudDocumentProvider,
flavorOf,
} from '../../../../src/nest/doc-sync/providers/webdav.provider';
import { WebdavClient } from '../../../../src/nest/doc-sync/providers/webdav.client';
import type {
DocumentConnectionRef,
DocumentScopeRef,
PushRequest,
} from '../../../../src/nest/doc-sync/document-provider';
/** Raised by the truncation test only; everything else stays under the cap. */
let capBytes = 1_000_000;
const NC_ORIGIN = 'https://cloud.example.org';
const OC_ORIGIN = 'https://opencloud.example.org';
const NC_ROOT = '/remote.php/dav/files/admin';
const OC_DRIVE = 'storage-1$space-9';
const OC_ROOT = `/dav/spaces/${OC_DRIVE}`;
const ncConn: DocumentConnectionRef = {
connectionId: 7,
ownerId: 3,
baseUrl: NC_ORIGIN,
secrets: { app_password: 'app-pw' },
settings: { login_name: 'admin', base_path: '/TREK' },
allowInsecureTls: false,
};
const ocConn: DocumentConnectionRef = {
connectionId: 8,
ownerId: 3,
baseUrl: OC_ORIGIN,
secrets: { app_token: 'app-token' },
settings: { username: 'admin' },
allowInsecureTls: true,
};
const ncScope: DocumentScopeRef = {
linkId: 1,
tripId: 42,
scopeKey: 'fileid:60',
remoteRootId: '60',
remoteRootPath: '/TREK/trip 42',
cursor: null,
};
const ocScope: DocumentScopeRef = {
linkId: 2,
tripId: 42,
scopeKey: `drive:${OC_DRIVE}`,
remoteRootId: OC_DRIVE,
remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`,
cursor: null,
};
interface Recorded {
url: string;
method: string;
headers: Record<string, string>;
body: string | undefined;
rejectUnauthorized: boolean | undefined;
}
let calls: Recorded[] = [];
/** `METHOD /path` → the answer, or a function of the request. */
type Answer = Response | ((req: Recorded) => Response);
let routes: Map<string, Answer[]>;
function route(key: string, ...answers: Answer[]): void {
routes.set(key, answers);
}
function reply(body: string | null, init: ResponseInit = {}): Response {
return new Response(body, init);
}
/** A PROPFIND answer built from the props each entry declares. */
function multistatus(
entries: Array<{
href: string;
collection?: boolean;
etag?: string;
fileid?: string;
size?: number;
type?: string;
modified?: string;
checksums?: string;
}>,
): Response {
const body = entries
.map((entry) => {
const found: string[] = [];
const missing: string[] = [];
found.push(`<d:getetag>${entry.etag ?? '"etag"'}</d:getetag>`);
found.push(`<d:getlastmodified>${entry.modified ?? 'Fri, 18 Sep 2026 15:15:54 GMT'}</d:getlastmodified>`);
found.push(entry.collection ? '<d:resourcetype><d:collection/></d:resourcetype>' : '<d:resourcetype/>');
if (entry.fileid) found.push(`<oc:fileid>${entry.fileid}</oc:fileid>`);
if (entry.size === undefined) missing.push('<d:getcontentlength/>');
else found.push(`<d:getcontentlength>${entry.size}</d:getcontentlength>`);
if (entry.type === undefined) missing.push('<d:getcontenttype/>');
else found.push(`<d:getcontenttype>${entry.type}</d:getcontenttype>`);
if (entry.checksums === undefined) missing.push('<oc:checksums/>');
else found.push(`<oc:checksums><oc:checksum>${entry.checksums}</oc:checksum></oc:checksums>`);
const blocks = [`<d:propstat><d:prop>${found.join('')}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`];
if (missing.length > 0) {
blocks.push(
`<d:propstat><d:prop>${missing.join('')}</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>`,
);
}
return `<d:response><d:href>${entry.href}</d:href>${blocks.join('')}</d:response>`;
})
.join('');
return reply(
`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">${body}</d:multistatus>`,
{ status: 207, headers: { 'content-type': 'application/xml' } },
);
}
function drive(id: string, name: string, driveType: string, webDavUrl: string): unknown {
return { id, name, driveType, root: { id, webDavUrl } };
}
function requests(method?: string): Recorded[] {
return method ? calls.filter((call) => call.method === method) : calls;
}
function pathOf(call: Recorded): string {
return new URL(call.url).pathname;
}
function pushRequest(overrides: Partial<PushRequest> = {}): PushRequest {
return {
body: Readable.from([Buffer.from('bytes')]),
fileName: 'reiseplan.pdf',
mimeType: 'application/pdf',
size: 5,
sha256: 'a'.repeat(64),
mtimeSeconds: 1_700_000_000,
trekDocUid: 'doc-1',
trekTripUid: 'trip-1',
...overrides,
};
}
let nextcloud: NextcloudDocumentProvider;
let opencloud: OpencloudDocumentProvider;
beforeEach(() => {
capBytes = 1_000_000;
calls = [];
routes = new Map();
const client = new WebdavClient();
nextcloud = new NextcloudDocumentProvider(client);
opencloud = new OpencloudDocumentProvider(client);
safeFetchMock.mockImplementation(
async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
const headers: Record<string, string> = {};
new Headers(init.headers).forEach((value, key) => {
headers[key] = value;
});
const record: Recorded = {
url,
method: init.method ?? 'GET',
headers,
body: typeof init.body === 'string' ? init.body : undefined,
rejectUnauthorized: options?.rejectUnauthorized,
};
calls.push(record);
const key = `${record.method} ${new URL(url).pathname}`;
const queued = routes.get(key);
if (!queued || queued.length === 0) {
return reply('<d:error/>', { status: 404 });
}
const answer = queued.length > 1 ? queued.shift() : queued[0];
if (!answer) return reply(null, { status: 404 });
return typeof answer === 'function' ? answer(record) : answer.clone();
},
);
});
describe('flavour detection', () => {
it('reads the product off the connection rather than off the registered id', () => {
expect(flavorOf(ncConn)).toBe('nextcloud');
expect(flavorOf(ocConn)).toBe('opencloud');
expect(flavorOf({ ...ncConn, settings: {}, secrets: {} })).toBeNull();
});
it('answers the same capabilities whichever class asks, because the connection decides', () => {
expect(nextcloud.capabilities(ocConn).push).toBe('none');
expect(opencloud.capabilities(ncConn).push).toBe('webhook-manual');
});
it('never claims a content hash it cannot stand behind', () => {
const caps = nextcloud.capabilities(ncConn);
expect(caps.contentHashInListing).toBe(false);
expect(caps.stableId).toBe(true);
expect(caps.remoteTrash).toBe(true);
expect(caps.replaceInPlace).toBe(true);
});
});
describe('probe', () => {
it('checks the WebDAV home and upgrades push when the account may manage webhooks', async () => {
route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }]));
route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{"ocs":{"data":[]}}'));
const result = await nextcloud.probe(ncConn);
expect(result).toMatchObject({ success: true, data: { account: 'admin' } });
expect(result.success && result.data.capabilities.push).toBe('webhook-self-registered');
expect(calls[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:app-pw').toString('base64')}`);
});
it('keeps push at webhook-manual when the webhook endpoint is closed to this account', async () => {
route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }]));
route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 }));
const result = await nextcloud.probe(ncConn);
expect(result.success && result.data.capabilities.push).toBe('webhook-manual');
});
it('separates a wrong login name from a wrong password', async () => {
route(`PROPFIND ${NC_ROOT}`, reply('<d:error/>', { status: 404 }));
const missing = await nextcloud.probe(ncConn);
expect(missing).toMatchObject({ success: false, error: { code: 'not_found' } });
expect(missing.success === false && missing.error.detail).toContain('admin');
calls = [];
route(`PROPFIND ${NC_ROOT}`, reply('<d:error/>', { status: 401 }));
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unauthorized' } });
});
it('names the OpenCloud account from Graph and passes the TLS opt-in through', async () => {
route('GET /graph/v1.0/me', reply('{"onPremisesSamAccountName":"admin","displayName":"Admin"}'));
const result = await opencloud.probe(ocConn);
expect(result).toMatchObject({ success: true, data: { account: 'admin' } });
expect(result.success && result.data.capabilities.push).toBe('none');
expect(calls[0].rejectUnauthorized).toBe(false);
});
it('refuses a half-filled connection without going near the network', async () => {
const result = await nextcloud.probe({ ...ncConn, secrets: {}, settings: { login_name: 'admin' } });
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(calls).toHaveLength(0);
});
});
describe('scopes', () => {
it('offers the folders under the base path and keys them by oc:fileid', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK`,
multistatus([
{ href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' },
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60' },
{ href: `${NC_ROOT}/TREK/notes.txt`, fileid: '61', size: 3, type: 'text/plain' },
]),
);
const result = await nextcloud.listScopes(ncConn);
expect(result).toEqual({
success: true,
data: [{ scopeKey: 'fileid:60', label: 'trip 42', remoteRootId: '60', remoteRootPath: '/TREK/trip 42' }],
});
});
it('answers an empty picker rather than an error when the base folder does not exist yet', async () => {
route(`PROPFIND ${NC_ROOT}/TREK`, reply('<d:error/>', { status: 404 }));
expect(await nextcloud.listScopes(ncConn)).toEqual({ success: true, data: [] });
});
it('filters the picker by the query', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK`,
multistatus([
{ href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' },
{ href: `${NC_ROOT}/TREK/Japan/`, collection: true, fileid: '60' },
{ href: `${NC_ROOT}/TREK/Peru/`, collection: true, fileid: '61' },
]),
);
const result = await nextcloud.listScopes(ncConn, 'jap');
expect(result.success && result.data.map((option) => option.label)).toEqual(['Japan']);
});
it('re-bases an OpenCloud space on the entered origin and drops the virtual share drive', async () => {
route(
'GET /graph/v1.0/me/drives',
reply(
JSON.stringify({
value: [
drive('shares$1', 'Shares', 'virtual', 'https://internal.lan:9200/dav/spaces/shares$1'),
drive(OC_DRIVE, 'TREK Trip 42', 'project', `https://internal.lan:9200${OC_ROOT}`),
],
}),
),
);
const result = await opencloud.listScopes(ocConn);
expect(result).toEqual({
success: true,
data: [
{
scopeKey: `drive:${OC_DRIVE}`,
label: 'TREK Trip 42',
remoteRootId: OC_DRIVE,
remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`,
},
],
});
});
it('creates the base folder when it is missing and reads the new id back', async () => {
route(
`MKCOL ${NC_ROOT}/TREK/Japan%202026`,
reply(null, { status: 409 }),
reply(null, { status: 201 }),
);
route(`MKCOL ${NC_ROOT}/TREK`, reply(null, { status: 201 }));
route(
`PROPFIND ${NC_ROOT}/TREK/Japan%202026`,
multistatus([{ href: `${NC_ROOT}/TREK/Japan%202026/`, collection: true, fileid: '73' }]),
);
const result = await nextcloud.createScope(ncConn, 'Japan 2026');
expect(result).toMatchObject({ success: true, data: { scopeKey: 'fileid:73', remoteRootPath: '/TREK/Japan 2026' } });
expect(requests('MKCOL').map(pathOf)).toEqual([
`${NC_ROOT}/TREK/Japan%202026`,
`${NC_ROOT}/TREK`,
`${NC_ROOT}/TREK/Japan%202026`,
]);
});
it('refuses to hand back a folder somebody else already made', async () => {
route(`MKCOL ${NC_ROOT}/TREK/Japan`, reply(null, { status: 405 }));
expect(await nextcloud.createScope(ncConn, 'Japan')).toMatchObject({
success: false,
error: { code: 'conflict' },
});
});
it('replaces the characters Nextcloud refuses instead of failing the whole folder', async () => {
route(`MKCOL ${NC_ROOT}/TREK/Japan-%202026`, reply(null, { status: 201 }));
route(
`PROPFIND ${NC_ROOT}/TREK/Japan-%202026`,
multistatus([{ href: `${NC_ROOT}/TREK/Japan-%202026/`, collection: true, fileid: '74' }]),
);
const result = await nextcloud.createScope(ncConn, 'Japan: 2026');
expect(result.success && result.data.label).toBe('Japan- 2026');
});
it('creates an OpenCloud space as a project drive', async () => {
route(
'POST /graph/v1.0/drives',
reply(JSON.stringify(drive('storage-1$new', 'Japan', 'project', `https://internal.lan:9200/dav/spaces/storage-1$new`)), {
status: 201,
}),
);
const result = await opencloud.createScope(ocConn, 'Japan');
expect(result).toMatchObject({ success: true, data: { scopeKey: 'drive:storage-1$new' } });
expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ name: 'Japan', driveType: 'project' });
});
it('calls a folder with the same path but a different id scope_missing', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '99' }]),
);
const result = await nextcloud.resolveScope(ncConn, ncScope);
expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } });
expect(result.success === false && result.error.detail).toContain('99');
});
it('reports a deleted space as scope_missing rather than as a broken connection', async () => {
route(`GET /graph/v1.0/drives/${encodeURIComponent(OC_DRIVE)}`, reply('{"error":{}}', { status: 404 }));
expect(await opencloud.resolveScope(ocConn, ocScope)).toMatchObject({
success: false,
error: { code: 'scope_missing' },
});
});
it('rejects a scope key that belongs to another provider', async () => {
const result = await nextcloud.list(ncConn, { ...ncScope, scopeKey: 'drive:storage$x' });
expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } });
expect(calls).toHaveLength(0);
});
});
describe('list', () => {
const listing = () =>
multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' },
{
href: `${NC_ROOT}/TREK/trip%2042/sub/`,
collection: true,
fileid: '62',
etag: '"sub-1"',
},
{
href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`,
fileid: '61',
etag: '"doc-1"',
size: 1234,
type: 'application/pdf',
modified: 'Tue, 14 Nov 2023 22:13:20 GMT',
checksums: `SHA256:${'b'.repeat(64)}`,
},
]);
it('enumerates documents only, never the root and never a sub-folder', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing());
const result = await nextcloud.list(ncConn, ncScope);
expect(result.success && result.data.documents).toEqual([
{
remoteId: '61',
name: 'pass.pdf',
size: 1234,
mimeType: 'application/pdf',
remoteVersion: '"doc-1"',
contentHash: 'b'.repeat(64),
remoteModifiedAt: '2023-11-14T22:13:20.000Z',
isDeleted: false,
},
]);
expect(result.success && result.data.cursor).toBe('"root-1"');
expect(result.success && result.data.truncated).toBe(false);
});
it('skips the walk entirely when the root ETag has not moved', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' },
]));
const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' });
expect(result).toEqual({
success: true,
data: { documents: [], cursor: '"root-1"', cursorUnchanged: true, truncated: false },
});
expect(calls).toHaveLength(1);
expect(calls[0].headers.depth).toBe('0');
});
it('walks after the cursor probe shows a different root ETag', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-2"' }]),
listing(),
);
const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' });
expect(result.success && result.data.cursorUnchanged).toBe(false);
expect(calls.map((call) => call.headers.depth)).toEqual(['0', '1']);
});
it('reads a checksum only when it really is a SHA256, so OpenCloud reports none', async () => {
route(
`PROPFIND ${OC_ROOT}`,
multistatus([
{ href: `${OC_ROOT}/`, collection: true, fileid: `${OC_DRIVE}!root`, etag: '"root"' },
{
href: `${OC_ROOT}/pass.pdf`,
fileid: `${OC_DRIVE}!doc`,
etag: '"doc"',
size: 10,
type: 'application/pdf',
checksums: 'SHA1:2117 MD5:eebe ADLER32:5f9f',
},
]),
);
const result = await opencloud.list(ocConn, ocScope);
expect(result.success && result.data.documents[0]).toMatchObject({
remoteId: `${OC_DRIVE}!doc`,
contentHash: null,
});
});
it('reports a listing cut off at the size cap instead of failing the run', async () => {
capBytes = 620;
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing());
const result = await nextcloud.list(ncConn, ncScope);
expect(result.success && result.data.truncated).toBe(true);
expect(result.success && result.data.documents.length).toBeLessThanOrEqual(1);
});
it('turns a vanished folder into scope_missing rather than an empty trip', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('<d:error/>', { status: 404 }));
expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({
success: false,
error: { code: 'scope_missing' },
});
});
it('keeps a document without an oc:fileid addressable instead of dropping it', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
{ href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, etag: '"doc"', size: 1, type: 'application/pdf' },
]),
);
const result = await nextcloud.list(ncConn, ncScope);
expect(result.success && result.data.documents[0].remoteId).toBe('path:pass.pdf');
});
});
describe('push', () => {
const uploaded = (req: Recorded) =>
reply(null, {
status: 201,
headers: { 'oc-fileid': '00000062oc9fy1g5e2cj', 'oc-etag': `"${req.headers['x-oc-mtime']}"` },
});
it('pins the modification time and the checksum, and normalises the id Nextcloud answers with', async () => {
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded);
route(`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' },
]));
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
const put = requests('PUT')[0];
expect(put.headers['x-oc-mtime']).toBe('1700000000');
expect(put.headers['oc-checksum']).toBe(`SHA256:${'a'.repeat(64)}`);
expect(put.headers['content-length']).toBe('5');
// The header spells the id 00000062oc9fy1g5e2cj; the listing spells it 62,
// and a mismatch here is a duplicate document on every run.
expect(result).toMatchObject({
success: true,
data: { remoteId: '62', remoteModifiedAt: '2023-11-14T22:13:20.000Z', deduplicated: false },
});
});
it('never sends a SHA256 checksum to OpenCloud, which answers 400 to one', async () => {
route(`PUT ${OC_ROOT}/reiseplan.pdf`, reply(null, { status: 201, headers: { 'oc-fileid': 'd1', 'oc-etag': '"e1"' } }));
route(`PROPPATCH ${OC_ROOT}/reiseplan.pdf`, multistatus([{ href: `${OC_ROOT}/reiseplan.pdf`, etag: '"e1"' }]));
await opencloud.push(ocConn, ocScope, pushRequest());
expect(requests('PUT')[0].headers['oc-checksum']).toBeUndefined();
expect(requests('PUT')[0].headers['x-oc-mtime']).toBe('1700000000');
});
it('writes the TREK anchors as dead properties without letting a refusal undo the upload', async () => {
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded);
route(`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('<d:error/>', { status: 403 }));
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
expect(result.success).toBe(true);
expect(requests('PROPPATCH')[0].body).toContain('doc-1');
expect(requests('PROPPATCH')[0].body).toContain('trip-1');
});
it('asks for the id and version a proxy stripped from the response', async () => {
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply(null, { status: 201 }));
route(`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' },
]));
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`,
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, fileid: '62', etag: '"stored"', size: 5 }]),
);
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
expect(result).toMatchObject({ success: true, data: { remoteId: '62', remoteVersion: '"stored"' } });
});
it('sends If-Match when the core knows which version it is replacing, and reports 412 as a conflict', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
{ href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', etag: '"doc-1"', size: 1, type: 'application/pdf' },
]),
);
route(`PUT ${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply('<d:error/>', { status: 412 }));
const result = await nextcloud.push(
ncConn,
ncScope,
pushRequest({ remoteId: '61', expectedRemoteVersion: '"doc-1"', fileName: 'pass.pdf' }),
);
expect(requests('PUT')[0].headers['if-match']).toBe('"doc-1"');
expect(result).toMatchObject({ success: false, error: { code: 'conflict', status: 412 } });
});
it('refuses to recreate a document that disappeared upstream', async () => {
route(
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }]),
);
const result = await nextcloud.push(ncConn, ncScope, pushRequest({ remoteId: '61' }));
expect(result).toMatchObject({ success: false, error: { code: 'not_found' } });
expect(requests('PUT')).toHaveLength(0);
});
it('maps a full quota to quota_exceeded rather than a generic failure', async () => {
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('<d:error/>', { status: 507 }));
expect(await nextcloud.push(ncConn, ncScope, pushRequest())).toMatchObject({
success: false,
error: { code: 'quota_exceeded' },
});
});
});
describe('fetch, rename and trash', () => {
const folder = () =>
multistatus([
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
{
href: `${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
fileid: '61',
etag: '"doc-1"',
size: 5,
type: 'application/pdf',
},
]);
it('streams a document back and reports the version it came with', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
route(
`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
reply('the bytes', { headers: { 'content-length': '9', 'content-type': 'application/pdf', etag: '"doc-1"' } }),
);
const result = await nextcloud.fetch(ncConn, ncScope, '61');
expect(result.success).toBe(true);
if (!result.success) return;
const chunks: Buffer[] = [];
for await (const chunk of result.data.body) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe('the bytes');
expect(result.data).toMatchObject({ size: 9, mimeType: 'application/pdf', remoteVersion: '"doc-1"' });
});
it('re-uses the listing it already walked instead of re-walking per document', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
route(`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('x', { headers: { etag: '"doc-1"' } }));
await nextcloud.list(ncConn, ncScope);
await nextcloud.fetch(ncConn, ncScope, '61');
expect(requests('PROPFIND')).toHaveLength(1);
});
it('says not_found for an id the folder no longer holds', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
expect(await nextcloud.fetch(ncConn, ncScope, '999')).toMatchObject({
success: false,
error: { code: 'not_found' },
});
});
it('renames inside the same folder and refuses to overwrite a namesake', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
route(`MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply(null, { status: 201, headers: { 'oc-etag': '"doc-2"' } }));
const result = await nextcloud.rename(ncConn, ncScope, '61', 'Reisepass 2026.pdf');
const move = requests('MOVE')[0];
expect(move.headers.destination).toBe(`${NC_ORIGIN}${NC_ROOT}/TREK/trip%2042/Reisepass%202026.pdf`);
expect(move.headers.overwrite).toBe('F');
expect(result).toEqual({ success: true, data: { remoteVersion: '"doc-2"' } });
});
it('reports a rename onto an existing name as a conflict', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
route(`MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('<d:error/>', { status: 412 }));
expect(await nextcloud.rename(ncConn, ncScope, '61', 'anders.pdf')).toMatchObject({
success: false,
error: { code: 'conflict' },
});
});
it('deletes into the recycle bin and treats an already-deleted document as done', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
route(`DELETE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply(null, { status: 204 }), reply('<d:error/>', { status: 404 }));
expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined });
expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined });
});
});
describe('webhooks', () => {
const OCS = '/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks';
it('registers one unfiltered subscription per event, form-encoded, with the secret as a header', async () => {
route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}'));
let next = 10;
route(`POST ${OCS}`, () => reply(JSON.stringify({ ocs: { data: { id: next++ } } })));
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's3cret');
expect(result).toEqual({ success: true, data: { subscriptionId: '10,11,12,13' } });
const bodies = requests('POST').map((call) => new URLSearchParams(call.body ?? ''));
expect(requests('POST')[0].headers['content-type']).toBe('application/x-www-form-urlencoded');
expect(bodies[0].get('httpMethod')).toBe('POST');
expect(bodies[0].get('uri')).toBe('https://trek.example/hook');
expect(bodies[0].get('authMethod')).toBe('header');
expect(bodies[0].get('authData[X-TREK-Docsync-Signature]')).toBe('s3cret');
// A $regex filter is not merely ignored upstream: it throws inside the
// listener and stops delivery for every other webhook on that event.
expect(bodies.every((body) => [...body.keys()].every((key) => !key.startsWith('eventFilter')))).toBe(true);
expect(bodies.map((body) => body.get('event'))).toEqual([
'OCP\\Files\\Events\\Node\\NodeCreatedEvent',
'OCP\\Files\\Events\\Node\\NodeWrittenEvent',
'OCP\\Files\\Events\\Node\\NodeDeletedEvent',
'OCP\\Files\\Events\\Node\\NodeRenamedEvent',
]);
});
it('answers a non-admin account with forbidden and leaves the link polling', async () => {
route(`GET ${OCS}`, reply('{}', { status: 403 }));
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's');
expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } });
expect(requests('POST')).toHaveLength(0);
});
it('rolls back the subscriptions it already made when a later one fails', async () => {
route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}'));
route(
`POST ${OCS}`,
reply(JSON.stringify({ ocs: { data: { id: 10 } } })),
reply(JSON.stringify({ ocs: { data: { id: 11 } } })),
reply('<ocs/>', { status: 500 }),
);
route(`DELETE ${OCS}/10`, reply(null, { status: 200 }));
route(`DELETE ${OCS}/11`, reply(null, { status: 200 }));
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's');
expect(result).toMatchObject({ success: false, error: { code: 'provider_error' } });
expect(requests('DELETE').map(pathOf)).toEqual([`${OCS}/10`, `${OCS}/11`]);
});
it('tells an OpenCloud admin there is nothing to subscribe to instead of failing the connection', async () => {
const result = await opencloud.registerWebhook(ocConn, ocScope, 'https://trek.example/hook', 's');
expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } });
expect(calls).toHaveLength(0);
});
it('removes every subscription the id list names, tolerating one that is already gone', async () => {
route(`DELETE ${OCS}/10`, reply(null, { status: 200 }));
route(`DELETE ${OCS}/11`, reply('<ocs/>', { status: 404 }));
expect(await nextcloud.unregisterWebhook(ncConn, '10,11')).toEqual({ success: true, data: undefined });
expect(requests('DELETE')).toHaveLength(2);
});
});
describe('failure classification', () => {
it('separates a timeout, an untrusted certificate and a blocked host', async () => {
const timeout = Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }),
});
safeFetchMock.mockRejectedValueOnce(timeout);
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'timeout' } });
const tls = Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error('self-signed certificate'), { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' }),
});
safeFetchMock.mockRejectedValueOnce(tls);
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'tls_untrusted' } });
safeFetchMock.mockRejectedValueOnce(new SsrfBlockedError('Requests to loopback are not allowed'));
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'ssrf_blocked' } });
safeFetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unreachable' } });
});
it('keeps the upstream text in detail and out of the code', async () => {
route(`PROPFIND ${NC_ROOT}`, reply('<s:message>Username or password was incorrect</s:message>', { status: 401 }));
const result = await nextcloud.probe(ncConn);
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(result.success === false && result.error.detail).toContain('incorrect');
});
it('refuses a body that is not a multistatus instead of reading it as an empty folder', async () => {
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('<html><body>Please log in</body></html>', { status: 207 }));
expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({
success: false,
error: { code: 'provider_error' },
});
});
});
+1
View File
@@ -82,6 +82,7 @@ export default defineConfig({
'src/nest/database/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/day-notes/**/*.ts': { statements: 92, branches: 83, functions: 99, lines: 99 },
'src/nest/days/**/*.ts': { statements: 92, branches: 82, functions: 98, lines: 97 },
'src/nest/doc-sync/**/*.ts': { statements: 91, branches: 79, functions: 96, lines: 95 },
'src/nest/feeds/**/*.ts': { statements: 91, branches: 83, functions: 83, lines: 91 },
'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 },
'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 },
+230
View File
@@ -0,0 +1,230 @@
import { idSchema } from '../common/primitives.schema';
import { z } from 'zod';
/**
* Document provider contracts — two-way sync between a trip's documents and a
* self-hosted document store (Paperless-ngx, Papra, Nextcloud, OpenCloud,
* Synology Drive).
*
* Three deliberate decisions shape every schema below.
*
* **The connection belongs to the trip, not to the person.** Every other
* integration in TREK is per user (`users.immich_*`, `dawarich_connections`,
* even `trip_album_links` carries a `user_id`), and photos are shared only
* through an opt-in `shared` flag. That model cannot satisfy "everyone on the
* trip sees the same documents": it would make a document's visibility depend
* on whose credentials fetched it. So a trip is bound to exactly one
* connection, the server talks to the provider under that one identity, and
* who may see what is decided by TREK's own trip membership — never by the
* provider. The provider never learns that TREK has members.
*
* **TREK keeps the bytes.** A synced document is an ordinary `trip_files` row,
* so download, trash, offline cache, PDF export and backup keep working
* untouched, and a provider outage does not blank out a trip that starts
* tomorrow. The provider is a partner, not a disk.
*
* **Deletion never crosses the boundary on its own.** A file that disappears
* upstream is recorded as missing and waits for a human. An empty listing from
* an unmounted share must never read as "everything was deleted".
*/
// ── Provider identity ────────────────────────────────────────────────────────
/**
* Nextcloud and OpenCloud are separate ids on purpose even though one WebDAV
* adapter serves both: the admin toggles, the setup hints and the scope picker
* differ (a Nextcloud folder with an `oc:fileid`, an OpenCloud space with a
* `driveId`), and an operator looking at the addon shelf wants to see the
* product they run.
*/
export const DOCUMENT_PROVIDER_IDS = ['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive'] as const;
export const documentProviderIdSchema = z.enum(DOCUMENT_PROVIDER_IDS);
export type DocumentProviderId = z.infer<typeof documentProviderIdSchema>;
/** Placeholder returned instead of a stored secret. Never the secret itself. */
export const DOCSYNC_SECRET_MASK = '••••••••';
// ── Connection (per trip, created by a trip admin) ───────────────────────────
/**
* The credential fields are provider-specific and described by rows in
* `document_provider_fields`, so the payload is an open map rather than a fixed
* shape. It is still validated: keys must look like field keys and values are
* length-capped, which is what keeps an oversized blob out of the encrypted
* column.
*/
export const docsyncCredentialsSchema = z.record(z.string().regex(/^[a-z0-9_]{1,40}$/), z.string().max(4096));
export const docsyncConnectionInputSchema = z.object({
providerId: documentProviderIdSchema,
/** Instance origin. The adapter appends its own API path. */
baseUrl: z.string().trim().min(1).max(2048),
/**
* Secret and non-secret fields together, exactly as the generic form renders
* them. A secret that is omitted, blank or the mask keeps the stored value —
* the same rule the photo providers and Dawarich already use, so the client
* never has to hold a secret it was given back.
*/
credentials: docsyncCredentialsSchema.optional().default({}),
/** LAN instances routinely run self-signed TLS. Default off, never on. */
allowInsecureTls: z.boolean().optional().default(false),
});
export type DocsyncConnectionInput = z.infer<typeof docsyncConnectionInputSchema>;
/** Probe a set of form values before anything is stored. */
export const docsyncConnectionTestSchema = docsyncConnectionInputSchema;
// ── Scope (which slice of the provider belongs to this trip) ─────────────────
/**
* A scope key is opaque to everything except its own adapter. It is normalised,
* never empty, and carries its own kind so a stored binding stays readable
* after a restore: `tag:12`, `org:org_x/tag:tag_y`, `fileid:437`,
* `drive:storage-users-1$89ad…`, `path:/trek/japan-2026`.
*
* It is NOT null-able, and that is load-bearing: SQLite compares NULLs as
* distinct, so a unique index over nullable anchor columns would happily accept
* unlimited duplicate bindings for the same folder.
*/
export const docsyncScopeKeySchema = z.string().trim().min(1).max(512);
export const docsyncScopeOptionSchema = z.object({
scopeKey: docsyncScopeKeySchema,
/** What a human sees in the picker: folder name, tag name, space name. */
label: z.string().max(512),
/** Stable provider-side id where one exists (fileid, driveId, tag id). */
remoteRootId: z.string().max(512).nullable(),
/** Human-readable location, for the "where do my files live" line. */
remoteRootPath: z.string().max(2048).nullable(),
});
export type DocsyncScopeOption = z.infer<typeof docsyncScopeOptionSchema>;
/**
* Creating a scope rather than picking one. Only providers with a container
* concept accept this (a WebDAV folder, an OpenCloud space, a Paperless tag,
* a Papra tag); the adapter reports whether it can.
*/
export const docsyncScopeCreateSchema = z.object({
connectionId: idSchema,
/** Name for the new folder, tag or space. */
name: z.string().trim().min(1).max(200),
});
// ── Binding a trip to a scope ────────────────────────────────────────────────
/**
* `both` is the point of the feature. The one-way modes exist because a shared
* Paperless holding a household's private papers is a place some people will
* only ever want to read from, and forcing a write path on them would mean
* they do not turn the feature on at all.
*/
export const docsyncDirectionSchema = z.enum(['both', 'pull', 'push']);
/**
* What happens to the other side when a document is deleted here.
*
* `unlink` is the default and the only safe one: the pairing is dropped, both
* copies stay. `trash` propagates into the provider's own recycle bin (never a
* hard delete, which several of these APIs do not even expose to a token) and
* has to be chosen deliberately, because a misclick in a file manager should
* not empty a trip four people are travelling on.
*/
export const docsyncDeletePolicySchema = z.enum(['unlink', 'trash']);
/** Who wins when both sides changed since the last agreed state. */
export const docsyncConflictPolicySchema = z.enum(['manual', 'trek_wins', 'provider_wins']);
export const docsyncLinkInputSchema = z.object({
connectionId: idSchema,
scopeKey: docsyncScopeKeySchema,
remoteRootId: z.string().max(512).nullable().optional(),
remoteRootPath: z.string().max(2048).nullable().optional(),
remoteLabel: z.string().max(512).optional().default(''),
direction: docsyncDirectionSchema.optional().default('both'),
deletePolicy: docsyncDeletePolicySchema.optional().default('unlink'),
conflictPolicy: docsyncConflictPolicySchema.optional().default('manual'),
syncEnabled: z.boolean().optional().default(true),
});
export type DocsyncLinkInput = z.infer<typeof docsyncLinkInputSchema>;
export const docsyncLinkUpdateSchema = docsyncLinkInputSchema.omit({ connectionId: true, scopeKey: true }).partial();
// ── Sync state ───────────────────────────────────────────────────────────────
/**
* `needs_reauth`, `scope_lost` and `orphaned` are separate from plain `failed`
* because each needs a different human action, and a single "error" state would
* hide which one. `scope_lost` means the folder or tag the trip is bound to no
* longer exists — someone renamed or deleted it upstream. `orphaned` means the
* person whose credentials drive this binding left the trip.
*/
export const docsyncLinkStateSchema = z.enum([
'never',
'ok',
'partial',
'failed',
'needs_reauth',
'scope_lost',
'orphaned',
]);
/**
* Per-document state. `rejected_type` and `too_large` are visible states rather
* than silent skips: a Paperless that refuses .gpx and a TREK that refuses .svg
* both drop files on the floor otherwise, and the person who uploaded them has
* no way to find out.
*/
export const docsyncItemStateSchema = z.enum([
'pending',
'synced',
'conflict',
'rejected_type',
'too_large',
'error',
'remote_missing',
'local_deleted',
'scope_drift',
]);
/**
* Failure reasons travel as codes, not as upstream text. A provider answers in
* English, or with an HTML login page from a reverse proxy, and neither belongs
* in a German user's file list. The client resolves these through i18n; the
* detail string stays for the self-hoster's log.
*/
export const DOCSYNC_ERROR_CODES = [
'unreachable',
'tls_untrusted',
'unauthorized',
'forbidden',
'not_found',
'scope_missing',
'rate_limited',
'too_large',
'unsupported_type',
'quota_exceeded',
'conflict',
'checksum_mismatch',
'provider_error',
'timeout',
'ssrf_blocked',
'mass_delete_guard',
'unknown',
] as const;
export const docsyncErrorCodeSchema = z.enum(DOCSYNC_ERROR_CODES);
export type DocsyncErrorCode = z.infer<typeof docsyncErrorCodeSchema>;
// ── Conflict resolution ──────────────────────────────────────────────────────
export const docsyncResolveConflictSchema = z.object({
/** Which side becomes the agreed state. */
keep: z.enum(['trek', 'provider', 'both']),
});
/** Manual "sync now", so a user never has to wait out the poll interval. */
export const docsyncSyncNowSchema = z.object({
/** Skip the cursor short-circuit and enumerate both sides in full. */
full: z.boolean().optional().default(false),
});
+101
View File
@@ -0,0 +1,101 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'مزامنة المستندات',
'docsync.subtitle': 'أبقِ مستندات هذه الرحلة متوافقة مع مخزن المستندات الخاص بك.',
'docsync.noProviders': 'لا يوجد مزوّدو مستندات متاحون',
'docsync.noProvidersHint': 'يفعّلها مسؤول الخادم من الإدارة ← الإضافات ← المستندات.',
'docsync.addProvider': 'ربط مزوّد',
'docsync.test': 'اختبار الاتصال',
'docsync.connected': 'متصل',
'docsync.chooseFolder': 'اختيار مجلد',
'docsync.chooseFolderHint':
'اختر المجلد أو الوسم أو المساحة التي تخص هذه الرحلة. تُزامَن المستندات الموجودة فيها فقط.',
'docsync.noFolders': 'لم يُعثر على شيء على هذا الخادم بعد.',
'docsync.newFolderPlaceholder': 'اسم المجلد الجديد',
'docsync.createFolder': 'إنشاء',
'docsync.syncNow': 'زامن الآن',
'docsync.unlink': 'قطع الاتصال',
'docsync.syncEnabled': 'المزامنة تلقائياً',
'docsync.direction': 'الاتجاه',
'docsync.directionBoth': 'في الاتجاهين',
'docsync.directionPull': 'إلى TREK فقط',
'docsync.directionPush': 'إلى المزوّد فقط',
'docsync.deletePolicy': 'عند حذف مستند',
'docsync.deleteUnlink': 'الإبقاء على النسختين وإلغاء الاقتران',
'docsync.deleteTrash': 'نقل النسخة الأخرى إلى سلة المهملات لديها',
'docsync.webhookHint': 'الصق هذا الـ URL في المزوّد لتصل التغييرات فوراً. بدونه يتحقق TREK على فترات زمنية.',
// حقول نموذج الاتصال. تطابق المفاتيح عمود `label` في
// document_provider_fields، وهو يخزّن لاحقة مفتاح لا نصاً.
'docsync.providerUrl': 'العنوان',
'docsync.providerApiToken': 'رمز API',
'docsync.providerApiKey': 'مفتاح API',
'docsync.providerAppPassword': 'كلمة مرور التطبيق',
'docsync.providerAppToken': 'رمز التطبيق',
'docsync.providerUsername': 'اسم المستخدم',
'docsync.providerPassword': 'كلمة المرور',
'docsync.providerOrganization': 'معرّف المؤسسة',
'docsync.providerBasePath': 'المجلد الأساسي',
'docsync.providerOTP': 'رمز المصادقة الثنائية',
'docsync.allowInsecureTls': 'قبول شهادة موقّعة ذاتياً',
'docsync.hintPaperlessToken': 'أنشئه في Paperless ضمن ملفي الشخصي. يحمل كامل صلاحيات ذلك الحساب.',
'docsync.hintPapraKey': 'أنشئه في Papra ضمن مفاتيح API. تصل مفاتيح Papra دائماً إلى كل مؤسسة تنتمي إليها.',
'docsync.hintPapraOrg': 'المعرّف org_… من شريط العنوان في Papra.',
'docsync.hintNextcloudLogin': 'اسم الدخول إلى Nextcloud، لا بريدك الإلكتروني.',
'docsync.hintNextcloudAppPassword':
'الإعدادات ← الأمان ← إنشاء كلمة مرور تطبيق جديدة. لا تستخدم كلمة مرور حسابك أبداً.',
'docsync.hintOpenCloudToken': 'يُنشأ في OpenCloud ضمن رموز التطبيقات.',
'docsync.hintBasePath': 'المكان الذي يبحث فيه TREK عن مجلدات الرحلات. الافتراضي /TREK.',
'docsync.hintSynologyUrl': 'أدرج المنفذ، مثال https://nas.example.com:5001',
'docsync.hintSynologyUser': 'يُفضّل حساب DSM مخصّص لا يصل إلا إلى هذا المجلد المشترك.',
'docsync.hintSynologyOtp': 'مطلوب مرة واحدة فقط، إذا كان الحساب يستخدم المصادقة الثنائية.',
'docsync.linkState.never': 'لم تجرِ المزامنة بعد',
'docsync.linkState.ok': 'متزامن',
'docsync.linkState.partial': 'متزامن جزئياً',
'docsync.linkState.failed': 'فشل',
'docsync.linkState.needs_reauth': 'سجّل الدخول مجدداً',
'docsync.linkState.scope_lost': 'المجلد لم يعد موجوداً',
'docsync.linkState.orphaned': 'غادر المالك الرحلة',
'docsync.state.pending': 'في الانتظار',
'docsync.state.synced': 'متزامن',
'docsync.state.conflict': 'تعارض',
'docsync.state.rejected_type': 'نوع غير مسموح',
'docsync.state.too_large': 'كبير جداً',
'docsync.state.error': 'خطأ',
'docsync.state.remote_missing': 'غير موجود لدى المزوّد',
'docsync.state.local_deleted': 'محذوف في TREK',
'docsync.state.scope_drift': 'نُقل خارج المجلد',
'docsync.conflict.title': 'تغيّرت النسختان',
'docsync.conflict.keepTrek': 'الإبقاء على نسخة TREK',
'docsync.conflict.keepProvider': 'الإبقاء على نسخة المزوّد',
'docsync.conflict.keepBoth': 'الإبقاء على النسختين',
// تنتقل أسباب الفشل كرموز لا كنص قادم من المزوّد: فهو يجيب بالإنجليزية،
// أو بصفحة تسجيل دخول HTML من وسيط، وكلاهما لا مكان له هنا.
'docsync.error.unreachable': 'تعذّر الوصول إلى المزوّد.',
'docsync.error.tls_untrusted': 'رُفضت الشهادة. اسمح بالشهادات الموقّعة ذاتياً إذا كنت تثق بهذا الخادم.',
'docsync.error.unauthorized': 'رُفضت بيانات الاعتماد.',
'docsync.error.forbidden': 'هذا الحساب لا يملك صلاحية القيام بذلك.',
'docsync.error.not_found': 'غير موجود لدى المزوّد.',
'docsync.error.scope_missing': 'المجلد المرتبط لم يعد موجوداً.',
'docsync.error.rate_limited': 'المزوّد يحدّ من عدد طلباتنا. سيعيد TREK المحاولة لاحقاً.',
'docsync.error.too_large': 'حجم الملف أكبر مما يقبله المزوّد.',
'docsync.error.unsupported_type': 'المزوّد لا يقبل هذا النوع من الملفات.',
'docsync.error.quota_exceeded': 'لم تعد لدى المزوّد مساحة.',
'docsync.error.conflict': 'تغيّر المستند في الجهتين.',
'docsync.error.checksum_mismatch': 'لم يصل النقل سليماً.',
'docsync.error.provider_error': 'أبلغ المزوّد عن خطأ.',
'docsync.error.timeout': 'استغرق المزوّد وقتاً طويلاً للرد.',
'docsync.error.ssrf_blocked': 'هذا العنوان غير مسموح به.',
'docsync.error.mass_delete_guard':
'اختفت معظم المستندات دفعة واحدة، لذا لم يُغيَّر شيء. تأكد من أن المجلد لا يزال موصولاً.',
'docsync.error.unknown': 'حدث خطأ ما.',
'docsync.error.unknown_provider': 'هذا المزوّد غير متاح على هذا الخادم.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronização de documentos',
'docsync.subtitle': 'Mantenha os documentos desta viagem em sintonia com o seu próprio repositório de documentos.',
'docsync.noProviders': 'Nenhum provedor de documentos está disponível',
'docsync.noProvidersHint': 'Um administrador da instância ativa isso em Admin, Complementos, Documentos.',
'docsync.addProvider': 'Conectar um provedor',
'docsync.test': 'Testar conexão',
'docsync.connected': 'Conectado',
'docsync.chooseFolder': 'Escolher pasta',
'docsync.chooseFolderHint':
'Escolha a pasta, a etiqueta ou o espaço que pertence a esta viagem. Só os documentos que estão nele são sincronizados.',
'docsync.noFolders': 'Nada encontrado nesta instância ainda.',
'docsync.newFolderPlaceholder': 'Nome da nova pasta',
'docsync.createFolder': 'Criar',
'docsync.syncNow': 'Sincronizar agora',
'docsync.unlink': 'Desconectar',
'docsync.syncEnabled': 'Sincronizar automaticamente',
'docsync.direction': 'Direção',
'docsync.directionBoth': 'Nos dois sentidos',
'docsync.directionPull': 'Somente para o TREK',
'docsync.directionPush': 'Somente para o provedor',
'docsync.deletePolicy': 'Quando um documento é excluído',
'docsync.deleteUnlink': 'Manter as duas cópias e desfazer o vínculo',
'docsync.deleteTrash': 'Mover a outra cópia para a lixeira dela',
'docsync.webhookHint':
'Cole esta URL no seu provedor para que as mudanças cheguem na hora. Sem isso, o TREK verifica em intervalos.',
// Campos do formulário de conexão. As chaves espelham a coluna `label` de
// document_provider_fields, que guarda um sufixo de chave, não o texto.
'docsync.providerUrl': 'Endereço',
'docsync.providerApiToken': 'Token de API',
'docsync.providerApiKey': 'Chave de API',
'docsync.providerAppPassword': 'Senha de aplicativo',
'docsync.providerAppToken': 'Token de aplicativo',
'docsync.providerUsername': 'Nome de usuário',
'docsync.providerPassword': 'Senha',
'docsync.providerOrganization': 'ID da organização',
'docsync.providerBasePath': 'Pasta base',
'docsync.providerOTP': 'Código de dois fatores',
'docsync.allowInsecureTls': 'Aceitar certificado autoassinado',
'docsync.hintPaperlessToken': 'Crie um em Meu perfil no Paperless. Ele carrega todos os direitos daquela conta.',
'docsync.hintPapraKey':
'Crie uma em Chaves de API no Papra. As chaves do Papra sempre alcançam todas as organizações às quais você pertence.',
'docsync.hintPapraOrg': 'O id org_… que aparece na barra de endereços do Papra.',
'docsync.hintNextcloudLogin': 'Seu nome de login do Nextcloud, não seu endereço de e-mail.',
'docsync.hintNextcloudAppPassword':
'Configurações, Segurança, Criar nova senha de aplicativo. Nunca a senha da sua conta.',
'docsync.hintOpenCloudToken': 'Criado em tokens de aplicativo no OpenCloud.',
'docsync.hintBasePath': 'Onde o TREK procura as pastas das viagens. O padrão é /TREK.',
'docsync.hintSynologyUrl': 'Inclua a porta, por exemplo https://nas.example.com:5001',
'docsync.hintSynologyUser': 'De preferência uma conta DSM dedicada com acesso apenas a esta pasta compartilhada.',
'docsync.hintSynologyOtp': 'Necessário só uma vez, se a conta usa autenticação de dois fatores.',
'docsync.linkState.never': 'Ainda não sincronizado',
'docsync.linkState.ok': 'Em sincronia',
'docsync.linkState.partial': 'Sincronizado em parte',
'docsync.linkState.failed': 'Falhou',
'docsync.linkState.needs_reauth': 'Entre novamente',
'docsync.linkState.scope_lost': 'A pasta sumiu',
'docsync.linkState.orphaned': 'O dono saiu da viagem',
'docsync.state.pending': 'Aguardando',
'docsync.state.synced': 'Sincronizado',
'docsync.state.conflict': 'Conflito',
'docsync.state.rejected_type': 'Tipo não permitido',
'docsync.state.too_large': 'Grande demais',
'docsync.state.error': 'Erro',
'docsync.state.remote_missing': 'Não está no provedor',
'docsync.state.local_deleted': 'Excluído no TREK',
'docsync.state.scope_drift': 'Saiu da pasta',
'docsync.conflict.title': 'As duas cópias mudaram',
'docsync.conflict.keepTrek': 'Manter a versão do TREK',
'docsync.conflict.keepProvider': 'Manter a versão do provedor',
'docsync.conflict.keepBoth': 'Manter as duas',
// Os motivos de falha viajam como códigos, nunca como texto do outro lado: um
// provedor responde em inglês, ou com a página de login HTML de um proxy, e
// nenhum dos dois cabe aqui.
'docsync.error.unreachable': 'Não foi possível acessar o provedor.',
'docsync.error.tls_untrusted':
'O certificado foi recusado. Permita certificados autoassinados se você confia nesta instância.',
'docsync.error.unauthorized': 'As credenciais foram recusadas.',
'docsync.error.forbidden': 'Esta conta não tem permissão para fazer isso.',
'docsync.error.not_found': 'Não encontrado no provedor.',
'docsync.error.scope_missing': 'A pasta conectada não existe mais.',
'docsync.error.rate_limited': 'O provedor está limitando o ritmo. O TREK vai tentar de novo mais tarde.',
'docsync.error.too_large': 'O arquivo é maior do que o provedor aceita.',
'docsync.error.unsupported_type': 'O provedor não aceita esse tipo de arquivo.',
'docsync.error.quota_exceeded': 'O provedor está sem espaço.',
'docsync.error.conflict': 'O documento mudou dos dois lados.',
'docsync.error.checksum_mismatch': 'A transferência não chegou intacta.',
'docsync.error.provider_error': 'O provedor relatou um erro.',
'docsync.error.timeout': 'O provedor demorou demais para responder.',
'docsync.error.ssrf_blocked': 'Esse endereço não é permitido.',
'docsync.error.mass_delete_guard':
'Quase todos os documentos sumiram de uma vez, então nada foi alterado. Verifique se a pasta ainda está montada.',
'docsync.error.unknown': 'Algo deu errado.',
'docsync.error.unknown_provider': 'Este provedor não está disponível nesta instância.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronització de documents',
'docsync.subtitle': 'Mantén els documents d’aquest viatge en sintonia amb el teu propi gestor de documents.',
'docsync.noProviders': 'No hi ha cap proveïdor de documents disponible',
'docsync.noProvidersHint': 'Un administrador de la instància els activa a Administració, Complements, Documents.',
'docsync.addProvider': 'Connecta un proveïdor',
'docsync.test': 'Prova la connexió',
'docsync.connected': 'Connectat',
'docsync.chooseFolder': 'Tria una carpeta',
'docsync.chooseFolderHint':
'Tria la carpeta, l’etiqueta o l’espai que pertany a aquest viatge. Només se’n sincronitzen els documents que hi ha a dins.',
'docsync.noFolders': 'Encara no s’ha trobat res en aquesta instància.',
'docsync.newFolderPlaceholder': 'Nom de la carpeta nova',
'docsync.createFolder': 'Crea',
'docsync.syncNow': 'Sincronitza ara',
'docsync.unlink': 'Desconnecta',
'docsync.syncEnabled': 'Sincronitza automàticament',
'docsync.direction': 'Direcció',
'docsync.directionBoth': 'En tots dos sentits',
'docsync.directionPull': 'Només cap a TREK',
'docsync.directionPush': 'Només cap al proveïdor',
'docsync.deletePolicy': 'Quan s’elimina un document',
'docsync.deleteUnlink': 'Conserva les dues còpies i desfés l’aparellament',
'docsync.deleteTrash': 'Mou l’altra còpia a la seva paperera',
'docsync.webhookHint':
'Enganxa aquesta URL al teu proveïdor perquè els canvis arribin de seguida. Sense això, TREK ho comprova cada cert temps.',
// Camps del formulari de connexió. Les claus reflecteixen la columna `label` de
// document_provider_fields, que desa un sufix de clau i no pas el text.
'docsync.providerUrl': 'Adreça',
'docsync.providerApiToken': 'Token API',
'docsync.providerApiKey': 'Clau API',
'docsync.providerAppPassword': 'Contrasenya d’aplicació',
'docsync.providerAppToken': 'Token d’aplicació',
'docsync.providerUsername': 'Nom d’usuari',
'docsync.providerPassword': 'Contrasenya',
'docsync.providerOrganization': 'ID d’organització',
'docsync.providerBasePath': 'Carpeta base',
'docsync.providerOTP': 'Codi de doble factor',
'docsync.allowInsecureTls': 'Accepta un certificat autosignat',
'docsync.hintPaperlessToken': 'Crea’l a El meu perfil, dins de Paperless. Té tots els drets d’aquell compte.',
'docsync.hintPapraKey':
'Crea-la a Claus API, dins de Papra. Les claus de Papra sempre arriben a totes les organitzacions a què pertanys.',
'docsync.hintPapraOrg': 'L’id org_… de la barra d’adreces de Papra.',
'docsync.hintNextcloudLogin': 'El teu nom d’usuari de Nextcloud, no pas la teva adreça electrònica.',
'docsync.hintNextcloudAppPassword':
'Configuració, Seguretat, Crea una contrasenya d’aplicació nova. Mai la contrasenya del compte.',
'docsync.hintOpenCloudToken': 'Es crea als tokens d’aplicació d’OpenCloud.',
'docsync.hintBasePath': 'On busca TREK les carpetes dels viatges. Per defecte, /TREK.',
'docsync.hintSynologyUrl': 'Inclou-hi el port, per exemple https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Millor un compte DSM dedicat amb accés només a aquesta carpeta compartida.',
'docsync.hintSynologyOtp': 'Només cal un cop, si el compte fa servir autenticació de doble factor.',
'docsync.linkState.never': 'Encara no s’ha sincronitzat',
'docsync.linkState.ok': 'Al dia',
'docsync.linkState.partial': 'Sincronitzat en part',
'docsync.linkState.failed': 'Ha fallat',
'docsync.linkState.needs_reauth': 'Torna a iniciar la sessió',
'docsync.linkState.scope_lost': 'La carpeta ha desaparegut',
'docsync.linkState.orphaned': 'El propietari ha deixat el viatge',
'docsync.state.pending': 'En espera',
'docsync.state.synced': 'Sincronitzat',
'docsync.state.conflict': 'Conflicte',
'docsync.state.rejected_type': 'Tipus no permès',
'docsync.state.too_large': 'Massa gran',
'docsync.state.error': 'Error',
'docsync.state.remote_missing': 'No hi és, al proveïdor',
'docsync.state.local_deleted': 'Eliminat a TREK',
'docsync.state.scope_drift': 'Ha sortit de la carpeta',
'docsync.conflict.title': 'Han canviat totes dues còpies',
'docsync.conflict.keepTrek': 'Conserva la versió de TREK',
'docsync.conflict.keepProvider': 'Conserva la versió del proveïdor',
'docsync.conflict.keepBoth': 'Conserva-les totes dues',
// Els motius de fallada viatgen com a codis, mai com a text de l’altra banda:
// un proveïdor respon en anglès, o amb la pàgina de login HTML d’un proxy, i
// cap de les dues coses no hi pinta res.
'docsync.error.unreachable': 'No s’ha pogut contactar amb el proveïdor.',
'docsync.error.tls_untrusted':
'El certificat s’ha rebutjat. Permet els certificats autosignats si confies en aquesta instància.',
'docsync.error.unauthorized': 'S’han rebutjat les credencials.',
'docsync.error.forbidden': 'Aquest compte no té permís per fer-ho.',
'docsync.error.not_found': 'No s’ha trobat al proveïdor.',
'docsync.error.scope_missing': 'La carpeta connectada ja no existeix.',
'docsync.error.rate_limited': 'El proveïdor ens està limitant el ritme. TREK ho tornarà a provar més tard.',
'docsync.error.too_large': 'El fitxer és més gran del que accepta el proveïdor.',
'docsync.error.unsupported_type': 'El proveïdor no accepta aquest tipus de fitxer.',
'docsync.error.quota_exceeded': 'El proveïdor s’ha quedat sense espai.',
'docsync.error.conflict': 'El document ha canviat a totes dues bandes.',
'docsync.error.checksum_mismatch': 'La transferència no ha arribat intacta.',
'docsync.error.provider_error': 'El proveïdor ha informat d’un error.',
'docsync.error.timeout': 'El proveïdor ha trigat massa a respondre.',
'docsync.error.ssrf_blocked': 'Aquesta adreça no està permesa.',
'docsync.error.mass_delete_guard':
'Han desaparegut molts documents de cop, així que no s’ha canviat res. Comprova que la carpeta encara estigui muntada.',
'docsync.error.unknown': 'Alguna cosa ha anat malament.',
'docsync.error.unknown_provider': 'Aquest proveïdor no està disponible en aquesta instància.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+103
View File
@@ -0,0 +1,103 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronizace dokumentů',
'docsync.subtitle': 'Udržujte dokumenty této cesty v souladu s vlastním úložištěm dokumentů.',
'docsync.noProviders': 'Nejsou dostupní žádní poskytovatelé dokumentů',
'docsync.noProvidersHint': 'Správce instance je zapíná v Administrace → Doplňky → Dokumenty.',
'docsync.addProvider': 'Připojit poskytovatele',
'docsync.test': 'Otestovat připojení',
'docsync.connected': 'Připojeno',
'docsync.chooseFolder': 'Vybrat složku',
'docsync.chooseFolderHint':
'Zvolte složku, štítek nebo prostor, který patří k této cestě. Synchronizují se jen dokumenty v něm.',
'docsync.noFolders': 'Na této instanci zatím nic nenalezeno.',
'docsync.newFolderPlaceholder': 'Název nové složky',
'docsync.createFolder': 'Vytvořit',
'docsync.syncNow': 'Synchronizovat teď',
'docsync.unlink': 'Odpojit',
'docsync.syncEnabled': 'Synchronizovat automaticky',
'docsync.direction': 'Směr',
'docsync.directionBoth': 'Oběma směry',
'docsync.directionPull': 'Jen do TREKu',
'docsync.directionPush': 'Jen k poskytovateli',
'docsync.deletePolicy': 'Když se dokument smaže',
'docsync.deleteUnlink': 'Zachovat obě kopie, zrušit propojení',
'docsync.deleteTrash': 'Přesunout druhou kopii do jejího koše',
'docsync.webhookHint':
'Vložte tuto URL k poskytovateli, aby změny přicházely okamžitě. Bez toho se TREK ptá v pravidelných intervalech.',
// Pole formuláře připojení. Klíče odpovídají sloupci `label` v tabulce
// document_provider_fields, která ukládá příponu klíče, ne text.
'docsync.providerUrl': 'Adresa',
'docsync.providerApiToken': 'API token',
'docsync.providerApiKey': 'API klíč',
'docsync.providerAppPassword': 'Heslo aplikace',
'docsync.providerAppToken': 'Token aplikace',
'docsync.providerUsername': 'Uživatelské jméno',
'docsync.providerPassword': 'Heslo',
'docsync.providerOrganization': 'ID organizace',
'docsync.providerBasePath': 'Základní složka',
'docsync.providerOTP': 'Dvoufaktorový kód',
'docsync.allowInsecureTls': 'Přijmout vlastnoručně podepsaný certifikát',
'docsync.hintPaperlessToken': 'Vytvořte jej v Paperless-ngx pod My Profile. Nese plná práva daného účtu.',
'docsync.hintPapraKey':
'Vytvořte jej v aplikaci Papra pod API keys. Klíče Papra vždy dosáhnou na všechny organizace, do kterých patříte.',
'docsync.hintPapraOrg': 'ID ve tvaru org_… z adresního řádku aplikace Papra.',
'docsync.hintNextcloudLogin': 'Vaše přihlašovací jméno v Nextcloudu, ne e-mailová adresa.',
'docsync.hintNextcloudAppPassword': 'Nastavení → Zabezpečení → Vytvořit nové heslo aplikace. Nikdy heslo k účtu.',
'docsync.hintOpenCloudToken': 'Vytváří se v OpenCloudu pod tokeny aplikací.',
'docsync.hintBasePath': 'Kde TREK hledá složky cest. Výchozí je /TREK.',
'docsync.hintSynologyUrl': 'Uveďte i port, například https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Nejlépe vyhrazený účet DSM s přístupem jen k této sdílené složce.',
'docsync.hintSynologyOtp': 'Potřeba jen jednou, pokud účet používá dvoufaktorové ověření.',
'docsync.linkState.never': 'Zatím nesynchronizováno',
'docsync.linkState.ok': 'Aktuální',
'docsync.linkState.partial': 'Částečně synchronizováno',
'docsync.linkState.failed': 'Selhalo',
'docsync.linkState.needs_reauth': 'Přihlaste se znovu',
'docsync.linkState.scope_lost': 'Složka zmizela',
'docsync.linkState.orphaned': 'Vlastník opustil cestu',
'docsync.state.pending': 'Čeká',
'docsync.state.synced': 'Synchronizováno',
'docsync.state.conflict': 'Konflikt',
'docsync.state.rejected_type': 'Nepovolený typ',
'docsync.state.too_large': 'Příliš velké',
'docsync.state.error': 'Chyba',
'docsync.state.remote_missing': 'Chybí u poskytovatele',
'docsync.state.local_deleted': 'Smazáno v TREKu',
'docsync.state.scope_drift': 'Přesunuto mimo složku',
'docsync.conflict.title': 'Změnily se obě kopie',
'docsync.conflict.keepTrek': 'Ponechat verzi z TREKu',
'docsync.conflict.keepProvider': 'Ponechat verzi poskytovatele',
'docsync.conflict.keepBoth': 'Ponechat obě',
// Důvody selhání se přenášejí jako kódy, nikdy jako text od poskytovatele:
// ten odpovídá anglicky nebo HTML přihlašovací stránkou proxy a ani jedno sem nepatří.
'docsync.error.unreachable': 'Poskytovatele se nepodařilo kontaktovat.',
'docsync.error.tls_untrusted':
'Certifikát byl odmítnut. Pokud této instanci důvěřujete, povolte vlastnoručně podepsané certifikáty.',
'docsync.error.unauthorized': 'Přihlašovací údaje byly odmítnuty.',
'docsync.error.forbidden': 'Tento účet k tomu nemá oprávnění.',
'docsync.error.not_found': 'U poskytovatele nenalezeno.',
'docsync.error.scope_missing': 'Připojená složka už neexistuje.',
'docsync.error.rate_limited': 'Poskytovatel omezuje počet požadavků. TREK to zkusí znovu později.',
'docsync.error.too_large': 'Soubor je větší, než poskytovatel přijímá.',
'docsync.error.unsupported_type': 'Poskytovatel tento typ souboru nepřijímá.',
'docsync.error.quota_exceeded': 'Poskytovateli došlo místo.',
'docsync.error.conflict': 'Dokument se změnil na obou stranách.',
'docsync.error.checksum_mismatch': 'Přenos nedorazil neporušený.',
'docsync.error.provider_error': 'Poskytovatel ohlásil chybu.',
'docsync.error.timeout': 'Poskytovatel odpovídal příliš dlouho.',
'docsync.error.ssrf_blocked': 'Tato adresa není povolena.',
'docsync.error.mass_delete_guard':
'Najednou zmizela většina dokumentů, proto se nic nezměnilo. Zkontrolujte, zda je složka stále připojená.',
'docsync.error.unknown': 'Něco se pokazilo.',
'docsync.error.unknown_provider': 'Tento poskytovatel není na této instanci dostupný.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentenabgleich',
'docsync.subtitle': 'Halte die Dokumente dieser Reise und deinen eigenen Dokumentenspeicher im Gleichstand.',
'docsync.noProviders': 'Es sind keine Dokumentenanbieter verfügbar',
'docsync.noProvidersHint':
'Ein Administrator dieser Instanz schaltet sie unter Administration, Addons, Dokumente frei.',
'docsync.addProvider': 'Anbieter verbinden',
'docsync.test': 'Verbindung testen',
'docsync.connected': 'Verbunden',
'docsync.chooseFolder': 'Ordner wählen',
'docsync.chooseFolderHint':
'Wähle den Ordner, das Tag oder den Bereich, der zu dieser Reise gehört. Abgeglichen wird nur, was darin liegt.',
'docsync.noFolders': 'Auf dieser Instanz wurde noch nichts gefunden.',
'docsync.newFolderPlaceholder': 'Name des neuen Ordners',
'docsync.createFolder': 'Anlegen',
'docsync.syncNow': 'Jetzt abgleichen',
'docsync.unlink': 'Trennen',
'docsync.syncEnabled': 'Automatisch abgleichen',
'docsync.direction': 'Richtung',
'docsync.directionBoth': 'In beide Richtungen',
'docsync.directionPull': 'Nur nach TREK',
'docsync.directionPush': 'Nur zum Anbieter',
'docsync.deletePolicy': 'Wenn ein Dokument gelöscht wird',
'docsync.deleteUnlink': 'Beide Kopien behalten, die Verknüpfung lösen',
'docsync.deleteTrash': 'Die andere Kopie in den Papierkorb verschieben',
'docsync.webhookHint':
'Trage diese URL bei deinem Anbieter ein, damit Änderungen sofort ankommen. Ohne sie fragt TREK in festen Abständen nach.',
// Felder des Verbindungsformulars. Die Keys spiegeln die Spalte `label` in
// document_provider_fields, die ein Key-Suffix speichert und keinen Text.
'docsync.providerUrl': 'Adresse',
'docsync.providerApiToken': 'API-Token',
'docsync.providerApiKey': 'API-Schlüssel',
'docsync.providerAppPassword': 'App-Passwort',
'docsync.providerAppToken': 'App-Token',
'docsync.providerUsername': 'Benutzername',
'docsync.providerPassword': 'Passwort',
'docsync.providerOrganization': 'Organisations-ID',
'docsync.providerBasePath': 'Basisordner',
'docsync.providerOTP': 'Zwei-Faktor-Code',
'docsync.allowInsecureTls': 'Selbstsigniertes Zertifikat zulassen',
'docsync.hintPaperlessToken': 'In Paperless unter My Profile anzulegen. Er trägt die vollen Rechte dieses Kontos.',
'docsync.hintPapraKey':
'In Papra unter API keys anzulegen. Papra-Schlüssel erreichen immer jede Organisation, der du angehörst.',
'docsync.hintPapraOrg': 'Die org_…-ID aus der Adresszeile von Papra.',
'docsync.hintNextcloudLogin': 'Dein Nextcloud-Anmeldename, nicht deine E-Mail-Adresse.',
'docsync.hintNextcloudAppPassword':
'Einstellungen, Sicherheit, Neues App-Passwort erstellen. Niemals dein Kontopasswort.',
'docsync.hintOpenCloudToken': 'Wird in OpenCloud unter App-Tokens angelegt.',
'docsync.hintBasePath': 'Wo TREK nach Reiseordnern sucht. Standard ist /TREK.',
'docsync.hintSynologyUrl': 'Mit Port angeben, zum Beispiel https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Am besten ein eigenes DSM-Konto, das nur auf diesen freigegebenen Ordner zugreift.',
'docsync.hintSynologyOtp': 'Nur einmal nötig, wenn das Konto Zwei-Faktor-Authentifizierung nutzt.',
'docsync.linkState.never': 'Noch nicht abgeglichen',
'docsync.linkState.ok': 'Im Gleichstand',
'docsync.linkState.partial': 'Teilweise abgeglichen',
'docsync.linkState.failed': 'Fehlgeschlagen',
'docsync.linkState.needs_reauth': 'Erneut anmelden',
'docsync.linkState.scope_lost': 'Ordner ist weg',
'docsync.linkState.orphaned': 'Besitzer hat die Reise verlassen',
'docsync.state.pending': 'Wartet',
'docsync.state.synced': 'Abgeglichen',
'docsync.state.conflict': 'Konflikt',
'docsync.state.rejected_type': 'Dateityp nicht erlaubt',
'docsync.state.too_large': 'Zu groß',
'docsync.state.error': 'Fehler',
'docsync.state.remote_missing': 'Beim Anbieter nicht vorhanden',
'docsync.state.local_deleted': 'In TREK gelöscht',
'docsync.state.scope_drift': 'Aus dem Ordner verschoben',
'docsync.conflict.title': 'Beide Kopien wurden geändert',
'docsync.conflict.keepTrek': 'Die TREK-Version behalten',
'docsync.conflict.keepProvider': 'Die Version des Anbieters behalten',
'docsync.conflict.keepBoth': 'Beide behalten',
// Fehlergründe kommen als Codes an, nie als Text des Anbieters: der antwortet
// englisch oder mit der HTML-Loginseite eines Proxys, und beides gehört nicht hierher.
'docsync.error.unreachable': 'Der Anbieter war nicht erreichbar.',
'docsync.error.tls_untrusted':
'Das Zertifikat wurde abgelehnt. Lass selbstsignierte Zertifikate zu, wenn du dieser Instanz vertraust.',
'docsync.error.unauthorized': 'Die Zugangsdaten wurden abgelehnt.',
'docsync.error.forbidden': 'Dieses Konto darf das nicht.',
'docsync.error.not_found': 'Beim Anbieter nicht gefunden.',
'docsync.error.scope_missing': 'Den verbundenen Ordner gibt es nicht mehr.',
'docsync.error.rate_limited': 'Der Anbieter bremst uns aus. TREK versucht es später noch einmal.',
'docsync.error.too_large': 'Die Datei ist größer, als der Anbieter annimmt.',
'docsync.error.unsupported_type': 'Der Anbieter nimmt diesen Dateityp nicht an.',
'docsync.error.quota_exceeded': 'Beim Anbieter ist kein Speicher mehr frei.',
'docsync.error.conflict': 'Das Dokument wurde auf beiden Seiten geändert.',
'docsync.error.checksum_mismatch': 'Die Übertragung ist nicht unversehrt angekommen.',
'docsync.error.provider_error': 'Der Anbieter hat einen Fehler gemeldet.',
'docsync.error.timeout': 'Der Anbieter hat zu lange für eine Antwort gebraucht.',
'docsync.error.ssrf_blocked': 'Diese Adresse ist nicht erlaubt.',
'docsync.error.mass_delete_guard':
'Es sind auf einen Schlag fast alle Dokumente verschwunden, deshalb wurde nichts geändert. Prüfe, ob der Ordner noch eingebunden ist.',
'docsync.error.unknown': 'Da ist etwas schiefgelaufen.',
'docsync.error.unknown_provider': 'Dieser Anbieter steht auf dieser Instanz nicht zur Verfügung.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+103
View File
@@ -0,0 +1,103 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Document sync',
'docsync.subtitle': "Keep this trip's documents in step with your own document store.",
'docsync.noProviders': 'No document providers are available',
'docsync.noProvidersHint': 'An instance administrator switches these on under Admin, Addons, Documents.',
'docsync.addProvider': 'Connect a provider',
'docsync.test': 'Test connection',
'docsync.connected': 'Connected',
'docsync.chooseFolder': 'Choose folder',
'docsync.chooseFolderHint':
'Pick the folder, tag or space that belongs to this trip. Only documents in it are synced.',
'docsync.noFolders': 'Nothing found on this instance yet.',
'docsync.newFolderPlaceholder': 'New folder name',
'docsync.createFolder': 'Create',
'docsync.syncNow': 'Sync now',
'docsync.unlink': 'Disconnect',
'docsync.syncEnabled': 'Sync automatically',
'docsync.direction': 'Direction',
'docsync.directionBoth': 'Both ways',
'docsync.directionPull': 'Only into TREK',
'docsync.directionPush': 'Only out to the provider',
'docsync.deletePolicy': 'When a document is deleted',
'docsync.deleteUnlink': 'Keep both copies, drop the pairing',
'docsync.deleteTrash': 'Move the other copy to its recycle bin',
'docsync.webhookHint':
'Paste this URL into your provider so changes arrive immediately. Without it, TREK checks on a timer.',
// Connection form fields. The keys mirror the `label` column in
// document_provider_fields, which stores a key suffix rather than text.
'docsync.providerUrl': 'Address',
'docsync.providerApiToken': 'API token',
'docsync.providerApiKey': 'API key',
'docsync.providerAppPassword': 'App password',
'docsync.providerAppToken': 'App token',
'docsync.providerUsername': 'Username',
'docsync.providerPassword': 'Password',
'docsync.providerOrganization': 'Organisation ID',
'docsync.providerBasePath': 'Base folder',
'docsync.providerOTP': 'Two-factor code',
'docsync.allowInsecureTls': 'Accept a self-signed certificate',
'docsync.hintPaperlessToken': "Create one under My Profile in Paperless. It carries that account's full rights.",
'docsync.hintPapraKey':
'Create one under API keys in Papra. Papra keys always reach every organisation you belong to.',
'docsync.hintPapraOrg': 'The org_… id from the Papra address bar.',
'docsync.hintNextcloudLogin': 'Your Nextcloud login name, not your email address.',
'docsync.hintNextcloudAppPassword': 'Settings, Security, Create new app password. Never your account password.',
'docsync.hintOpenCloudToken': 'Created under app tokens in OpenCloud.',
'docsync.hintBasePath': 'Where TREK looks for trip folders. Defaults to /TREK.',
'docsync.hintSynologyUrl': 'Include the port, for example https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Best a dedicated DSM account with access to just this shared folder.',
'docsync.hintSynologyOtp': 'Only needed once, if the account uses two-factor authentication.',
'docsync.linkState.never': 'Not synced yet',
'docsync.linkState.ok': 'In sync',
'docsync.linkState.partial': 'Partly synced',
'docsync.linkState.failed': 'Failed',
'docsync.linkState.needs_reauth': 'Sign in again',
'docsync.linkState.scope_lost': 'Folder is gone',
'docsync.linkState.orphaned': 'Owner left the trip',
'docsync.state.pending': 'Waiting',
'docsync.state.synced': 'Synced',
'docsync.state.conflict': 'Conflict',
'docsync.state.rejected_type': 'Type not allowed',
'docsync.state.too_large': 'Too large',
'docsync.state.error': 'Error',
'docsync.state.remote_missing': 'Missing at the provider',
'docsync.state.local_deleted': 'Deleted in TREK',
'docsync.state.scope_drift': 'Moved out of the folder',
'docsync.conflict.title': 'Both copies changed',
'docsync.conflict.keepTrek': 'Keep the TREK version',
'docsync.conflict.keepProvider': 'Keep the provider version',
'docsync.conflict.keepBoth': 'Keep both',
// Failure reasons travel as codes, never as upstream text: a provider answers
// in English, or with a proxy's HTML login page, and neither belongs here.
'docsync.error.unreachable': 'The provider could not be reached.',
'docsync.error.tls_untrusted':
'The certificate was rejected. Allow self-signed certificates if you trust this instance.',
'docsync.error.unauthorized': 'The credentials were refused.',
'docsync.error.forbidden': 'This account is not allowed to do that.',
'docsync.error.not_found': 'Not found on the provider.',
'docsync.error.scope_missing': 'The connected folder no longer exists.',
'docsync.error.rate_limited': 'The provider is rate limiting us. TREK will try again later.',
'docsync.error.too_large': 'The file is larger than the provider accepts.',
'docsync.error.unsupported_type': 'The provider does not accept this file type.',
'docsync.error.quota_exceeded': 'The provider is out of space.',
'docsync.error.conflict': 'The document changed on both sides.',
'docsync.error.checksum_mismatch': 'The transfer did not arrive intact.',
'docsync.error.provider_error': 'The provider reported an error.',
'docsync.error.timeout': 'The provider took too long to answer.',
'docsync.error.ssrf_blocked': 'That address is not allowed.',
'docsync.error.mass_delete_guard':
'Most documents vanished at once, so nothing was changed. Check that the folder is still mounted.',
'docsync.error.unknown': 'Something went wrong.',
'docsync.error.unknown_provider': 'This provider is not available on this instance.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronización de documentos',
'docsync.subtitle': 'Mantén los documentos de este viaje al día con tu propio gestor de documentos.',
'docsync.noProviders': 'No hay proveedores de documentos disponibles',
'docsync.noProvidersHint': 'Un administrador de la instancia los activa en Admin, Complementos, Documentos.',
'docsync.addProvider': 'Conectar un proveedor',
'docsync.test': 'Probar conexión',
'docsync.connected': 'Conectado',
'docsync.chooseFolder': 'Elegir carpeta',
'docsync.chooseFolderHint':
'Elige la carpeta, la etiqueta o el espacio que corresponde a este viaje. Solo se sincronizan los documentos que contiene.',
'docsync.noFolders': 'Todavía no se ha encontrado nada en esta instancia.',
'docsync.newFolderPlaceholder': 'Nombre de la nueva carpeta',
'docsync.createFolder': 'Crear',
'docsync.syncNow': 'Sincronizar ahora',
'docsync.unlink': 'Desconectar',
'docsync.syncEnabled': 'Sincronizar automáticamente',
'docsync.direction': 'Dirección',
'docsync.directionBoth': 'En ambos sentidos',
'docsync.directionPull': 'Solo hacia TREK',
'docsync.directionPush': 'Solo hacia el proveedor',
'docsync.deletePolicy': 'Cuando se elimina un documento',
'docsync.deleteUnlink': 'Conservar ambas copias y deshacer el emparejamiento',
'docsync.deleteTrash': 'Mover la otra copia a su papelera',
'docsync.webhookHint':
'Pega esta URL en tu proveedor para que los cambios lleguen de inmediato. Sin ella, TREK comprueba cada cierto tiempo.',
// Campos del formulario de conexión. Las claves reflejan la columna `label` de
// document_provider_fields, que guarda un sufijo de clave en lugar de texto.
'docsync.providerUrl': 'Dirección',
'docsync.providerApiToken': 'Token de API',
'docsync.providerApiKey': 'Clave de API',
'docsync.providerAppPassword': 'Contraseña de aplicación',
'docsync.providerAppToken': 'Token de aplicación',
'docsync.providerUsername': 'Nombre de usuario',
'docsync.providerPassword': 'Contraseña',
'docsync.providerOrganization': 'ID de organización',
'docsync.providerBasePath': 'Carpeta base',
'docsync.providerOTP': 'Código de doble factor',
'docsync.allowInsecureTls': 'Aceptar un certificado autofirmado',
'docsync.hintPaperlessToken': 'Créalo en Paperless, en Mi perfil. Tiene todos los permisos de esa cuenta.',
'docsync.hintPapraKey':
'Créala en Papra, en Claves de API. Las claves de Papra siempre alcanzan a todas las organizaciones a las que perteneces.',
'docsync.hintPapraOrg': 'El id org_… que aparece en la barra de direcciones de Papra.',
'docsync.hintNextcloudLogin': 'Tu nombre de usuario de Nextcloud, no tu dirección de correo.',
'docsync.hintNextcloudAppPassword':
'Ajustes, Seguridad, Crear nueva contraseña de aplicación. Nunca la contraseña de tu cuenta.',
'docsync.hintOpenCloudToken': 'Se crea en OpenCloud, en los tokens de aplicación.',
'docsync.hintBasePath': 'Donde TREK busca las carpetas de los viajes. Por defecto, /TREK.',
'docsync.hintSynologyUrl': 'Incluye el puerto, por ejemplo https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Mejor una cuenta DSM dedicada con acceso solo a esta carpeta compartida.',
'docsync.hintSynologyOtp': 'Solo hace falta una vez, si la cuenta usa autenticación de doble factor.',
'docsync.linkState.never': 'Aún sin sincronizar',
'docsync.linkState.ok': 'Al día',
'docsync.linkState.partial': 'Sincronizado en parte',
'docsync.linkState.failed': 'Falló',
'docsync.linkState.needs_reauth': 'Vuelve a iniciar sesión',
'docsync.linkState.scope_lost': 'La carpeta ya no está',
'docsync.linkState.orphaned': 'El propietario dejó el viaje',
'docsync.state.pending': 'En espera',
'docsync.state.synced': 'Sincronizado',
'docsync.state.conflict': 'Conflicto',
'docsync.state.rejected_type': 'Tipo no permitido',
'docsync.state.too_large': 'Demasiado grande',
'docsync.state.error': 'Error',
'docsync.state.remote_missing': 'Falta en el proveedor',
'docsync.state.local_deleted': 'Eliminado en TREK',
'docsync.state.scope_drift': 'Fuera de la carpeta',
'docsync.conflict.title': 'Las dos copias han cambiado',
'docsync.conflict.keepTrek': 'Conservar la versión de TREK',
'docsync.conflict.keepProvider': 'Conservar la versión del proveedor',
'docsync.conflict.keepBoth': 'Conservar ambas',
// Los motivos de fallo viajan como códigos, nunca como el texto del proveedor: este
// responde en inglés, o devuelve la página de inicio de sesión HTML de un proxy, y ninguna
// de las dos cosas pinta nada aquí.
'docsync.error.unreachable': 'No se pudo contactar con el proveedor.',
'docsync.error.tls_untrusted':
'El certificado fue rechazado. Permite los certificados autofirmados si confías en esta instancia.',
'docsync.error.unauthorized': 'Las credenciales fueron rechazadas.',
'docsync.error.forbidden': 'Esta cuenta no tiene permiso para hacerlo.',
'docsync.error.not_found': 'No se encontró en el proveedor.',
'docsync.error.scope_missing': 'La carpeta conectada ya no existe.',
'docsync.error.rate_limited': 'El proveedor está limitando las peticiones. TREK lo intentará más tarde.',
'docsync.error.too_large': 'El archivo supera el tamaño que acepta el proveedor.',
'docsync.error.unsupported_type': 'El proveedor no acepta este tipo de archivo.',
'docsync.error.quota_exceeded': 'El proveedor se ha quedado sin espacio.',
'docsync.error.conflict': 'El documento cambió en ambos lados.',
'docsync.error.checksum_mismatch': 'La transferencia no llegó íntegra.',
'docsync.error.provider_error': 'El proveedor informó de un error.',
'docsync.error.timeout': 'El proveedor tardó demasiado en responder.',
'docsync.error.ssrf_blocked': 'Esa dirección no está permitida.',
'docsync.error.mass_delete_guard':
'Desaparecieron casi todos los documentos a la vez, así que no se cambió nada. Comprueba que la carpeta siga montada.',
'docsync.error.unknown': 'Algo salió mal.',
'docsync.error.unknown_provider': 'Este proveedor no está disponible en esta instancia.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronisation des documents',
'docsync.subtitle': 'Gardez les documents de ce voyage alignés sur votre propre gestionnaire de documents.',
'docsync.noProviders': 'Aucun fournisseur de documents n’est disponible',
'docsync.noProvidersHint': 'Un administrateur de l’instance les active dans Admin, Extensions, Documents.',
'docsync.addProvider': 'Connecter un fournisseur',
'docsync.test': 'Tester la connexion',
'docsync.connected': 'Connecté',
'docsync.chooseFolder': 'Choisir le dossier',
'docsync.chooseFolderHint':
'Choisissez le dossier, l’étiquette ou l’espace qui correspond à ce voyage. Seuls les documents qui s’y trouvent sont synchronisés.',
'docsync.noFolders': 'Rien trouvé sur cette instance pour le moment.',
'docsync.newFolderPlaceholder': 'Nom du nouveau dossier',
'docsync.createFolder': 'Créer',
'docsync.syncNow': 'Synchroniser maintenant',
'docsync.unlink': 'Déconnecter',
'docsync.syncEnabled': 'Synchroniser automatiquement',
'docsync.direction': 'Sens',
'docsync.directionBoth': 'Dans les deux sens',
'docsync.directionPull': 'Uniquement vers TREK',
'docsync.directionPush': 'Uniquement vers le fournisseur',
'docsync.deletePolicy': 'À la suppression d’un document',
'docsync.deleteUnlink': 'Garder les deux copies, rompre l’association',
'docsync.deleteTrash': 'Mettre l’autre copie à la corbeille',
'docsync.webhookHint':
'Collez cette URL chez votre fournisseur pour que les changements arrivent immédiatement. Sans cela, TREK vérifie à intervalles réguliers.',
// Champs du formulaire de connexion. Les clés reprennent la colonne `label` de
// document_provider_fields, qui stocke un suffixe de clé plutôt qu’un texte.
'docsync.providerUrl': 'Adresse',
'docsync.providerApiToken': 'Jeton API',
'docsync.providerApiKey': 'Clé API',
'docsync.providerAppPassword': 'Mot de passe d’application',
'docsync.providerAppToken': 'Jeton d’application',
'docsync.providerUsername': 'Nom d’utilisateur',
'docsync.providerPassword': 'Mot de passe',
'docsync.providerOrganization': 'ID d’organisation',
'docsync.providerBasePath': 'Dossier de base',
'docsync.providerOTP': 'Code à deux facteurs',
'docsync.allowInsecureTls': 'Accepter un certificat auto-signé',
'docsync.hintPaperlessToken': 'À créer dans Paperless sous Mon profil. Il donne tous les droits de ce compte.',
'docsync.hintPapraKey':
'À créer dans Papra sous Clés API. Une clé Papra atteint toujours toutes les organisations dont vous faites partie.',
'docsync.hintPapraOrg': 'L’identifiant org_… affiché dans la barre d’adresse de Papra.',
'docsync.hintNextcloudLogin': 'Votre identifiant de connexion Nextcloud, pas votre adresse e-mail.',
'docsync.hintNextcloudAppPassword':
'Paramètres, Sécurité, Créer un nouveau mot de passe d’application. Jamais le mot de passe de votre compte.',
'docsync.hintOpenCloudToken': 'À créer dans OpenCloud sous les jetons d’application.',
'docsync.hintBasePath': 'Là où TREK cherche les dossiers de voyage. Par défaut /TREK.',
'docsync.hintSynologyUrl': 'Indiquez le port, par exemple https://nas.example.com:5001',
'docsync.hintSynologyUser': 'De préférence un compte DSM dédié, avec accès à ce seul dossier partagé.',
'docsync.hintSynologyOtp': 'Nécessaire une seule fois, si le compte utilise l’authentification à deux facteurs.',
'docsync.linkState.never': 'Pas encore synchronisé',
'docsync.linkState.ok': 'À jour',
'docsync.linkState.partial': 'Partiellement synchronisé',
'docsync.linkState.failed': 'Échec',
'docsync.linkState.needs_reauth': 'Reconnectez-vous',
'docsync.linkState.scope_lost': 'Dossier introuvable',
'docsync.linkState.orphaned': 'Le propriétaire a quitté le voyage',
'docsync.state.pending': 'En attente',
'docsync.state.synced': 'Synchronisé',
'docsync.state.conflict': 'Conflit',
'docsync.state.rejected_type': 'Type non autorisé',
'docsync.state.too_large': 'Trop volumineux',
'docsync.state.error': 'Erreur',
'docsync.state.remote_missing': 'Absent chez le fournisseur',
'docsync.state.local_deleted': 'Supprimé dans TREK',
'docsync.state.scope_drift': 'Sorti du dossier',
'docsync.conflict.title': 'Les deux copies ont changé',
'docsync.conflict.keepTrek': 'Garder la version TREK',
'docsync.conflict.keepProvider': 'Garder la version du fournisseur',
'docsync.conflict.keepBoth': 'Garder les deux',
// Les causes d’échec circulent sous forme de codes, jamais de texte venu du fournisseur :
// celui-ci répond en anglais, ou renvoie la page de connexion HTML d’un proxy, et ni l’un
// ni l’autre n’a sa place ici.
'docsync.error.unreachable': 'Le fournisseur n’a pas pu être joint.',
'docsync.error.tls_untrusted':
'Le certificat a été rejeté. Autorisez les certificats auto-signés si vous faites confiance à cette instance.',
'docsync.error.unauthorized': 'Les identifiants ont été refusés.',
'docsync.error.forbidden': 'Ce compte n’a pas le droit de faire cela.',
'docsync.error.not_found': 'Introuvable chez le fournisseur.',
'docsync.error.scope_missing': 'Le dossier connecté n’existe plus.',
'docsync.error.rate_limited': 'Le fournisseur limite nos requêtes. TREK réessaiera plus tard.',
'docsync.error.too_large': 'Le fichier dépasse la taille acceptée par le fournisseur.',
'docsync.error.unsupported_type': 'Le fournisseur n’accepte pas ce type de fichier.',
'docsync.error.quota_exceeded': 'Le fournisseur n’a plus d’espace libre.',
'docsync.error.conflict': 'Le document a changé des deux côtés.',
'docsync.error.checksum_mismatch': 'Le transfert n’est pas arrivé intact.',
'docsync.error.provider_error': 'Le fournisseur a signalé une erreur.',
'docsync.error.timeout': 'Le fournisseur a mis trop de temps à répondre.',
'docsync.error.ssrf_blocked': 'Cette adresse n’est pas autorisée.',
'docsync.error.mass_delete_guard':
'La plupart des documents ont disparu d’un coup, rien n’a donc été modifié. Vérifiez que le dossier est toujours monté.',
'docsync.error.unknown': 'Une erreur est survenue.',
'docsync.error.unknown_provider': 'Ce fournisseur n’est pas disponible sur cette instance.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+107
View File
@@ -0,0 +1,107 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Συγχρονισμός εγγράφων',
'docsync.subtitle': 'Κρατήστε τα έγγραφα αυτού του ταξιδιού στην ίδια κατάσταση με τον δικό σας χώρο εγγράφων.',
'docsync.noProviders': 'Δεν υπάρχουν διαθέσιμοι πάροχοι εγγράφων',
'docsync.noProvidersHint': 'Τους ενεργοποιεί ένας διαχειριστής της εγκατάστασης στο Διαχείριση → Πρόσθετα → Έγγραφα.',
'docsync.addProvider': 'Σύνδεση παρόχου',
'docsync.test': 'Δοκιμή σύνδεσης',
'docsync.connected': 'Συνδεδεμένο',
'docsync.chooseFolder': 'Επιλογή φακέλου',
'docsync.chooseFolderHint':
'Επιλέξτε τον φάκελο, την ετικέτα ή τον χώρο που ανήκει σε αυτό το ταξίδι. Συγχρονίζονται μόνο τα έγγραφα που βρίσκονται εκεί.',
'docsync.noFolders': 'Δεν βρέθηκε ακόμη τίποτα σε αυτή την εγκατάσταση.',
'docsync.newFolderPlaceholder': 'Όνομα νέου φακέλου',
'docsync.createFolder': 'Δημιουργία',
'docsync.syncNow': 'Συγχρονισμός τώρα',
'docsync.unlink': 'Αποσύνδεση',
'docsync.syncEnabled': 'Αυτόματος συγχρονισμός',
'docsync.direction': 'Κατεύθυνση',
'docsync.directionBoth': 'Και προς τις δύο κατευθύνσεις',
'docsync.directionPull': 'Μόνο προς το TREK',
'docsync.directionPush': 'Μόνο προς τον πάροχο',
'docsync.deletePolicy': 'Όταν διαγράφεται ένα έγγραφο',
'docsync.deleteUnlink': 'Διατήρηση και των δύο αντιγράφων, κατάργηση της αντιστοίχισης',
'docsync.deleteTrash': 'Μετακίνηση του άλλου αντιγράφου στον κάδο του',
'docsync.webhookHint':
'Επικολλήστε αυτό το URL στον πάροχό σας ώστε οι αλλαγές να φτάνουν αμέσως. Χωρίς αυτό, το TREK ελέγχει ανά τακτά διαστήματα.',
// Τα πεδία της φόρμας σύνδεσης. Τα κλειδιά αντικατοπτρίζουν τη στήλη `label` του
// document_provider_fields, που αποθηκεύει κατάληξη κλειδιού και όχι κείμενο.
'docsync.providerUrl': 'Διεύθυνση',
'docsync.providerApiToken': 'Διακριτικό API',
'docsync.providerApiKey': 'Κλειδί API',
'docsync.providerAppPassword': 'Κωδικός εφαρμογής',
'docsync.providerAppToken': 'Διακριτικό εφαρμογής',
'docsync.providerUsername': 'Όνομα χρήστη',
'docsync.providerPassword': 'Κωδικός πρόσβασης',
'docsync.providerOrganization': 'Αναγνωριστικό οργανισμού',
'docsync.providerBasePath': 'Βασικός φάκελος',
'docsync.providerOTP': 'Κωδικός δύο παραγόντων',
'docsync.allowInsecureTls': 'Αποδοχή αυτο-υπογεγραμμένου πιστοποιητικού',
'docsync.hintPaperlessToken':
'Δημιουργήστε το στο Paperless, στο Το προφίλ μου. Φέρει όλα τα δικαιώματα εκείνου του λογαριασμού.',
'docsync.hintPapraKey':
'Δημιουργήστε το στο Papra, στα κλειδιά API. Τα κλειδιά του Papra φτάνουν πάντα σε κάθε οργανισμό στον οποίο ανήκετε.',
'docsync.hintPapraOrg': 'Το αναγνωριστικό org_… από τη γραμμή διευθύνσεων του Papra.',
'docsync.hintNextcloudLogin': 'Το όνομα σύνδεσής σας στο Nextcloud, όχι η διεύθυνση email σας.',
'docsync.hintNextcloudAppPassword':
'Ρυθμίσεις → Ασφάλεια → Δημιουργία νέου κωδικού εφαρμογής. Ποτέ ο κωδικός του λογαριασμού σας.',
'docsync.hintOpenCloudToken': 'Δημιουργείται στο OpenCloud, στα διακριτικά εφαρμογών.',
'docsync.hintBasePath': 'Πού ψάχνει το TREK για φακέλους ταξιδιών. Προεπιλογή /TREK.',
'docsync.hintSynologyUrl': 'Συμπεριλάβετε τη θύρα, για παράδειγμα https://nas.example.com:5001',
'docsync.hintSynologyUser':
'Καλύτερα ένας ξεχωριστός λογαριασμός DSM με πρόσβαση μόνο σε αυτόν τον κοινόχρηστο φάκελο.',
'docsync.hintSynologyOtp': 'Χρειάζεται μόνο μία φορά, αν ο λογαριασμός χρησιμοποιεί ταυτοποίηση δύο παραγόντων.',
'docsync.linkState.never': 'Δεν έχει συγχρονιστεί ακόμα',
'docsync.linkState.ok': 'Σε συγχρονισμό',
'docsync.linkState.partial': 'Μερικώς συγχρονισμένο',
'docsync.linkState.failed': 'Απέτυχε',
'docsync.linkState.needs_reauth': 'Συνδεθείτε ξανά',
'docsync.linkState.scope_lost': 'Ο φάκελος χάθηκε',
'docsync.linkState.orphaned': 'Ο κάτοχος αποχώρησε από το ταξίδι',
'docsync.state.pending': 'Σε αναμονή',
'docsync.state.synced': 'Συγχρονίστηκε',
'docsync.state.conflict': 'Διένεξη',
'docsync.state.rejected_type': 'Μη επιτρεπτός τύπος',
'docsync.state.too_large': 'Πολύ μεγάλο',
'docsync.state.error': 'Σφάλμα',
'docsync.state.remote_missing': 'Λείπει από τον πάροχο',
'docsync.state.local_deleted': 'Διαγράφηκε στο TREK',
'docsync.state.scope_drift': 'Μετακινήθηκε εκτός του φακέλου',
'docsync.conflict.title': 'Άλλαξαν και τα δύο αντίγραφα',
'docsync.conflict.keepTrek': 'Διατήρηση της έκδοσης του TREK',
'docsync.conflict.keepProvider': 'Διατήρηση της έκδοσης του παρόχου',
'docsync.conflict.keepBoth': 'Διατήρηση και των δύο',
// Οι λόγοι αποτυχίας ταξιδεύουν ως κωδικοί, ποτέ ως κείμενο του παρόχου: ένας πάροχος
// απαντά στα αγγλικά ή με τη σελίδα σύνδεσης ενός διαμεσολαβητή σε HTML, και κανένα από
// τα δύο δεν έχει θέση εδώ.
'docsync.error.unreachable': 'Δεν ήταν δυνατή η επικοινωνία με τον πάροχο.',
'docsync.error.tls_untrusted':
'Το πιστοποιητικό απορρίφθηκε. Επιτρέψτε τα αυτο-υπογεγραμμένα πιστοποιητικά αν εμπιστεύεστε αυτή την εγκατάσταση.',
'docsync.error.unauthorized': 'Τα διαπιστευτήρια απορρίφθηκαν.',
'docsync.error.forbidden': 'Αυτός ο λογαριασμός δεν επιτρέπεται να το κάνει αυτό.',
'docsync.error.not_found': 'Δεν βρέθηκε στον πάροχο.',
'docsync.error.scope_missing': 'Ο συνδεδεμένος φάκελος δεν υπάρχει πλέον.',
'docsync.error.rate_limited': 'Ο πάροχος περιορίζει τον ρυθμό των αιτημάτων. Το TREK θα δοκιμάσει ξανά αργότερα.',
'docsync.error.too_large': 'Το αρχείο είναι μεγαλύτερο από όσο δέχεται ο πάροχος.',
'docsync.error.unsupported_type': 'Ο πάροχος δεν δέχεται αυτόν τον τύπο αρχείου.',
'docsync.error.quota_exceeded': 'Ο πάροχος δεν έχει άλλο χώρο.',
'docsync.error.conflict': 'Το έγγραφο άλλαξε και στις δύο πλευρές.',
'docsync.error.checksum_mismatch': 'Η μεταφορά δεν έφτασε ακέραιη.',
'docsync.error.provider_error': 'Ο πάροχος ανέφερε σφάλμα.',
'docsync.error.timeout': 'Ο πάροχος άργησε πολύ να απαντήσει.',
'docsync.error.ssrf_blocked': 'Αυτή η διεύθυνση δεν επιτρέπεται.',
'docsync.error.mass_delete_guard':
'Τα περισσότερα έγγραφα εξαφανίστηκαν μονομιάς, οπότε δεν άλλαξε τίποτα. Ελέγξτε ότι ο φάκελος είναι ακόμη προσαρτημένος.',
'docsync.error.unknown': 'Κάτι πήγε στραβά.',
'docsync.error.unknown_provider': 'Αυτός ο πάροχος δεν είναι διαθέσιμος σε αυτή την εγκατάσταση.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+106
View File
@@ -0,0 +1,106 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentumok szinkronizálása',
'docsync.subtitle': 'Tartsd szinkronban ennek az útnak a dokumentumait a saját dokumentumtárolóddal.',
'docsync.noProviders': 'Nincs elérhető dokumentumszolgáltató',
'docsync.noProvidersHint': 'A példány rendszergazdája kapcsolja be ezeket az Admin, Bővítmények, Dokumentumok alatt.',
'docsync.addProvider': 'Szolgáltató csatlakoztatása',
'docsync.test': 'Kapcsolat tesztelése',
'docsync.connected': 'Kapcsolódva',
'docsync.chooseFolder': 'Mappa kiválasztása',
'docsync.chooseFolderHint':
'Válaszd ki az ehhez az úthoz tartozó mappát, címkét vagy teret. Csak az abban lévő dokumentumok szinkronizálódnak.',
'docsync.noFolders': 'Ezen a példányon még nem található semmi.',
'docsync.newFolderPlaceholder': 'Az új mappa neve',
'docsync.createFolder': 'Létrehozás',
'docsync.syncNow': 'Szinkronizálás most',
'docsync.unlink': 'Leválasztás',
'docsync.syncEnabled': 'Automatikus szinkronizálás',
'docsync.direction': 'Irány',
'docsync.directionBoth': 'Mindkét irányban',
'docsync.directionPull': 'Csak a TREK-be',
'docsync.directionPush': 'Csak a szolgáltató felé',
'docsync.deletePolicy': 'Ha egy dokumentumot törölnek',
'docsync.deleteUnlink': 'Mindkét példány maradjon meg, csak a párosítás szűnjön meg',
'docsync.deleteTrash': 'A másik példány kerüljön a saját kukájába',
'docsync.webhookHint':
'Illeszd be ezt az URL-t a szolgáltatódnál, hogy a változások azonnal megérkezzenek. Enélkül a TREK időzítve ellenőriz.',
// A kapcsolati űrlap mezői. A kulcsok a document_provider_fields tábla `label`
// oszlopát tükrözik, amely szöveg helyett kulcsvégződést tárol.
'docsync.providerUrl': 'Cím',
'docsync.providerApiToken': 'API-token',
'docsync.providerApiKey': 'API-kulcs',
'docsync.providerAppPassword': 'Alkalmazásjelszó',
'docsync.providerAppToken': 'Alkalmazástoken',
'docsync.providerUsername': 'Felhasználónév',
'docsync.providerPassword': 'Jelszó',
'docsync.providerOrganization': 'Szervezet azonosítója',
'docsync.providerBasePath': 'Alapmappa',
'docsync.providerOTP': 'Kétlépcsős kód',
'docsync.allowInsecureTls': 'Saját aláírású tanúsítvány elfogadása',
'docsync.hintPaperlessToken':
'A Paperlessben a Saját profil alatt hozhatsz létre egyet. Az adott fiók teljes jogosultságát viszi magával.',
'docsync.hintPapraKey':
'A Paprában az API-kulcsok alatt hozhatsz létre egyet. A Papra kulcsaival mindig eléred minden szervezetedet.',
'docsync.hintPapraOrg': 'Az org_… azonosító a Papra címsorából.',
'docsync.hintNextcloudLogin': 'A Nextcloud bejelentkezési neved, nem az e-mail-címed.',
'docsync.hintNextcloudAppPassword':
'Beállítások, Biztonság, Új alkalmazásjelszó létrehozása. Soha ne a fiókod jelszava.',
'docsync.hintOpenCloudToken': 'Az OpenCloudban az alkalmazástokenek alatt jön létre.',
'docsync.hintBasePath': 'Itt keresi a TREK az utak mappáit. Alapértelmezés szerint /TREK.',
'docsync.hintSynologyUrl': 'Add meg a portot is, például https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Legjobb egy külön DSM-fiók, amely csak ehhez a megosztott mappához fér hozzá.',
'docsync.hintSynologyOtp': 'Csak egyszer kell, ha a fiók kétlépcsős azonosítást használ.',
'docsync.linkState.never': 'Még nincs szinkronizálva',
'docsync.linkState.ok': 'Naprakész',
'docsync.linkState.partial': 'Részben szinkronizálva',
'docsync.linkState.failed': 'Sikertelen',
'docsync.linkState.needs_reauth': 'Jelentkezz be újra',
'docsync.linkState.scope_lost': 'A mappa eltűnt',
'docsync.linkState.orphaned': 'A tulajdonos elhagyta az utat',
'docsync.state.pending': 'Várakozik',
'docsync.state.synced': 'Szinkronizálva',
'docsync.state.conflict': 'Ütközés',
'docsync.state.rejected_type': 'Nem engedélyezett típus',
'docsync.state.too_large': 'Túl nagy',
'docsync.state.error': 'Hiba',
'docsync.state.remote_missing': 'Hiányzik a szolgáltatónál',
'docsync.state.local_deleted': 'Törölve a TREK-ben',
'docsync.state.scope_drift': 'Kikerült a mappából',
'docsync.conflict.title': 'Mindkét példány megváltozott',
'docsync.conflict.keepTrek': 'A TREK-verzió megtartása',
'docsync.conflict.keepProvider': 'A szolgáltató verziójának megtartása',
'docsync.conflict.keepBoth': 'Mindkettő megtartása',
// A hiba okai kódként utaznak, sosem a túloldal szövegeként: a szolgáltató
// angolul válaszol, vagy egy proxy HTML-es bejelentkező oldalával, és egyiknek
// sincs itt helye.
'docsync.error.unreachable': 'A szolgáltató nem érhető el.',
'docsync.error.tls_untrusted':
'A tanúsítvány elutasítva. Engedélyezd a saját aláírású tanúsítványokat, ha megbízol ebben a példányban.',
'docsync.error.unauthorized': 'A hitelesítő adatokat elutasították.',
'docsync.error.forbidden': 'Ennek a fióknak ehhez nincs jogosultsága.',
'docsync.error.not_found': 'Nem található a szolgáltatónál.',
'docsync.error.scope_missing': 'A csatlakoztatott mappa már nem létezik.',
'docsync.error.rate_limited': 'A szolgáltató korlátozza a kéréseinket. A TREK később újra próbálkozik.',
'docsync.error.too_large': 'A fájl nagyobb, mint amennyit a szolgáltató elfogad.',
'docsync.error.unsupported_type': 'A szolgáltató nem fogadja el ezt a fájltípust.',
'docsync.error.quota_exceeded': 'A szolgáltatónál elfogyott a hely.',
'docsync.error.conflict': 'A dokumentum mindkét oldalon megváltozott.',
'docsync.error.checksum_mismatch': 'Az átvitel nem érkezett meg épségben.',
'docsync.error.provider_error': 'A szolgáltató hibát jelzett.',
'docsync.error.timeout': 'A szolgáltató túl sokáig válaszolt.',
'docsync.error.ssrf_blocked': 'Ez a cím nem engedélyezett.',
'docsync.error.mass_delete_guard':
'Egyszerre tűnt el a dokumentumok nagy része, ezért semmi nem változott. Ellenőrizd, hogy a mappa még csatolva van-e.',
'docsync.error.unknown': 'Valami hiba történt.',
'docsync.error.unknown_provider': 'Ez a szolgáltató nem érhető el ezen a példányon.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sinkronisasi dokumen',
'docsync.subtitle': 'Jaga dokumen perjalanan ini tetap selaras dengan penyimpanan dokumen milikmu sendiri.',
'docsync.noProviders': 'Tidak ada penyedia dokumen yang tersedia',
'docsync.noProvidersHint': 'Administrator instans mengaktifkannya di Admin → Addon → Dokumen.',
'docsync.addProvider': 'Hubungkan penyedia',
'docsync.test': 'Uji koneksi',
'docsync.connected': 'Terhubung',
'docsync.chooseFolder': 'Pilih folder',
'docsync.chooseFolderHint':
'Pilih folder, tag, atau ruang yang menjadi milik perjalanan ini. Hanya dokumen di dalamnya yang disinkronkan.',
'docsync.noFolders': 'Belum ada yang ditemukan di instans ini.',
'docsync.newFolderPlaceholder': 'Nama folder baru',
'docsync.createFolder': 'Buat',
'docsync.syncNow': 'Sinkronkan sekarang',
'docsync.unlink': 'Putuskan',
'docsync.syncEnabled': 'Sinkronkan otomatis',
'docsync.direction': 'Arah',
'docsync.directionBoth': 'Dua arah',
'docsync.directionPull': 'Hanya ke dalam TREK',
'docsync.directionPush': 'Hanya ke penyedia',
'docsync.deletePolicy': 'Saat sebuah dokumen dihapus',
'docsync.deleteUnlink': 'Simpan kedua salinan, lepaskan pasangannya',
'docsync.deleteTrash': 'Pindahkan salinan satunya ke tempat sampahnya',
'docsync.webhookHint':
'Tempelkan URL ini di penyediamu agar perubahan langsung tiba. Tanpa itu, TREK memeriksa secara berkala.',
// Bidang formulir koneksi. Kuncinya mencerminkan kolom `label` di
// document_provider_fields, yang menyimpan sufiks kunci, bukan teks.
'docsync.providerUrl': 'Alamat',
'docsync.providerApiToken': 'Token API',
'docsync.providerApiKey': 'Kunci API',
'docsync.providerAppPassword': 'Sandi aplikasi',
'docsync.providerAppToken': 'Token aplikasi',
'docsync.providerUsername': 'Nama pengguna',
'docsync.providerPassword': 'Kata sandi',
'docsync.providerOrganization': 'ID organisasi',
'docsync.providerBasePath': 'Folder dasar',
'docsync.providerOTP': 'Kode dua faktor',
'docsync.allowInsecureTls': 'Terima sertifikat yang ditandatangani sendiri',
'docsync.hintPaperlessToken': 'Buat di Paperless pada My Profile. Token ini membawa seluruh hak akun tersebut.',
'docsync.hintPapraKey':
'Buat di Papra pada API keys. Kunci Papra selalu menjangkau setiap organisasi yang kamu ikuti.',
'docsync.hintPapraOrg': 'ID org_… dari bilah alamat Papra.',
'docsync.hintNextcloudLogin': 'Nama login Nextcloud milikmu, bukan alamat emailmu.',
'docsync.hintNextcloudAppPassword':
'Settings → Security → Create new app password. Jangan pernah pakai kata sandi akunmu.',
'docsync.hintOpenCloudToken': 'Dibuat di OpenCloud pada app tokens.',
'docsync.hintBasePath': 'Tempat TREK mencari folder perjalanan. Default-nya /TREK.',
'docsync.hintSynologyUrl': 'Sertakan portnya, misalnya https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Sebaiknya akun DSM khusus yang hanya punya akses ke folder bersama ini.',
'docsync.hintSynologyOtp': 'Hanya perlu sekali, jika akun memakai autentikasi dua faktor.',
'docsync.linkState.never': 'Belum disinkronkan',
'docsync.linkState.ok': 'Tersinkron',
'docsync.linkState.partial': 'Sebagian tersinkron',
'docsync.linkState.failed': 'Gagal',
'docsync.linkState.needs_reauth': 'Masuk lagi',
'docsync.linkState.scope_lost': 'Folder sudah tidak ada',
'docsync.linkState.orphaned': 'Pemilik meninggalkan perjalanan',
'docsync.state.pending': 'Menunggu',
'docsync.state.synced': 'Tersinkron',
'docsync.state.conflict': 'Konflik',
'docsync.state.rejected_type': 'Tipe tidak diizinkan',
'docsync.state.too_large': 'Terlalu besar',
'docsync.state.error': 'Kesalahan',
'docsync.state.remote_missing': 'Tidak ada di penyedia',
'docsync.state.local_deleted': 'Dihapus di TREK',
'docsync.state.scope_drift': 'Dipindahkan keluar folder',
'docsync.conflict.title': 'Kedua salinan berubah',
'docsync.conflict.keepTrek': 'Pertahankan versi TREK',
'docsync.conflict.keepProvider': 'Pertahankan versi penyedia',
'docsync.conflict.keepBoth': 'Pertahankan keduanya',
// Alasan kegagalan dikirim sebagai kode, bukan sebagai teks dari penyedia:
// penyedia menjawab dalam bahasa Inggris, atau dengan halaman login HTML milik
// proxy, dan keduanya tidak pantas ada di sini.
'docsync.error.unreachable': 'Penyedia tidak dapat dijangkau.',
'docsync.error.tls_untrusted':
'Sertifikat ditolak. Izinkan sertifikat yang ditandatangani sendiri jika kamu memercayai instans ini.',
'docsync.error.unauthorized': 'Kredensial ditolak.',
'docsync.error.forbidden': 'Akun ini tidak diizinkan melakukan itu.',
'docsync.error.not_found': 'Tidak ditemukan di penyedia.',
'docsync.error.scope_missing': 'Folder yang terhubung sudah tidak ada.',
'docsync.error.rate_limited': 'Penyedia membatasi laju permintaan kami. TREK akan mencoba lagi nanti.',
'docsync.error.too_large': 'File lebih besar daripada yang diterima penyedia.',
'docsync.error.unsupported_type': 'Penyedia tidak menerima tipe file ini.',
'docsync.error.quota_exceeded': 'Penyedia kehabisan ruang.',
'docsync.error.conflict': 'Dokumen berubah di kedua sisi.',
'docsync.error.checksum_mismatch': 'Transfer tidak tiba dengan utuh.',
'docsync.error.provider_error': 'Penyedia melaporkan kesalahan.',
'docsync.error.timeout': 'Penyedia terlalu lama menjawab.',
'docsync.error.ssrf_blocked': 'Alamat itu tidak diizinkan.',
'docsync.error.mass_delete_guard':
'Hampir semua dokumen hilang sekaligus, jadi tidak ada yang diubah. Periksa apakah foldernya masih terpasang.',
'docsync.error.unknown': 'Ada yang tidak beres.',
'docsync.error.unknown_provider': 'Penyedia ini tidak tersedia di instans ini.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronizzazione documenti',
'docsync.subtitle': 'Tieni i documenti di questo viaggio allineati con il tuo archivio documenti.',
'docsync.noProviders': 'Nessun provider di documenti disponibile',
'docsync.noProvidersHint': 'Un amministratore dell’istanza li attiva in Admin, Moduli, Documenti.',
'docsync.addProvider': 'Collega un provider',
'docsync.test': 'Prova la connessione',
'docsync.connected': 'Connesso',
'docsync.chooseFolder': 'Scegli la cartella',
'docsync.chooseFolderHint':
'Scegli la cartella, il tag o lo spazio che appartiene a questo viaggio. Vengono sincronizzati solo i documenti al suo interno.',
'docsync.noFolders': 'Non è ancora stato trovato nulla su questa istanza.',
'docsync.newFolderPlaceholder': 'Nome della nuova cartella',
'docsync.createFolder': 'Crea',
'docsync.syncNow': 'Sincronizza ora',
'docsync.unlink': 'Disconnetti',
'docsync.syncEnabled': 'Sincronizza automaticamente',
'docsync.direction': 'Direzione',
'docsync.directionBoth': 'In entrambe le direzioni',
'docsync.directionPull': 'Solo verso TREK',
'docsync.directionPush': 'Solo verso il provider',
'docsync.deletePolicy': 'Quando un documento viene eliminato',
'docsync.deleteUnlink': 'Mantieni entrambe le copie, sciogli l’abbinamento',
'docsync.deleteTrash': 'Sposta l’altra copia nel suo cestino',
'docsync.webhookHint':
'Incolla questo URL nel tuo provider per ricevere subito le modifiche. Senza, TREK controlla a intervalli regolari.',
// Campi del modulo di connessione. Le chiavi rispecchiano la colonna `label` di
// document_provider_fields, che salva un suffisso di chiave invece del testo.
'docsync.providerUrl': 'Indirizzo',
'docsync.providerApiToken': 'Token API',
'docsync.providerApiKey': 'Chiave API',
'docsync.providerAppPassword': 'Password per applicazioni',
'docsync.providerAppToken': 'Token applicativo',
'docsync.providerUsername': 'Nome utente',
'docsync.providerPassword': 'Password',
'docsync.providerOrganization': 'ID organizzazione',
'docsync.providerBasePath': 'Cartella di base',
'docsync.providerOTP': 'Codice a due fattori',
'docsync.allowInsecureTls': 'Accetta un certificato autofirmato',
'docsync.hintPaperlessToken': 'Crealo in Paperless sotto Il mio profilo. Ha tutti i diritti di quell’account.',
'docsync.hintPapraKey':
'Creala in Papra sotto Chiavi API. Le chiavi Papra raggiungono sempre tutte le organizzazioni a cui appartieni.',
'docsync.hintPapraOrg': 'L’id org_… che compare nella barra degli indirizzi di Papra.',
'docsync.hintNextcloudLogin': 'Il tuo nome utente Nextcloud, non l’indirizzo email.',
'docsync.hintNextcloudAppPassword':
'Impostazioni, Sicurezza, Crea nuova password per applicazioni. Mai la password del tuo account.',
'docsync.hintOpenCloudToken': 'Si crea in OpenCloud sotto i token applicativi.',
'docsync.hintBasePath': 'Dove TREK cerca le cartelle dei viaggi. Come impostazione predefinita /TREK.',
'docsync.hintSynologyUrl': 'Indica anche la porta, per esempio https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Meglio un account DSM dedicato, con accesso solo a questa cartella condivisa.',
'docsync.hintSynologyOtp': 'Serve una sola volta, se l’account usa l’autenticazione a due fattori.',
'docsync.linkState.never': 'Non ancora sincronizzato',
'docsync.linkState.ok': 'Allineato',
'docsync.linkState.partial': 'Sincronizzato in parte',
'docsync.linkState.failed': 'Non riuscito',
'docsync.linkState.needs_reauth': 'Accedi di nuovo',
'docsync.linkState.scope_lost': 'La cartella non c’è più',
'docsync.linkState.orphaned': 'Il proprietario ha lasciato il viaggio',
'docsync.state.pending': 'In attesa',
'docsync.state.synced': 'Sincronizzato',
'docsync.state.conflict': 'Conflitto',
'docsync.state.rejected_type': 'Tipo non consentito',
'docsync.state.too_large': 'Troppo grande',
'docsync.state.error': 'Errore',
'docsync.state.remote_missing': 'Assente sul provider',
'docsync.state.local_deleted': 'Eliminato in TREK',
'docsync.state.scope_drift': 'Spostato fuori dalla cartella',
'docsync.conflict.title': 'Entrambe le copie sono cambiate',
'docsync.conflict.keepTrek': 'Mantieni la versione di TREK',
'docsync.conflict.keepProvider': 'Mantieni la versione del provider',
'docsync.conflict.keepBoth': 'Mantieni entrambe',
// I motivi di errore viaggiano come codici, mai come testo del provider: quello risponde
// in inglese, oppure restituisce la pagina di accesso HTML di un proxy, e nessuna delle due
// cose ha senso qui.
'docsync.error.unreachable': 'Impossibile raggiungere il provider.',
'docsync.error.tls_untrusted':
'Il certificato è stato rifiutato. Consenti i certificati autofirmati se ti fidi di questa istanza.',
'docsync.error.unauthorized': 'Le credenziali sono state rifiutate.',
'docsync.error.forbidden': 'Questo account non è autorizzato a farlo.',
'docsync.error.not_found': 'Non trovato sul provider.',
'docsync.error.scope_missing': 'La cartella collegata non esiste più.',
'docsync.error.rate_limited': 'Il provider sta limitando le richieste. TREK riproverà più tardi.',
'docsync.error.too_large': 'Il file supera la dimensione accettata dal provider.',
'docsync.error.unsupported_type': 'Il provider non accetta questo tipo di file.',
'docsync.error.quota_exceeded': 'Il provider ha esaurito lo spazio.',
'docsync.error.conflict': 'Il documento è cambiato da entrambe le parti.',
'docsync.error.checksum_mismatch': 'Il trasferimento non è arrivato integro.',
'docsync.error.provider_error': 'Il provider ha segnalato un errore.',
'docsync.error.timeout': 'Il provider ha impiegato troppo tempo a rispondere.',
'docsync.error.ssrf_blocked': 'Questo indirizzo non è consentito.',
'docsync.error.mass_delete_guard':
'Quasi tutti i documenti sono spariti in una volta, quindi non è stato modificato nulla. Controlla che la cartella sia ancora montata.',
'docsync.error.unknown': 'Qualcosa è andato storto.',
'docsync.error.unknown_provider': 'Questo provider non è disponibile su questa istanza.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+103
View File
@@ -0,0 +1,103 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'ドキュメント同期',
'docsync.subtitle': 'この旅行のドキュメントを、自分のドキュメント保管先と同じ状態に保ちます。',
'docsync.noProviders': '利用できる連携先がありません',
'docsync.noProvidersHint': 'インスタンスの管理者が「管理 → アドオン → ドキュメント」で有効にします。',
'docsync.addProvider': '連携先を接続',
'docsync.test': '接続をテスト',
'docsync.connected': '接続済み',
'docsync.chooseFolder': 'フォルダーを選択',
'docsync.chooseFolderHint':
'この旅行に対応するフォルダー、タグ、スペースを選びます。その中のドキュメントだけが同期されます。',
'docsync.noFolders': 'このインスタンスにはまだ何もありません。',
'docsync.newFolderPlaceholder': '新しいフォルダー名',
'docsync.createFolder': '作成',
'docsync.syncNow': '今すぐ同期',
'docsync.unlink': '接続解除',
'docsync.syncEnabled': '自動で同期',
'docsync.direction': '方向',
'docsync.directionBoth': '双方向',
'docsync.directionPull': 'TREK に取り込むだけ',
'docsync.directionPush': '連携先へ送るだけ',
'docsync.deletePolicy': 'ドキュメントが削除されたとき',
'docsync.deleteUnlink': '両方のコピーを残し、対応付けだけを解除する',
'docsync.deleteTrash': 'もう一方のコピーをゴミ箱へ移動する',
'docsync.webhookHint':
'この URL を連携先に貼り付けると、変更がすぐに届きます。設定しない場合、TREK は一定間隔で確認します。',
// 接続フォームの項目。キーは document_provider_fields の `label` 列に対応し、
// この列にはテキストではなくキーの末尾だけが入ります。
'docsync.providerUrl': 'アドレス',
'docsync.providerApiToken': 'API トークン',
'docsync.providerApiKey': 'API キー',
'docsync.providerAppPassword': 'アプリパスワード',
'docsync.providerAppToken': 'アプリトークン',
'docsync.providerUsername': 'ユーザー名',
'docsync.providerPassword': 'パスワード',
'docsync.providerOrganization': '組織 ID',
'docsync.providerBasePath': '基準フォルダー',
'docsync.providerOTP': '二段階認証コード',
'docsync.allowInsecureTls': '自己署名証明書を許可',
'docsync.hintPaperlessToken': 'Paperless の「My Profile」で作成します。そのアカウントの権限をすべて引き継ぎます。',
'docsync.hintPapraKey': 'Papra の「API keys」で作成します。Papra のキーは所属するすべての組織に届きます。',
'docsync.hintPapraOrg': 'Papra のアドレスバーに表示される org_… の ID です。',
'docsync.hintNextcloudLogin': 'Nextcloud のログイン名です。メールアドレスではありません。',
'docsync.hintNextcloudAppPassword':
'「設定 → セキュリティ → 新しいアプリパスワードを作成」で発行します。アカウントのパスワードは使わないでください。',
'docsync.hintOpenCloudToken': 'OpenCloud のアプリトークンとして作成します。',
'docsync.hintBasePath': 'TREK が旅行用フォルダーを探す場所です。既定は /TREK です。',
'docsync.hintSynologyUrl': 'ポート番号も含めてください。例:https://nas.example.com:5001',
'docsync.hintSynologyUser': 'この共有フォルダーだけにアクセスできる専用の DSM アカウントが最適です。',
'docsync.hintSynologyOtp': 'アカウントで二段階認証を使っている場合に、最初の一度だけ必要です。',
'docsync.linkState.never': 'まだ同期していません',
'docsync.linkState.ok': '同期済み',
'docsync.linkState.partial': '一部のみ同期',
'docsync.linkState.failed': '失敗',
'docsync.linkState.needs_reauth': '再度サインインしてください',
'docsync.linkState.scope_lost': 'フォルダーが見つかりません',
'docsync.linkState.orphaned': '所有者が旅行から抜けました',
'docsync.state.pending': '待機中',
'docsync.state.synced': '同期済み',
'docsync.state.conflict': '競合',
'docsync.state.rejected_type': '許可されていない形式',
'docsync.state.too_large': 'サイズ超過',
'docsync.state.error': 'エラー',
'docsync.state.remote_missing': '連携先に見つかりません',
'docsync.state.local_deleted': 'TREK で削除済み',
'docsync.state.scope_drift': 'フォルダー外へ移動',
'docsync.conflict.title': '両方のコピーが変更されました',
'docsync.conflict.keepTrek': 'TREK のバージョンを残す',
'docsync.conflict.keepProvider': '連携先のバージョンを残す',
'docsync.conflict.keepBoth': '両方を残す',
// 失敗の理由はコードとして扱い、連携先の文面をそのまま出しません。相手は英語で
// 応答したり、プロキシのログイン画面の HTML を返したりするためです。
'docsync.error.unreachable': '連携先に接続できませんでした。',
'docsync.error.tls_untrusted':
'証明書が拒否されました。このインスタンスを信頼できる場合は、自己署名証明書を許可してください。',
'docsync.error.unauthorized': '認証情報が拒否されました。',
'docsync.error.forbidden': 'このアカウントにはその権限がありません。',
'docsync.error.not_found': '連携先に見つかりませんでした。',
'docsync.error.scope_missing': '接続されたフォルダーはすでに存在しません。',
'docsync.error.rate_limited': '連携先が要求を制限しています。TREK が後でもう一度試します。',
'docsync.error.too_large': 'このファイルは連携先が受け付けるサイズを超えています。',
'docsync.error.unsupported_type': '連携先はこのファイル形式を受け付けません。',
'docsync.error.quota_exceeded': '連携先の空き容量がありません。',
'docsync.error.conflict': 'このドキュメントは両方で変更されました。',
'docsync.error.checksum_mismatch': '転送が完全な形で届きませんでした。',
'docsync.error.provider_error': '連携先がエラーを返しました。',
'docsync.error.timeout': '連携先の応答に時間がかかりすぎました。',
'docsync.error.ssrf_blocked': 'このアドレスは許可されていません。',
'docsync.error.mass_delete_guard':
'多数のドキュメントが一度に消えたため、何も変更しませんでした。フォルダーがまだマウントされているか確認してください。',
'docsync.error.unknown': '問題が発生しました。',
'docsync.error.unknown_provider': 'この連携先はこのインスタンスでは利用できません。',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+101
View File
@@ -0,0 +1,101 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '문서 동기화',
'docsync.subtitle': '이 여행의 문서를 직접 운영하는 문서 저장소와 같은 상태로 유지합니다.',
'docsync.noProviders': '사용할 수 있는 문서 저장소가 없습니다',
'docsync.noProvidersHint': '인스턴스 관리자가 관리 → 애드온 → 문서에서 켤 수 있습니다.',
'docsync.addProvider': '저장소 연결',
'docsync.test': '연결 테스트',
'docsync.connected': '연결됨',
'docsync.chooseFolder': '폴더 선택',
'docsync.chooseFolderHint': '이 여행에 해당하는 폴더, 태그 또는 공간을 고르세요. 그 안의 문서만 동기화됩니다.',
'docsync.noFolders': '이 인스턴스에서 아직 아무것도 찾지 못했습니다.',
'docsync.newFolderPlaceholder': '새 폴더 이름',
'docsync.createFolder': '만들기',
'docsync.syncNow': '지금 동기화',
'docsync.unlink': '연결 해제',
'docsync.syncEnabled': '자동으로 동기화',
'docsync.direction': '방향',
'docsync.directionBoth': '양방향',
'docsync.directionPull': 'TREK으로 가져오기만',
'docsync.directionPush': '저장소로 보내기만',
'docsync.deletePolicy': '문서가 삭제될 때',
'docsync.deleteUnlink': '양쪽 사본을 그대로 두고 연결만 해제',
'docsync.deleteTrash': '다른 쪽 사본을 휴지통으로 이동',
'docsync.webhookHint':
'이 URL을 저장소에 붙여넣으면 변경 사항이 바로 전달됩니다. 설정하지 않으면 TREK이 주기적으로 확인합니다.',
// 연결 양식의 항목. 키는 document_provider_fields의 `label` 열과 짝을 이루며,
// 이 열에는 문구가 아니라 키의 뒷부분만 저장됩니다.
'docsync.providerUrl': '주소',
'docsync.providerApiToken': 'API 토큰',
'docsync.providerApiKey': 'API 키',
'docsync.providerAppPassword': '앱 비밀번호',
'docsync.providerAppToken': '앱 토큰',
'docsync.providerUsername': '사용자 이름',
'docsync.providerPassword': '비밀번호',
'docsync.providerOrganization': '조직 ID',
'docsync.providerBasePath': '기준 폴더',
'docsync.providerOTP': '2단계 인증 코드',
'docsync.allowInsecureTls': '자체 서명 인증서 허용',
'docsync.hintPaperlessToken': 'Paperless의 My Profile에서 만듭니다. 해당 계정의 모든 권한을 그대로 가집니다.',
'docsync.hintPapraKey': 'Papra의 API keys에서 만듭니다. Papra 키는 언제나 소속된 모든 조직에 접근합니다.',
'docsync.hintPapraOrg': 'Papra 주소 표시줄에 있는 org_… 아이디입니다.',
'docsync.hintNextcloudLogin': 'Nextcloud 로그인 이름입니다. 이메일 주소가 아닙니다.',
'docsync.hintNextcloudAppPassword':
'설정 → 보안 → 새 앱 비밀번호 만들기에서 발급합니다. 계정 비밀번호는 쓰지 마세요.',
'docsync.hintOpenCloudToken': 'OpenCloud의 앱 토큰에서 만듭니다.',
'docsync.hintBasePath': 'TREK이 여행 폴더를 찾는 위치입니다. 기본값은 /TREK입니다.',
'docsync.hintSynologyUrl': '포트까지 포함하세요. 예: https://nas.example.com:5001',
'docsync.hintSynologyUser': '이 공유 폴더에만 접근할 수 있는 전용 DSM 계정을 권장합니다.',
'docsync.hintSynologyOtp': '계정에서 2단계 인증을 사용하는 경우 처음 한 번만 필요합니다.',
'docsync.linkState.never': '아직 동기화하지 않았습니다',
'docsync.linkState.ok': '동기화됨',
'docsync.linkState.partial': '일부만 동기화됨',
'docsync.linkState.failed': '실패',
'docsync.linkState.needs_reauth': '다시 로그인하세요',
'docsync.linkState.scope_lost': '폴더가 사라졌습니다',
'docsync.linkState.orphaned': '소유자가 여행에서 나갔습니다',
'docsync.state.pending': '대기 중',
'docsync.state.synced': '동기화됨',
'docsync.state.conflict': '충돌',
'docsync.state.rejected_type': '허용되지 않는 형식',
'docsync.state.too_large': '용량 초과',
'docsync.state.error': '오류',
'docsync.state.remote_missing': '저장소에 없음',
'docsync.state.local_deleted': 'TREK에서 삭제됨',
'docsync.state.scope_drift': '폴더 밖으로 이동됨',
'docsync.conflict.title': '양쪽 사본이 모두 바뀌었습니다',
'docsync.conflict.keepTrek': 'TREK 버전 유지',
'docsync.conflict.keepProvider': '저장소 버전 유지',
'docsync.conflict.keepBoth': '둘 다 유지',
// 실패 사유는 코드로 전달하며, 저장소가 보낸 문구를 그대로 쓰지 않습니다. 상대는
// 영어로 답하거나 프록시의 로그인 화면 HTML을 돌려주기도 합니다.
'docsync.error.unreachable': '저장소에 연결하지 못했습니다.',
'docsync.error.tls_untrusted': '인증서가 거부되었습니다. 이 인스턴스를 신뢰한다면 자체 서명 인증서를 허용하세요.',
'docsync.error.unauthorized': '인증 정보가 거부되었습니다.',
'docsync.error.forbidden': '이 계정에는 그럴 권한이 없습니다.',
'docsync.error.not_found': '저장소에서 찾을 수 없습니다.',
'docsync.error.scope_missing': '연결된 폴더가 더 이상 존재하지 않습니다.',
'docsync.error.rate_limited': '저장소가 요청 속도를 제한하고 있습니다. TREK이 나중에 다시 시도합니다.',
'docsync.error.too_large': '저장소가 허용하는 크기보다 파일이 큽니다.',
'docsync.error.unsupported_type': '저장소가 이 파일 형식을 받지 않습니다.',
'docsync.error.quota_exceeded': '저장소에 남은 공간이 없습니다.',
'docsync.error.conflict': '이 문서가 양쪽에서 바뀌었습니다.',
'docsync.error.checksum_mismatch': '전송된 내용이 온전하지 않습니다.',
'docsync.error.provider_error': '저장소가 오류를 반환했습니다.',
'docsync.error.timeout': '저장소의 응답이 너무 오래 걸렸습니다.',
'docsync.error.ssrf_blocked': '허용되지 않는 주소입니다.',
'docsync.error.mass_delete_guard':
'많은 문서가 한꺼번에 사라져 아무것도 변경하지 않았습니다. 폴더가 아직 마운트되어 있는지 확인하세요.',
'docsync.error.unknown': '문제가 발생했습니다.',
'docsync.error.unknown_provider': '이 저장소는 이 인스턴스에서 사용할 수 없습니다.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Documentsynchronisatie',
'docsync.subtitle': 'Houd de documenten van deze reis gelijk met je eigen documentopslag.',
'docsync.noProviders': 'Er zijn geen documentaanbieders beschikbaar',
'docsync.noProvidersHint': 'Een beheerder van deze instantie zet ze aan onder Beheer, Add-ons, Documenten.',
'docsync.addProvider': 'Aanbieder koppelen',
'docsync.test': 'Verbinding testen',
'docsync.connected': 'Verbonden',
'docsync.chooseFolder': 'Map kiezen',
'docsync.chooseFolderHint':
'Kies de map, tag of ruimte die bij deze reis hoort. Alleen documenten daarin worden gesynchroniseerd.',
'docsync.noFolders': 'Nog niets gevonden op deze instantie.',
'docsync.newFolderPlaceholder': 'Naam van de nieuwe map',
'docsync.createFolder': 'Aanmaken',
'docsync.syncNow': 'Nu synchroniseren',
'docsync.unlink': 'Verbinding verbreken',
'docsync.syncEnabled': 'Automatisch synchroniseren',
'docsync.direction': 'Richting',
'docsync.directionBoth': 'Beide richtingen',
'docsync.directionPull': 'Alleen naar TREK',
'docsync.directionPush': 'Alleen naar de aanbieder',
'docsync.deletePolicy': 'Als een document wordt verwijderd',
'docsync.deleteUnlink': 'Beide kopieën behouden, de koppeling loslaten',
'docsync.deleteTrash': 'De andere kopie naar de prullenbak verplaatsen',
'docsync.webhookHint':
'Plak deze URL bij je aanbieder, dan komen wijzigingen meteen binnen. Zonder die URL kijkt TREK op vaste tijden.',
// Velden van het verbindingsformulier. De keys volgen de kolom `label` in
// document_provider_fields, die een key-achtervoegsel bewaart en geen tekst.
'docsync.providerUrl': 'Adres',
'docsync.providerApiToken': 'API-token',
'docsync.providerApiKey': 'API-sleutel',
'docsync.providerAppPassword': 'App-wachtwoord',
'docsync.providerAppToken': 'App-token',
'docsync.providerUsername': 'Gebruikersnaam',
'docsync.providerPassword': 'Wachtwoord',
'docsync.providerOrganization': 'Organisatie-ID',
'docsync.providerBasePath': 'Basismap',
'docsync.providerOTP': 'Tweestapscode',
'docsync.allowInsecureTls': 'Zelfondertekend certificaat toestaan',
'docsync.hintPaperlessToken':
'Maak er een aan in Paperless onder My Profile. Hij draagt alle rechten van dat account.',
'docsync.hintPapraKey':
'Maak er een aan in Papra onder API keys. Papra-sleutels reiken altijd tot elke organisatie waar je bij hoort.',
'docsync.hintPapraOrg': 'De org_…-id uit de adresbalk van Papra.',
'docsync.hintNextcloudLogin': 'Je Nextcloud-inlognaam, niet je e-mailadres.',
'docsync.hintNextcloudAppPassword':
'Instellingen, Beveiliging, Nieuw app-wachtwoord aanmaken. Nooit je accountwachtwoord.',
'docsync.hintOpenCloudToken': 'Wordt in OpenCloud aangemaakt onder app-tokens.',
'docsync.hintBasePath': 'Waar TREK naar reismappen zoekt. Standaard /TREK.',
'docsync.hintSynologyUrl': 'Vermeld de poort, bijvoorbeeld https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Het beste een apart DSM-account dat alleen bij deze gedeelde map kan.',
'docsync.hintSynologyOtp': 'Maar één keer nodig, als het account tweestapsverificatie gebruikt.',
'docsync.linkState.never': 'Nog niet gesynchroniseerd',
'docsync.linkState.ok': 'Synchroon',
'docsync.linkState.partial': 'Deels gesynchroniseerd',
'docsync.linkState.failed': 'Mislukt',
'docsync.linkState.needs_reauth': 'Opnieuw aanmelden',
'docsync.linkState.scope_lost': 'De map is weg',
'docsync.linkState.orphaned': 'Eigenaar heeft de reis verlaten',
'docsync.state.pending': 'Wacht',
'docsync.state.synced': 'Gesynchroniseerd',
'docsync.state.conflict': 'Conflict',
'docsync.state.rejected_type': 'Bestandstype niet toegestaan',
'docsync.state.too_large': 'Te groot',
'docsync.state.error': 'Fout',
'docsync.state.remote_missing': 'Ontbreekt bij de aanbieder',
'docsync.state.local_deleted': 'Verwijderd in TREK',
'docsync.state.scope_drift': 'Buiten de map verplaatst',
'docsync.conflict.title': 'Beide kopieën zijn gewijzigd',
'docsync.conflict.keepTrek': 'De TREK-versie behouden',
'docsync.conflict.keepProvider': 'De versie van de aanbieder behouden',
'docsync.conflict.keepBoth': 'Allebei behouden',
// Foutredenen komen als code binnen, nooit als tekst van de aanbieder: die
// antwoordt in het Engels of met de HTML-inlogpagina van een proxy, en geen van beide hoort hier.
'docsync.error.unreachable': 'De aanbieder was niet bereikbaar.',
'docsync.error.tls_untrusted':
'Het certificaat is geweigerd. Sta zelfondertekende certificaten toe als je deze instantie vertrouwt.',
'docsync.error.unauthorized': 'De inloggegevens zijn geweigerd.',
'docsync.error.forbidden': 'Dit account mag dat niet.',
'docsync.error.not_found': 'Niet gevonden bij de aanbieder.',
'docsync.error.scope_missing': 'De gekoppelde map bestaat niet meer.',
'docsync.error.rate_limited': 'De aanbieder remt ons af. TREK probeert het later opnieuw.',
'docsync.error.too_large': 'Het bestand is groter dan de aanbieder accepteert.',
'docsync.error.unsupported_type': 'De aanbieder accepteert dit bestandstype niet.',
'docsync.error.quota_exceeded': 'De aanbieder heeft geen ruimte meer.',
'docsync.error.conflict': 'Het document is aan beide kanten gewijzigd.',
'docsync.error.checksum_mismatch': 'De overdracht is niet intact aangekomen.',
'docsync.error.provider_error': 'De aanbieder meldde een fout.',
'docsync.error.timeout': 'De aanbieder deed er te lang over om te antwoorden.',
'docsync.error.ssrf_blocked': 'Dat adres is niet toegestaan.',
'docsync.error.mass_delete_guard':
'Bijna alle documenten verdwenen in één keer, dus er is niets gewijzigd. Controleer of de map nog gekoppeld is.',
'docsync.error.unknown': 'Er ging iets mis.',
'docsync.error.unknown_provider': 'Deze aanbieder is niet beschikbaar op deze instantie.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronizacja dokumentów',
'docsync.subtitle': 'Utrzymuj dokumenty tej podróży w zgodzie z własnym repozytorium dokumentów.',
'docsync.noProviders': 'Brak dostępnych dostawców dokumentów',
'docsync.noProvidersHint': 'Administrator instancji włącza ich w sekcji Administracja → Dodatki → Dokumenty.',
'docsync.addProvider': 'Połącz dostawcę',
'docsync.test': 'Testuj połączenie',
'docsync.connected': 'Połączono',
'docsync.chooseFolder': 'Wybierz folder',
'docsync.chooseFolderHint':
'Wskaż folder, tag lub przestrzeń należącą do tej podróży. Synchronizowane są tylko dokumenty, które się w niej znajdują.',
'docsync.noFolders': 'Na tej instancji nic jeszcze nie znaleziono.',
'docsync.newFolderPlaceholder': 'Nazwa nowego folderu',
'docsync.createFolder': 'Utwórz',
'docsync.syncNow': 'Synchronizuj teraz',
'docsync.unlink': 'Rozłącz',
'docsync.syncEnabled': 'Synchronizuj automatycznie',
'docsync.direction': 'Kierunek',
'docsync.directionBoth': 'W obie strony',
'docsync.directionPull': 'Tylko do TREK',
'docsync.directionPush': 'Tylko do dostawcy',
'docsync.deletePolicy': 'Gdy dokument zostanie usunięty',
'docsync.deleteUnlink': 'Zachowaj obie kopie, usuń powiązanie',
'docsync.deleteTrash': 'Przenieś drugą kopię do jej kosza',
'docsync.webhookHint':
'Wklej ten adres URL u swojego dostawcy, aby zmiany docierały natychmiast. Bez tego TREK sprawdza je co jakiś czas.',
// Pola formularza połączenia. Klucze odpowiadają kolumnie `label` w tabeli
// document_provider_fields, która przechowuje sufiks klucza, a nie tekst.
'docsync.providerUrl': 'Adres',
'docsync.providerApiToken': 'Token API',
'docsync.providerApiKey': 'Klucz API',
'docsync.providerAppPassword': 'Hasło aplikacji',
'docsync.providerAppToken': 'Token aplikacji',
'docsync.providerUsername': 'Nazwa użytkownika',
'docsync.providerPassword': 'Hasło',
'docsync.providerOrganization': 'Identyfikator organizacji',
'docsync.providerBasePath': 'Folder bazowy',
'docsync.providerOTP': 'Kod dwuskładnikowy',
'docsync.allowInsecureTls': 'Zezwalaj na certyfikat z własnym podpisem',
'docsync.hintPaperlessToken': 'Utwórz go w Paperless-ngx w sekcji My Profile. Ma pełne uprawnienia tego konta.',
'docsync.hintPapraKey':
'Utwórz go w Papra w sekcji API keys. Klucze Papra zawsze obejmują wszystkie organizacje, do których należysz.',
'docsync.hintPapraOrg': 'Identyfikator org_… z paska adresu Papra.',
'docsync.hintNextcloudLogin': 'Twoja nazwa logowania w Nextcloud, nie adres e-mail.',
'docsync.hintNextcloudAppPassword':
'Ustawienia → Bezpieczeństwo → Utwórz nowe hasło aplikacji. Nigdy hasło do konta.',
'docsync.hintOpenCloudToken': 'Tworzony w OpenCloud w sekcji tokenów aplikacji.',
'docsync.hintBasePath': 'Miejsce, w którym TREK szuka folderów podróży. Domyślnie /TREK.',
'docsync.hintSynologyUrl': 'Podaj również port, na przykład https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Najlepiej osobne konto DSM z dostępem tylko do tego folderu współdzielonego.',
'docsync.hintSynologyOtp': 'Potrzebny tylko raz, jeśli konto używa uwierzytelniania dwuskładnikowego.',
'docsync.linkState.never': 'Jeszcze nie synchronizowano',
'docsync.linkState.ok': 'Aktualne',
'docsync.linkState.partial': 'Częściowo zsynchronizowane',
'docsync.linkState.failed': 'Niepowodzenie',
'docsync.linkState.needs_reauth': 'Zaloguj się ponownie',
'docsync.linkState.scope_lost': 'Folder zniknął',
'docsync.linkState.orphaned': 'Właściciel opuścił podróż',
'docsync.state.pending': 'Oczekuje',
'docsync.state.synced': 'Zsynchronizowany',
'docsync.state.conflict': 'Konflikt',
'docsync.state.rejected_type': 'Typ niedozwolony',
'docsync.state.too_large': 'Zbyt duży',
'docsync.state.error': 'Błąd',
'docsync.state.remote_missing': 'Brak u dostawcy',
'docsync.state.local_deleted': 'Usunięty w TREK',
'docsync.state.scope_drift': 'Przeniesiony poza folder',
'docsync.conflict.title': 'Obie kopie zostały zmienione',
'docsync.conflict.keepTrek': 'Zachowaj wersję z TREK',
'docsync.conflict.keepProvider': 'Zachowaj wersję dostawcy',
'docsync.conflict.keepBoth': 'Zachowaj obie',
// Przyczyny błędów przesyłane są jako kody, nigdy jako tekst od dostawcy:
// ten odpowiada po angielsku albo stroną logowania proxy w HTML, a żadna z tych
// rzeczy nie należy do interfejsu.
'docsync.error.unreachable': 'Nie udało się połączyć z dostawcą.',
'docsync.error.tls_untrusted':
'Certyfikat został odrzucony. Zezwól na certyfikaty z własnym podpisem, jeśli ufasz tej instancji.',
'docsync.error.unauthorized': 'Dane logowania zostały odrzucone.',
'docsync.error.forbidden': 'To konto nie ma do tego uprawnień.',
'docsync.error.not_found': 'Nie znaleziono u dostawcy.',
'docsync.error.scope_missing': 'Połączony folder już nie istnieje.',
'docsync.error.rate_limited': 'Dostawca ogranicza liczbę zapytań. TREK spróbuje ponownie później.',
'docsync.error.too_large': 'Plik jest większy, niż akceptuje dostawca.',
'docsync.error.unsupported_type': 'Dostawca nie przyjmuje tego typu pliku.',
'docsync.error.quota_exceeded': 'U dostawcy zabrakło miejsca.',
'docsync.error.conflict': 'Dokument zmienił się po obu stronach.',
'docsync.error.checksum_mismatch': 'Transfer nie dotarł w całości.',
'docsync.error.provider_error': 'Dostawca zgłosił błąd.',
'docsync.error.timeout': 'Dostawca odpowiadał zbyt długo.',
'docsync.error.ssrf_blocked': 'Ten adres jest niedozwolony.',
'docsync.error.mass_delete_guard':
'Naraz zniknęła większość dokumentów, więc nic nie zostało zmienione. Sprawdź, czy folder jest nadal podłączony.',
'docsync.error.unknown': 'Coś poszło nie tak.',
'docsync.error.unknown_provider': 'Ten dostawca nie jest dostępny na tej instancji.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+106
View File
@@ -0,0 +1,106 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Синхронизация документов',
'docsync.subtitle': 'Держите документы этой поездки в одном состоянии с вашим собственным хранилищем документов.',
'docsync.noProviders': 'Нет доступных провайдеров документов',
'docsync.noProvidersHint':
'Администратор экземпляра включает их в разделе «Администрирование → Дополнения → Документы».',
'docsync.addProvider': 'Подключить провайдера',
'docsync.test': 'Проверить подключение',
'docsync.connected': 'Подключено',
'docsync.chooseFolder': 'Выбрать папку',
'docsync.chooseFolderHint':
'Выберите папку, метку или пространство, которые относятся к этой поездке. Синхронизируются только документы из них.',
'docsync.noFolders': 'В этом экземпляре пока ничего не найдено.',
'docsync.newFolderPlaceholder': 'Название новой папки',
'docsync.createFolder': 'Создать',
'docsync.syncNow': 'Синхронизировать сейчас',
'docsync.unlink': 'Отключить',
'docsync.syncEnabled': 'Синхронизировать автоматически',
'docsync.direction': 'Направление',
'docsync.directionBoth': 'В обе стороны',
'docsync.directionPull': 'Только в TREK',
'docsync.directionPush': 'Только к провайдеру',
'docsync.deletePolicy': 'Когда документ удалён',
'docsync.deleteUnlink': 'Оставить обе копии, убрать связь',
'docsync.deleteTrash': 'Переместить вторую копию в её корзину',
'docsync.webhookHint':
'Вставьте этот URL у своего провайдера, чтобы изменения приходили сразу. Без него TREK проверяет по таймеру.',
// Поля формы подключения. Ключи повторяют столбец `label` в
// document_provider_fields, где хранится суффикс ключа, а не текст.
'docsync.providerUrl': 'Адрес',
'docsync.providerApiToken': 'Токен API',
'docsync.providerApiKey': 'Ключ API',
'docsync.providerAppPassword': 'Пароль приложения',
'docsync.providerAppToken': 'Токен приложения',
'docsync.providerUsername': 'Имя пользователя',
'docsync.providerPassword': 'Пароль',
'docsync.providerOrganization': 'ID организации',
'docsync.providerBasePath': 'Базовая папка',
'docsync.providerOTP': 'Код двухфакторной проверки',
'docsync.allowInsecureTls': 'Принимать самоподписанный сертификат',
'docsync.hintPaperlessToken':
'Создайте его в Paperless в разделе «Мой профиль». Он даёт все права этой учётной записи.',
'docsync.hintPapraKey':
'Создайте его в Papra в разделе «Ключи API». Ключи Papra всегда охватывают все организации, в которых вы состоите.',
'docsync.hintPapraOrg': 'Идентификатор org_… из адресной строки Papra.',
'docsync.hintNextcloudLogin': 'Ваше имя для входа в Nextcloud, а не адрес электронной почты.',
'docsync.hintNextcloudAppPassword':
'«Настройки → Безопасность → Создать новый пароль приложения». Никогда не пароль от учётной записи.',
'docsync.hintOpenCloudToken': 'Создаётся в OpenCloud в разделе токенов приложений.',
'docsync.hintBasePath': 'Где TREK ищет папки поездок. По умолчанию /TREK.',
'docsync.hintSynologyUrl': 'Укажите порт, например https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Лучше отдельная учётная запись DSM с доступом только к этой общей папке.',
'docsync.hintSynologyOtp': 'Нужен только один раз, если в учётной записи включена двухфакторная аутентификация.',
'docsync.linkState.never': 'Ещё не синхронизировано',
'docsync.linkState.ok': 'Всё синхронизировано',
'docsync.linkState.partial': 'Синхронизировано частично',
'docsync.linkState.failed': 'Не удалось',
'docsync.linkState.needs_reauth': 'Войдите снова',
'docsync.linkState.scope_lost': 'Папка исчезла',
'docsync.linkState.orphaned': 'Владелец покинул поездку',
'docsync.state.pending': 'Ожидает',
'docsync.state.synced': 'Синхронизировано',
'docsync.state.conflict': 'Конфликт',
'docsync.state.rejected_type': 'Тип не разрешён',
'docsync.state.too_large': 'Слишком большой',
'docsync.state.error': 'Ошибка',
'docsync.state.remote_missing': 'Отсутствует у провайдера',
'docsync.state.local_deleted': 'Удалено в TREK',
'docsync.state.scope_drift': 'Перемещено за пределы папки',
'docsync.conflict.title': 'Изменились обе копии',
'docsync.conflict.keepTrek': 'Оставить версию TREK',
'docsync.conflict.keepProvider': 'Оставить версию провайдера',
'docsync.conflict.keepBoth': 'Оставить обе',
// Причины сбоя передаются кодами, а не текстом провайдера: он отвечает
// по-английски или HTML-страницей входа от прокси, и ни тому, ни другому здесь не место.
'docsync.error.unreachable': 'Не удалось связаться с провайдером.',
'docsync.error.tls_untrusted':
'Сертификат отклонён. Разрешите самоподписанные сертификаты, если доверяете этому экземпляру.',
'docsync.error.unauthorized': 'Учётные данные отклонены.',
'docsync.error.forbidden': 'Этой учётной записи такое не разрешено.',
'docsync.error.not_found': 'У провайдера не найдено.',
'docsync.error.scope_missing': 'Подключённой папки больше не существует.',
'docsync.error.rate_limited': 'Провайдер ограничивает частоту запросов. TREK повторит попытку позже.',
'docsync.error.too_large': 'Файл больше, чем принимает провайдер.',
'docsync.error.unsupported_type': 'Провайдер не принимает файлы этого типа.',
'docsync.error.quota_exceeded': 'У провайдера закончилось место.',
'docsync.error.conflict': 'Документ изменился с обеих сторон.',
'docsync.error.checksum_mismatch': 'Данные пришли повреждёнными.',
'docsync.error.provider_error': 'Провайдер сообщил об ошибке.',
'docsync.error.timeout': 'Провайдер слишком долго отвечал.',
'docsync.error.ssrf_blocked': 'Этот адрес не разрешён.',
'docsync.error.mass_delete_guard':
'Сразу исчезло большинство документов, поэтому ничего не изменено. Проверьте, что папка всё ещё подключена.',
'docsync.error.unknown': 'Что-то пошло не так.',
'docsync.error.unknown_provider': 'Этот провайдер недоступен в этом экземпляре.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+102
View File
@@ -0,0 +1,102 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentsynkronisering',
'docsync.subtitle': 'Håll resans dokument i takt med ditt eget dokumentarkiv.',
'docsync.noProviders': 'Inga dokumentleverantörer är tillgängliga',
'docsync.noProvidersHint': 'En administratör för instansen slår på dem under Administration, Tillägg, Dokument.',
'docsync.addProvider': 'Anslut en leverantör',
'docsync.test': 'Testa anslutningen',
'docsync.connected': 'Ansluten',
'docsync.chooseFolder': 'Välj mapp',
'docsync.chooseFolderHint':
'Välj den mapp, tagg eller yta som hör till den här resan. Bara dokument i den synkroniseras.',
'docsync.noFolders': 'Ingenting hittades på den här instansen än.',
'docsync.newFolderPlaceholder': 'Namn på ny mapp',
'docsync.createFolder': 'Skapa',
'docsync.syncNow': 'Synkronisera nu',
'docsync.unlink': 'Koppla från',
'docsync.syncEnabled': 'Synkronisera automatiskt',
'docsync.direction': 'Riktning',
'docsync.directionBoth': 'Åt båda hållen',
'docsync.directionPull': 'Bara in till TREK',
'docsync.directionPush': 'Bara ut till leverantören',
'docsync.deletePolicy': 'När ett dokument tas bort',
'docsync.deleteUnlink': 'Behåll båda kopiorna, släpp kopplingen',
'docsync.deleteTrash': 'Flytta den andra kopian till papperskorgen',
'docsync.webhookHint':
'Klistra in den här URL:en hos din leverantör så kommer ändringar direkt. Utan den kontrollerar TREK med jämna mellanrum.',
// Fält i anslutningsformuläret. Nycklarna speglar kolumnen `label` i
// document_provider_fields, som lagrar ett nyckelsuffix i stället för text.
'docsync.providerUrl': 'Adress',
'docsync.providerApiToken': 'API-token',
'docsync.providerApiKey': 'API-nyckel',
'docsync.providerAppPassword': 'Applösenord',
'docsync.providerAppToken': 'App-token',
'docsync.providerUsername': 'Användarnamn',
'docsync.providerPassword': 'Lösenord',
'docsync.providerOrganization': 'Organisations-ID',
'docsync.providerBasePath': 'Basmapp',
'docsync.providerOTP': 'Tvåfaktorskod',
'docsync.allowInsecureTls': 'Tillåt självsignerat certifikat',
'docsync.hintPaperlessToken': 'Skapa en under My Profile i Paperless. Den bär kontots fulla rättigheter.',
'docsync.hintPapraKey': 'Skapa en under API keys i Papra. Papra-nycklar når alltid alla organisationer du tillhör.',
'docsync.hintPapraOrg': 'Det org_…-id som står i adressfältet i Papra.',
'docsync.hintNextcloudLogin': 'Ditt inloggningsnamn i Nextcloud, inte din e-postadress.',
'docsync.hintNextcloudAppPassword': 'Inställningar, Säkerhet, Skapa nytt applösenord. Aldrig ditt kontolösenord.',
'docsync.hintOpenCloudToken': 'Skapas under apptokens i OpenCloud.',
'docsync.hintBasePath': 'Där TREK letar efter resmappar. Standard är /TREK.',
'docsync.hintSynologyUrl': 'Ta med porten, till exempel https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Helst ett eget DSM-konto med åtkomst till bara den här delade mappen.',
'docsync.hintSynologyOtp': 'Behövs bara en gång, om kontot använder tvåfaktorsautentisering.',
'docsync.linkState.never': 'Inte synkroniserad än',
'docsync.linkState.ok': 'Synkroniserad',
'docsync.linkState.partial': 'Delvis synkroniserad',
'docsync.linkState.failed': 'Misslyckades',
'docsync.linkState.needs_reauth': 'Logga in igen',
'docsync.linkState.scope_lost': 'Mappen är borta',
'docsync.linkState.orphaned': 'Ägaren lämnade resan',
'docsync.state.pending': 'Väntar',
'docsync.state.synced': 'Synkroniserad',
'docsync.state.conflict': 'Konflikt',
'docsync.state.rejected_type': 'Filtypen tillåts inte',
'docsync.state.too_large': 'För stor',
'docsync.state.error': 'Fel',
'docsync.state.remote_missing': 'Saknas hos leverantören',
'docsync.state.local_deleted': 'Borttagen i TREK',
'docsync.state.scope_drift': 'Flyttad ut ur mappen',
'docsync.conflict.title': 'Båda kopiorna har ändrats',
'docsync.conflict.keepTrek': 'Behåll TREK-versionen',
'docsync.conflict.keepProvider': 'Behåll leverantörens version',
'docsync.conflict.keepBoth': 'Behåll båda',
// Felorsaker kommer som koder, aldrig som text från leverantören: den svarar på
// engelska, eller med en proxys HTML-inloggningssida, och inget av det hör hemma här.
'docsync.error.unreachable': 'Det gick inte att nå leverantören.',
'docsync.error.tls_untrusted':
'Certifikatet avvisades. Tillåt självsignerade certifikat om du litar på den här instansen.',
'docsync.error.unauthorized': 'Uppgifterna avvisades.',
'docsync.error.forbidden': 'Det här kontot får inte göra det.',
'docsync.error.not_found': 'Hittades inte hos leverantören.',
'docsync.error.scope_missing': 'Den anslutna mappen finns inte längre.',
'docsync.error.rate_limited': 'Leverantören begränsar oss. TREK försöker igen senare.',
'docsync.error.too_large': 'Filen är större än vad leverantören tar emot.',
'docsync.error.unsupported_type': 'Leverantören tar inte emot den här filtypen.',
'docsync.error.quota_exceeded': 'Leverantören har slut på utrymme.',
'docsync.error.conflict': 'Dokumentet ändrades på båda sidor.',
'docsync.error.checksum_mismatch': 'Överföringen kom inte fram hel.',
'docsync.error.provider_error': 'Leverantören rapporterade ett fel.',
'docsync.error.timeout': 'Leverantören tog för lång tid på sig att svara.',
'docsync.error.ssrf_blocked': 'Den adressen är inte tillåten.',
'docsync.error.mass_delete_guard':
'Nästan alla dokument försvann på en gång, så ingenting ändrades. Kontrollera att mappen fortfarande är monterad.',
'docsync.error.unknown': 'Något gick fel.',
'docsync.error.unknown_provider': 'Den här leverantören är inte tillgänglig på den här instansen.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Belge eşitleme',
'docsync.subtitle': 'Bu gezinin belgelerini kendi belge deponuzla aynı durumda tutun.',
'docsync.noProviders': 'Kullanılabilir belge sağlayıcısı yok',
'docsync.noProvidersHint': 'Bunları bir kurulum yöneticisi Yönetim → Eklentiler → Belgeler altından açar.',
'docsync.addProvider': 'Sağlayıcı bağla',
'docsync.test': 'Bağlantıyı test et',
'docsync.connected': 'Bağlandı',
'docsync.chooseFolder': 'Klasör seç',
'docsync.chooseFolderHint':
'Bu geziye ait klasörü, etiketi veya alanı seçin. Yalnızca onun içindeki belgeler eşitlenir.',
'docsync.noFolders': 'Bu kurulumda henüz bir şey bulunamadı.',
'docsync.newFolderPlaceholder': 'Yeni klasör adı',
'docsync.createFolder': 'Oluştur',
'docsync.syncNow': 'Şimdi eşitle',
'docsync.unlink': 'Bağlantıyı kes',
'docsync.syncEnabled': 'Otomatik eşitle',
'docsync.direction': 'Yön',
'docsync.directionBoth': 'Her iki yönde',
'docsync.directionPull': 'Yalnızca TREK’e',
'docsync.directionPush': 'Yalnızca sağlayıcıya',
'docsync.deletePolicy': 'Bir belge silindiğinde',
'docsync.deleteUnlink': 'İki kopyayı da tut, eşleştirmeyi kaldır',
'docsync.deleteTrash': 'Diğer kopyayı kendi çöp kutusuna taşı',
'docsync.webhookHint':
'Değişikliklerin hemen ulaşması için bu URL’yi sağlayıcınıza yapıştırın. Bu olmadan TREK belirli aralıklarla denetler.',
// Bağlantı formunun alanları. Anahtarlar, metin yerine bir anahtar soneki
// tutan document_provider_fields tablosundaki `label` sütununu yansıtır.
'docsync.providerUrl': 'Adres',
'docsync.providerApiToken': 'API belirteci',
'docsync.providerApiKey': 'API anahtarı',
'docsync.providerAppPassword': 'Uygulama parolası',
'docsync.providerAppToken': 'Uygulama belirteci',
'docsync.providerUsername': 'Kullanıcı adı',
'docsync.providerPassword': 'Parola',
'docsync.providerOrganization': 'Kuruluş kimliği',
'docsync.providerBasePath': 'Temel klasör',
'docsync.providerOTP': 'İki adımlı doğrulama kodu',
'docsync.allowInsecureTls': 'Kendinden imzalı sertifikayı kabul et',
'docsync.hintPaperlessToken': 'Paperless’ta Profilim altında oluşturun. O hesabın bütün yetkilerini taşır.',
'docsync.hintPapraKey':
'Papra’da API anahtarları altında oluşturun. Papra anahtarları her zaman üyesi olduğunuz bütün kuruluşlara erişir.',
'docsync.hintPapraOrg': 'Papra adres çubuğundaki org_… kimliği.',
'docsync.hintNextcloudLogin': 'Nextcloud oturum açma adınız, e-posta adresiniz değil.',
'docsync.hintNextcloudAppPassword':
'Ayarlar → Güvenlik → Yeni uygulama parolası oluştur. Asla hesap parolanız değil.',
'docsync.hintOpenCloudToken': 'OpenCloud’da uygulama belirteçleri altında oluşturulur.',
'docsync.hintBasePath': 'TREK’in gezi klasörlerini aradığı yer. Varsayılan /TREK.',
'docsync.hintSynologyUrl': 'Bağlantı noktasını da yazın, örneğin https://nas.example.com:5001',
'docsync.hintSynologyUser': 'En iyisi, yalnızca bu paylaşılan klasöre erişimi olan ayrı bir DSM hesabı.',
'docsync.hintSynologyOtp': 'Yalnızca hesapta iki adımlı doğrulama açıksa ve bir kez gerekir.',
'docsync.linkState.never': 'Henüz eşitlenmedi',
'docsync.linkState.ok': 'Eşit durumda',
'docsync.linkState.partial': 'Kısmen eşitlendi',
'docsync.linkState.failed': 'Başarısız',
'docsync.linkState.needs_reauth': 'Yeniden oturum açın',
'docsync.linkState.scope_lost': 'Klasör kayboldu',
'docsync.linkState.orphaned': 'Sahibi geziden ayrıldı',
'docsync.state.pending': 'Bekliyor',
'docsync.state.synced': 'Eşitlendi',
'docsync.state.conflict': 'Çakışma',
'docsync.state.rejected_type': 'Türe izin verilmiyor',
'docsync.state.too_large': 'Çok büyük',
'docsync.state.error': 'Hata',
'docsync.state.remote_missing': 'Sağlayıcıda yok',
'docsync.state.local_deleted': 'TREK’te silindi',
'docsync.state.scope_drift': 'Klasörün dışına taşındı',
'docsync.conflict.title': 'İki kopya da değişti',
'docsync.conflict.keepTrek': 'TREK sürümünü tut',
'docsync.conflict.keepProvider': 'Sağlayıcıdaki sürümü tut',
'docsync.conflict.keepBoth': 'İkisini de tut',
// Hata nedenleri her zaman kod olarak taşınır, karşı taraftan gelen metin olarak değil:
// bir sağlayıcı İngilizce yanıt verir ya da bir vekil sunucunun HTML oturum sayfasını
// döndürür; ikisinin de burada yeri yok.
'docsync.error.unreachable': 'Sağlayıcıya ulaşılamadı.',
'docsync.error.tls_untrusted':
'Sertifika reddedildi. Bu kuruluma güveniyorsanız kendinden imzalı sertifikalara izin verin.',
'docsync.error.unauthorized': 'Kimlik bilgileri reddedildi.',
'docsync.error.forbidden': 'Bu hesabın bunu yapma izni yok.',
'docsync.error.not_found': 'Sağlayıcıda bulunamadı.',
'docsync.error.scope_missing': 'Bağlanan klasör artık yok.',
'docsync.error.rate_limited': 'Sağlayıcı istek hızımızı sınırlıyor. TREK daha sonra yeniden deneyecek.',
'docsync.error.too_large': 'Dosya, sağlayıcının kabul ettiğinden büyük.',
'docsync.error.unsupported_type': 'Sağlayıcı bu dosya türünü kabul etmiyor.',
'docsync.error.quota_exceeded': 'Sağlayıcının yeri kalmadı.',
'docsync.error.conflict': 'Belge iki tarafta da değişti.',
'docsync.error.checksum_mismatch': 'Aktarım eksiksiz ulaşmadı.',
'docsync.error.provider_error': 'Sağlayıcı bir hata bildirdi.',
'docsync.error.timeout': 'Sağlayıcı yanıt vermekte çok gecikti.',
'docsync.error.ssrf_blocked': 'Bu adrese izin verilmiyor.',
'docsync.error.mass_delete_guard':
'Belgelerin çoğu bir anda kayboldu, bu yüzden hiçbir şey değiştirilmedi. Klasörün hâlâ bağlı olduğunu denetleyin.',
'docsync.error.unknown': 'Bir şeyler ters gitti.',
'docsync.error.unknown_provider': 'Bu sağlayıcı bu kurulumda kullanılamıyor.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+105
View File
@@ -0,0 +1,105 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Синхронізація документів',
'docsync.subtitle': 'Тримайте документи цієї подорожі узгодженими з власним сховищем документів.',
'docsync.noProviders': 'Немає доступних постачальників документів',
'docsync.noProvidersHint': 'Адміністратор сервера вмикає їх у розділі Адміністрування → Доповнення → Документи.',
'docsync.addProvider': 'Підключити постачальника',
'docsync.test': 'Перевірити з’єднання',
'docsync.connected': 'Підключено',
'docsync.chooseFolder': 'Вибрати теку',
'docsync.chooseFolderHint':
'Оберіть теку, мітку або простір, що належить цій подорожі. Синхронізуються лише документи в ній.',
'docsync.noFolders': 'На цьому сервері поки нічого не знайдено.',
'docsync.newFolderPlaceholder': 'Назва нової теки',
'docsync.createFolder': 'Створити',
'docsync.syncNow': 'Синхронізувати зараз',
'docsync.unlink': 'Відключити',
'docsync.syncEnabled': 'Синхронізувати автоматично',
'docsync.direction': 'Напрямок',
'docsync.directionBoth': 'В обидва боки',
'docsync.directionPull': 'Лише в TREK',
'docsync.directionPush': 'Лише до постачальника',
'docsync.deletePolicy': 'Коли документ видалено',
'docsync.deleteUnlink': 'Зберегти обидві копії, розірвати зв’язок',
'docsync.deleteTrash': 'Перемістити другу копію до її кошика',
'docsync.webhookHint':
'Вставте цю URL-адресу у свого постачальника, щоб зміни надходили одразу. Без цього TREK перевіряє їх за таймером.',
// Поля форми підключення. Ключі відповідають стовпцю `label` у таблиці
// document_provider_fields, яка зберігає суфікс ключа, а не текст.
'docsync.providerUrl': 'Адреса',
'docsync.providerApiToken': 'Токен API',
'docsync.providerApiKey': 'Ключ API',
'docsync.providerAppPassword': 'Пароль додатка',
'docsync.providerAppToken': 'Токен додатка',
'docsync.providerUsername': 'Ім’я користувача',
'docsync.providerPassword': 'Пароль',
'docsync.providerOrganization': 'Ідентифікатор організації',
'docsync.providerBasePath': 'Базова тека',
'docsync.providerOTP': 'Код двофакторної автентифікації',
'docsync.allowInsecureTls': 'Приймати самопідписаний сертифікат',
'docsync.hintPaperlessToken':
'Створіть його в Paperless-ngx у розділі My Profile. Він має всі права цього облікового запису.',
'docsync.hintPapraKey':
'Створіть його в Papra у розділі API keys. Ключі Papra завжди охоплюють усі організації, до яких ви належите.',
'docsync.hintPapraOrg': 'Ідентифікатор org_… з адресного рядка Papra.',
'docsync.hintNextcloudLogin': 'Ваше ім’я для входу в Nextcloud, а не адреса електронної пошти.',
'docsync.hintNextcloudAppPassword':
'Налаштування → Безпека → Створити новий пароль додатка. Ніколи не пароль облікового запису.',
'docsync.hintOpenCloudToken': 'Створюється в OpenCloud у розділі токенів додатків.',
'docsync.hintBasePath': 'Де TREK шукає теки подорожей. Типово /TREK.',
'docsync.hintSynologyUrl': 'Вкажіть і порт, наприклад https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Найкраще окремий обліковий запис DSM з доступом лише до цієї спільної теки.',
'docsync.hintSynologyOtp': 'Потрібен лише один раз, якщо обліковий запис використовує двофакторну автентифікацію.',
'docsync.linkState.never': 'Ще не синхронізовано',
'docsync.linkState.ok': 'Актуально',
'docsync.linkState.partial': 'Синхронізовано частково',
'docsync.linkState.failed': 'Помилка',
'docsync.linkState.needs_reauth': 'Увійдіть знову',
'docsync.linkState.scope_lost': 'Теки більше немає',
'docsync.linkState.orphaned': 'Власник залишив подорож',
'docsync.state.pending': 'Очікує',
'docsync.state.synced': 'Синхронізовано',
'docsync.state.conflict': 'Конфлікт',
'docsync.state.rejected_type': 'Тип не дозволено',
'docsync.state.too_large': 'Завеликий',
'docsync.state.error': 'Помилка',
'docsync.state.remote_missing': 'Відсутній у постачальника',
'docsync.state.local_deleted': 'Видалено в TREK',
'docsync.state.scope_drift': 'Переміщено за межі теки',
'docsync.conflict.title': 'Змінилися обидві копії',
'docsync.conflict.keepTrek': 'Залишити версію TREK',
'docsync.conflict.keepProvider': 'Залишити версію постачальника',
'docsync.conflict.keepBoth': 'Залишити обидві',
// Причини збоїв передаються як коди, ніколи як текст від постачальника:
// той відповідає англійською або HTML-сторінкою входу проксі, і ні те, ні інше сюди не належить.
'docsync.error.unreachable': 'Не вдалося зв’язатися з постачальником.',
'docsync.error.tls_untrusted':
'Сертифікат відхилено. Дозвольте самопідписані сертифікати, якщо довіряєте цьому серверу.',
'docsync.error.unauthorized': 'Облікові дані відхилено.',
'docsync.error.forbidden': 'Цей обліковий запис не має на це дозволу.',
'docsync.error.not_found': 'Не знайдено в постачальника.',
'docsync.error.scope_missing': 'Підключеної теки більше не існує.',
'docsync.error.rate_limited': 'Постачальник обмежує частоту запитів. TREK спробує ще раз пізніше.',
'docsync.error.too_large': 'Файл більший, ніж приймає постачальник.',
'docsync.error.unsupported_type': 'Постачальник не приймає цей тип файлу.',
'docsync.error.quota_exceeded': 'У постачальника закінчилося місце.',
'docsync.error.conflict': 'Документ змінився з обох боків.',
'docsync.error.checksum_mismatch': 'Передача надійшла пошкодженою.',
'docsync.error.provider_error': 'Постачальник повідомив про помилку.',
'docsync.error.timeout': 'Постачальник відповідав надто довго.',
'docsync.error.ssrf_blocked': 'Ця адреса не дозволена.',
'docsync.error.mass_delete_guard':
'Більшість документів зникла одночасно, тому нічого не змінено. Перевірте, чи теку досі підключено.',
'docsync.error.unknown': 'Щось пішло не так.',
'docsync.error.unknown_provider': 'Цей постачальник недоступний на цьому сервері.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+103
View File
@@ -0,0 +1,103 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Đồng bộ tài liệu',
'docsync.subtitle': 'Giữ tài liệu của chuyến đi này khớp với kho tài liệu của riêng bạn.',
'docsync.noProviders': 'Không có nhà cung cấp tài liệu nào khả dụng',
'docsync.noProvidersHint': 'Quản trị viên hệ thống bật chúng trong Quản trị → Tiện ích bổ sung → Tài liệu.',
'docsync.addProvider': 'Kết nối nhà cung cấp',
'docsync.test': 'Kiểm tra kết nối',
'docsync.connected': 'Đã kết nối',
'docsync.chooseFolder': 'Chọn thư mục',
'docsync.chooseFolderHint':
'Chọn thư mục, thẻ hoặc không gian thuộc về chuyến đi này. Chỉ tài liệu bên trong mới được đồng bộ.',
'docsync.noFolders': 'Chưa tìm thấy gì trên máy chủ này.',
'docsync.newFolderPlaceholder': 'Tên thư mục mới',
'docsync.createFolder': 'Tạo',
'docsync.syncNow': 'Đồng bộ ngay',
'docsync.unlink': 'Ngắt kết nối',
'docsync.syncEnabled': 'Tự động đồng bộ',
'docsync.direction': 'Hướng',
'docsync.directionBoth': 'Cả hai chiều',
'docsync.directionPull': 'Chỉ vào TREK',
'docsync.directionPush': 'Chỉ ra nhà cung cấp',
'docsync.deletePolicy': 'Khi một tài liệu bị xóa',
'docsync.deleteUnlink': 'Giữ cả hai bản, bỏ ghép cặp',
'docsync.deleteTrash': 'Chuyển bản còn lại vào thùng rác của nó',
'docsync.webhookHint':
'Dán URL này vào nhà cung cấp của bạn để thay đổi đến ngay lập tức. Nếu không, TREK sẽ kiểm tra theo định kỳ.',
// Các trường của biểu mẫu kết nối. Khóa phản chiếu cột `label` trong
// document_provider_fields, nơi lưu hậu tố khóa chứ không phải văn bản.
'docsync.providerUrl': 'Địa chỉ',
'docsync.providerApiToken': 'Mã thông báo API',
'docsync.providerApiKey': 'Khóa API',
'docsync.providerAppPassword': 'Mật khẩu ứng dụng',
'docsync.providerAppToken': 'Mã thông báo ứng dụng',
'docsync.providerUsername': 'Tên đăng nhập',
'docsync.providerPassword': 'Mật khẩu',
'docsync.providerOrganization': 'ID tổ chức',
'docsync.providerBasePath': 'Thư mục gốc',
'docsync.providerOTP': 'Mã xác thực hai yếu tố',
'docsync.allowInsecureTls': 'Chấp nhận chứng chỉ tự ký',
'docsync.hintPaperlessToken': 'Tạo trong Paperless ở mục Hồ sơ của tôi. Mã này mang toàn bộ quyền của tài khoản đó.',
'docsync.hintPapraKey': 'Tạo trong Papra ở mục Khóa API. Khóa Papra luôn truy cập được mọi tổ chức mà bạn tham gia.',
'docsync.hintPapraOrg': 'Mã org_… lấy từ thanh địa chỉ của Papra.',
'docsync.hintNextcloudLogin': 'Tên đăng nhập Nextcloud của bạn, không phải địa chỉ email.',
'docsync.hintNextcloudAppPassword':
'Cài đặt → Bảo mật → Tạo mật khẩu ứng dụng mới. Không bao giờ dùng mật khẩu tài khoản.',
'docsync.hintOpenCloudToken': 'Được tạo trong OpenCloud ở mục mã thông báo ứng dụng.',
'docsync.hintBasePath': 'Nơi TREK tìm thư mục chuyến đi. Mặc định là /TREK.',
'docsync.hintSynologyUrl': 'Nhớ kèm cổng, ví dụ https://nas.example.com:5001',
'docsync.hintSynologyUser': 'Tốt nhất là một tài khoản DSM riêng chỉ truy cập được thư mục chia sẻ này.',
'docsync.hintSynologyOtp': 'Chỉ cần một lần, nếu tài khoản dùng xác thực hai yếu tố.',
'docsync.linkState.never': 'Chưa đồng bộ',
'docsync.linkState.ok': 'Đã đồng bộ',
'docsync.linkState.partial': 'Đồng bộ một phần',
'docsync.linkState.failed': 'Thất bại',
'docsync.linkState.needs_reauth': 'Đăng nhập lại',
'docsync.linkState.scope_lost': 'Thư mục không còn',
'docsync.linkState.orphaned': 'Chủ sở hữu đã rời chuyến đi',
'docsync.state.pending': 'Đang chờ',
'docsync.state.synced': 'Đã đồng bộ',
'docsync.state.conflict': 'Xung đột',
'docsync.state.rejected_type': 'Loại không được phép',
'docsync.state.too_large': 'Quá lớn',
'docsync.state.error': 'Lỗi',
'docsync.state.remote_missing': 'Không có ở nhà cung cấp',
'docsync.state.local_deleted': 'Đã xóa trong TREK',
'docsync.state.scope_drift': 'Đã chuyển ra khỏi thư mục',
'docsync.conflict.title': 'Cả hai bản đều thay đổi',
'docsync.conflict.keepTrek': 'Giữ bản TREK',
'docsync.conflict.keepProvider': 'Giữ bản của nhà cung cấp',
'docsync.conflict.keepBoth': 'Giữ cả hai',
// Lý do thất bại được truyền dưới dạng mã, không bao giờ là văn bản từ phía
// nhà cung cấp: nhà cung cấp trả lời bằng tiếng Anh, hoặc bằng trang đăng nhập
// HTML của proxy, và cả hai đều không thuộc về đây.
'docsync.error.unreachable': 'Không thể kết nối tới nhà cung cấp.',
'docsync.error.tls_untrusted': 'Chứng chỉ bị từ chối. Hãy cho phép chứng chỉ tự ký nếu bạn tin cậy máy chủ này.',
'docsync.error.unauthorized': 'Thông tin đăng nhập bị từ chối.',
'docsync.error.forbidden': 'Tài khoản này không được phép làm việc đó.',
'docsync.error.not_found': 'Không tìm thấy ở nhà cung cấp.',
'docsync.error.scope_missing': 'Thư mục đã kết nối không còn tồn tại.',
'docsync.error.rate_limited': 'Nhà cung cấp đang giới hạn số lượt gọi của chúng ta. TREK sẽ thử lại sau.',
'docsync.error.too_large': 'Tệp lớn hơn mức nhà cung cấp chấp nhận.',
'docsync.error.unsupported_type': 'Nhà cung cấp không chấp nhận loại tệp này.',
'docsync.error.quota_exceeded': 'Nhà cung cấp đã hết dung lượng.',
'docsync.error.conflict': 'Tài liệu đã thay đổi ở cả hai bên.',
'docsync.error.checksum_mismatch': 'Dữ liệu truyền đi không đến nơi nguyên vẹn.',
'docsync.error.provider_error': 'Nhà cung cấp báo lỗi.',
'docsync.error.timeout': 'Nhà cung cấp phản hồi quá lâu.',
'docsync.error.ssrf_blocked': 'Địa chỉ đó không được phép.',
'docsync.error.mass_delete_guard':
'Phần lớn tài liệu biến mất cùng lúc, nên không có gì được thay đổi. Hãy kiểm tra xem thư mục còn được gắn kết không.',
'docsync.error.unknown': 'Đã xảy ra lỗi.',
'docsync.error.unknown_provider': 'Nhà cung cấp này không khả dụng trên máy chủ này.',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+98
View File
@@ -0,0 +1,98 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '文件同步',
'docsync.subtitle': '讓這趟旅行的文件與你自己的文件儲存庫保持同步。',
'docsync.noProviders': '沒有可用的文件提供者',
'docsync.noProvidersHint': '執行個體管理員可在「管理 → 擴充套件 → 文件」中啟用這些提供者。',
'docsync.addProvider': '連接提供者',
'docsync.test': '測試連線',
'docsync.connected': '已連線',
'docsync.chooseFolder': '選擇資料夾',
'docsync.chooseFolderHint': '挑選屬於這趟旅行的資料夾、標籤或空間。只有其中的文件會同步。',
'docsync.noFolders': '尚未在這個執行個體上找到任何項目。',
'docsync.newFolderPlaceholder': '新資料夾名稱',
'docsync.createFolder': '建立',
'docsync.syncNow': '立即同步',
'docsync.unlink': '中斷連線',
'docsync.syncEnabled': '自動同步',
'docsync.direction': '方向',
'docsync.directionBoth': '雙向',
'docsync.directionPull': '只同步進 TREK',
'docsync.directionPush': '只同步到提供者',
'docsync.deletePolicy': '當文件被刪除時',
'docsync.deleteUnlink': '兩邊都保留,只解除配對',
'docsync.deleteTrash': '把另一份移到它的回收站',
'docsync.webhookHint': '把這個 URL 貼到你的提供者,變更就會立刻送達。沒有它,TREK 只會定時檢查。',
// 連線表單欄位。這些鍵對應 document_provider_fields 的 `label` 欄,
// 該欄存的是鍵名後綴,而不是文字。
'docsync.providerUrl': '位址',
'docsync.providerApiToken': 'API 權杖',
'docsync.providerApiKey': 'API 金鑰',
'docsync.providerAppPassword': '應用程式密碼',
'docsync.providerAppToken': '應用程式權杖',
'docsync.providerUsername': '使用者名稱',
'docsync.providerPassword': '密碼',
'docsync.providerOrganization': '組織 ID',
'docsync.providerBasePath': '基礎資料夾',
'docsync.providerOTP': '雙因素驗證碼',
'docsync.allowInsecureTls': '接受自簽憑證',
'docsync.hintPaperlessToken': '在 Paperless 的「我的個人資料」中建立。它擁有該帳戶的完整權限。',
'docsync.hintPapraKey': '在 Papra 的「API 金鑰」中建立。Papra 的金鑰一律能存取你所屬的每一個組織。',
'docsync.hintPapraOrg': '來自 Papra 網址列的 org_… 識別碼。',
'docsync.hintNextcloudLogin': '你的 Nextcloud 登入名稱,不是電子郵件地址。',
'docsync.hintNextcloudAppPassword': '「設定 → 安全性 → 建立新的應用程式密碼」。絕對不要用你的帳戶密碼。',
'docsync.hintOpenCloudToken': '在 OpenCloud 的應用程式權杖中建立。',
'docsync.hintBasePath': 'TREK 會在這裡尋找旅行資料夾。預設為 /TREK。',
'docsync.hintSynologyUrl': '請包含連接埠,例如 https://nas.example.com:5001',
'docsync.hintSynologyUser': '最好使用一個專用的 DSM 帳戶,只能存取這個共用資料夾。',
'docsync.hintSynologyOtp': '只有在該帳戶啟用雙因素驗證時才需要,而且只需一次。',
'docsync.linkState.never': '尚未同步',
'docsync.linkState.ok': '已同步',
'docsync.linkState.partial': '部分已同步',
'docsync.linkState.failed': '失敗',
'docsync.linkState.needs_reauth': '請重新登入',
'docsync.linkState.scope_lost': '資料夾已不存在',
'docsync.linkState.orphaned': '擁有者已離開旅行',
'docsync.state.pending': '等待中',
'docsync.state.synced': '已同步',
'docsync.state.conflict': '衝突',
'docsync.state.rejected_type': '不允許的類型',
'docsync.state.too_large': '檔案過大',
'docsync.state.error': '錯誤',
'docsync.state.remote_missing': '提供者上找不到',
'docsync.state.local_deleted': '已在 TREK 中刪除',
'docsync.state.scope_drift': '已移出資料夾',
'docsync.conflict.title': '兩份都有變更',
'docsync.conflict.keepTrek': '保留 TREK 版本',
'docsync.conflict.keepProvider': '保留提供者版本',
'docsync.conflict.keepBoth': '兩份都保留',
// 失敗原因以代碼傳遞,絕不沿用上游的文字:提供者會用英文回應,
// 或回傳代理伺服器的 HTML 登入頁,兩者都不該出現在這裡。
'docsync.error.unreachable': '無法連上提供者。',
'docsync.error.tls_untrusted': '憑證遭到拒絕。如果你信任這個執行個體,請允許自簽憑證。',
'docsync.error.unauthorized': '登入資訊遭到拒絕。',
'docsync.error.forbidden': '這個帳戶沒有執行此操作的權限。',
'docsync.error.not_found': '在提供者上找不到。',
'docsync.error.scope_missing': '已連接的資料夾不存在了。',
'docsync.error.rate_limited': '提供者正在限制我們的請求速率。TREK 稍後會再試。',
'docsync.error.too_large': '這個檔案超過提供者接受的大小。',
'docsync.error.unsupported_type': '提供者不接受這種檔案類型。',
'docsync.error.quota_exceeded': '提供者的空間已用盡。',
'docsync.error.conflict': '這份文件在兩邊都有變更。',
'docsync.error.checksum_mismatch': '傳輸過程中檔案未完整送達。',
'docsync.error.provider_error': '提供者回報了一個錯誤。',
'docsync.error.timeout': '提供者回應逾時。',
'docsync.error.ssrf_blocked': '不允許這個位址。',
'docsync.error.mass_delete_guard': '大量文件同時消失,因此沒有做任何變更。請確認資料夾仍然掛載中。',
'docsync.error.unknown': '發生錯誤。',
'docsync.error.unknown_provider': '這個提供者在此執行個體上無法使用。',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+98
View File
@@ -0,0 +1,98 @@
import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '文档同步',
'docsync.subtitle': '让此行程的文档与你自己的文档存储保持一致。',
'docsync.noProviders': '暂无可用的文档服务',
'docsync.noProvidersHint': '实例管理员可在“管理 → 插件 → 文档”中启用。',
'docsync.addProvider': '连接文档服务',
'docsync.test': '测试连接',
'docsync.connected': '已连接',
'docsync.chooseFolder': '选择文件夹',
'docsync.chooseFolderHint': '选择属于此行程的文件夹、标签或空间。只有其中的文档会被同步。',
'docsync.noFolders': '此实例上暂未找到任何内容。',
'docsync.newFolderPlaceholder': '新文件夹名称',
'docsync.createFolder': '创建',
'docsync.syncNow': '立即同步',
'docsync.unlink': '断开连接',
'docsync.syncEnabled': '自动同步',
'docsync.direction': '方向',
'docsync.directionBoth': '双向',
'docsync.directionPull': '仅同步到 TREK',
'docsync.directionPush': '仅同步到文档服务',
'docsync.deletePolicy': '当文档被删除时',
'docsync.deleteUnlink': '保留两份副本,只解除配对',
'docsync.deleteTrash': '把另一份副本移入其回收站',
'docsync.webhookHint': '把此 URL 粘贴到你的文档服务中,变更就会立即送达。不设置时,TREK 会定时检查。',
// 连接表单的字段。这些键与 document_provider_fields 表的 `label` 列一一对应,
// 该列存的是键的后缀,而不是文字本身。
'docsync.providerUrl': '地址',
'docsync.providerApiToken': 'API 令牌',
'docsync.providerApiKey': 'API 密钥',
'docsync.providerAppPassword': '应用密码',
'docsync.providerAppToken': '应用令牌',
'docsync.providerUsername': '用户名',
'docsync.providerPassword': '密码',
'docsync.providerOrganization': '组织 ID',
'docsync.providerBasePath': '基础文件夹',
'docsync.providerOTP': '两步验证码',
'docsync.allowInsecureTls': '接受自签名证书',
'docsync.hintPaperlessToken': '在 Paperless 的“My Profile”中创建。它拥有该账户的全部权限。',
'docsync.hintPapraKey': '在 Papra 的“API keys”中创建。Papra 的密钥始终可以访问你所属的每个组织。',
'docsync.hintPapraOrg': 'Papra 地址栏中的 org_… 编号。',
'docsync.hintNextcloudLogin': '你的 Nextcloud 登录名,不是邮箱地址。',
'docsync.hintNextcloudAppPassword': '设置 → 安全 → 创建新的应用密码。切勿使用账户密码。',
'docsync.hintOpenCloudToken': '在 OpenCloud 的应用令牌中创建。',
'docsync.hintBasePath': 'TREK 查找行程文件夹的位置。默认为 /TREK。',
'docsync.hintSynologyUrl': '请包含端口,例如 https://nas.example.com:5001',
'docsync.hintSynologyUser': '最好使用只能访问此共享文件夹的专用 DSM 账户。',
'docsync.hintSynologyOtp': '仅在账户启用了两步验证时需要,且只需一次。',
'docsync.linkState.never': '尚未同步',
'docsync.linkState.ok': '已同步',
'docsync.linkState.partial': '部分已同步',
'docsync.linkState.failed': '失败',
'docsync.linkState.needs_reauth': '请重新登录',
'docsync.linkState.scope_lost': '文件夹已不存在',
'docsync.linkState.orphaned': '所有者已退出行程',
'docsync.state.pending': '等待中',
'docsync.state.synced': '已同步',
'docsync.state.conflict': '冲突',
'docsync.state.rejected_type': '不允许的类型',
'docsync.state.too_large': '文件过大',
'docsync.state.error': '错误',
'docsync.state.remote_missing': '文档服务中不存在',
'docsync.state.local_deleted': '已在 TREK 中删除',
'docsync.state.scope_drift': '已移出该文件夹',
'docsync.conflict.title': '两份副本都有改动',
'docsync.conflict.keepTrek': '保留 TREK 版本',
'docsync.conflict.keepProvider': '保留文档服务的版本',
'docsync.conflict.keepBoth': '两份都保留',
// 失败原因以代码传递,绝不直接转述对方的文字:文档服务会用英文作答,或者返回
// 代理的 HTML 登录页,两者都不该出现在这里。
'docsync.error.unreachable': '无法连接到该文档服务。',
'docsync.error.tls_untrusted': '证书被拒绝。如果你信任此实例,请允许自签名证书。',
'docsync.error.unauthorized': '凭据被拒绝。',
'docsync.error.forbidden': '此账户没有执行该操作的权限。',
'docsync.error.not_found': '在文档服务中未找到。',
'docsync.error.scope_missing': '已连接的文件夹不再存在。',
'docsync.error.rate_limited': '文档服务正在限制请求频率。TREK 稍后会重试。',
'docsync.error.too_large': '该文件超出了文档服务允许的大小。',
'docsync.error.unsupported_type': '文档服务不接受此文件类型。',
'docsync.error.quota_exceeded': '文档服务的存储空间已满。',
'docsync.error.conflict': '该文档在两边都发生了改动。',
'docsync.error.checksum_mismatch': '传输的内容不完整。',
'docsync.error.provider_error': '文档服务返回了一个错误。',
'docsync.error.timeout': '文档服务响应超时。',
'docsync.error.ssrf_blocked': '不允许使用该地址。',
'docsync.error.mass_delete_guard': '大量文档同时消失,因此未做任何改动。请检查该文件夹是否仍处于挂载状态。',
'docsync.error.unknown': '出了点问题。',
'docsync.error.unknown_provider': '此实例不提供该文档服务。',
};
export default docsync;
+2
View File
@@ -1,5 +1,6 @@
import admin from './admin';
import dawarich from './dawarich';
import docsync from './docsync';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -108,5 +109,6 @@ const locale = {
...mobileCollections,
...storage,
...dawarich,
...docsync,
};
export default locale;
+1
View File
@@ -34,6 +34,7 @@ export * from './reservation/ki-reservation.schema';
export * from './datetime/datetime-normalize';
export * from './airtrail/airtrail.schema';
export * from './dawarich/dawarich.schema';
export * from './docsync/docsync.schema';
export * from './day/day.schema';
export * from './day/note-colors';
export * from './assignment/assignment.schema';
+6 -3
View File
@@ -73,6 +73,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
'trip:deleted': { id: 1 },
'member:added': { member: { user_id: 2 } },
'member:removed': { userId: 2 },
'docsync:changed': { linkId: 4, pulled: 2, pushed: 1 },
'file:created': { file: { id: 13 } },
'file:updated': { file: { id: 13 } },
'file:deleted': { fileId: 13 },
@@ -144,12 +145,14 @@ const DRIFT_VARIANTS: Partial<Record<TrekWsEventName, Record<string, unknown>[]>
};
describe('@trek/shared realtime event registry', () => {
it('WSEVT-REG-001: pins the authoritative inventory counts (73 trip + 32 user = 105)', () => {
it('WSEVT-REG-001: pins the authoritative inventory counts (74 trip + 32 user = 106)', () => {
// 67th to 69th trip event: the three collab:link:* a shared link emits.
// 70th and 71st: the road trip's vias and tracks, which used to be written silently.
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(73);
// 74th: docsync:changed, so a sync run that moved documents refreshes the
// panel without every member polling for it.
expect(TREK_WS_TRIP_EVENT_NAMES).toHaveLength(74);
expect(TREK_WS_USER_EVENT_NAMES).toHaveLength(32);
expect(TREK_WS_EVENT_NAMES).toHaveLength(105);
expect(TREK_WS_EVENT_NAMES).toHaveLength(106);
});
it('WSEVT-REG-002: every name is domain:action shaped and outside the reserved plugin: namespace', () => {
+7
View File
@@ -233,6 +233,13 @@ export const TREK_WS_EVENTS = {
'file:created': { scope: 'trip', payload: z.object({ file: entity }) },
'file:updated': { scope: 'trip', payload: z.object({ file: entity }) },
'file:deleted': { scope: 'trip', payload: z.object({ fileId: id }) },
// A sync run moved documents. Carries counts rather than the documents
// themselves: the client already learns about each one through file:created,
// and this is what tells the sync panel to refresh its status line.
'docsync:changed': {
scope: 'trip',
payload: z.object({ linkId: id, pulled: z.number(), pushed: z.number() }),
},
// ── Collab ───────────────────────────────────────────────────────────────
'collab:note:created': { scope: 'trip', payload: z.object({ note: entity }) },