mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
style(docsync): drop dash punctuation from the added comments and test titles
The lines this branch adds used em and en dashes as sentence punctuation across comments, JSDoc, CSS comments and test titles. Each one is rewritten with a comma, a colon, parentheses or a second sentence, keeping what the sentence said. Only lines this branch adds are touched; everything that already exists on dev stays as it is. No logic, error code or logged message changes. The only string literals affected are describe and it titles, which now read "X: topic" like the rest of the provider tests.
This commit is contained in:
@@ -1361,7 +1361,7 @@ export const filesApi = {
|
||||
/**
|
||||
* Document sync: one provider connection per trip, shared by every member.
|
||||
*
|
||||
* Trip-scoped rather than user-scoped on purpose — a per-user connection would
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -81,7 +81,7 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// who has the book open, and where their pointers are)
|
||||
'journey:book:peers',
|
||||
'journey:book:cursor',
|
||||
// Document sync — components/Files/docsync/useDocSync re-reads the bindings
|
||||
// Document sync: components/Files/docsync/useDocSync re-reads the bindings
|
||||
// and their counts. Content-free like the bag ping: what a run changed is
|
||||
// per-binding server state, and the files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
|
||||
@@ -480,7 +480,7 @@ describe('DocSyncConnectModal with a connection already stored', () => {
|
||||
await screen.findByPlaceholderText('••••••••')
|
||||
// The address is a column of its own rather than a settings entry. Reading
|
||||
// it out of settings left the box empty over its placeholder while the form
|
||||
// went on submitting the stored value — the field and the request quietly
|
||||
// went on submitting the stored value, the field and the request quietly
|
||||
// disagreeing about what the connection is.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveValue('https://cloud.example.com'),
|
||||
|
||||
@@ -144,7 +144,7 @@ function Verdict({
|
||||
}: {
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test — saving, most of all. */
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -18,8 +18,8 @@ export interface FlowHoldings {
|
||||
/**
|
||||
* The direction control, drawn as what it actually is.
|
||||
*
|
||||
* A document sync has one characteristic picture — two places and things moving
|
||||
* between them — and a dropdown reading "Both ways" hides it behind a word.
|
||||
* A document sync has one characteristic picture (two places and things moving
|
||||
* between them), and a dropdown reading "Both ways" hides it behind a word.
|
||||
* Here the two ends carry what each side is holding, the lanes between them
|
||||
* carry the direction, and switching direction means switching a lane off. It
|
||||
* is the one loud element in the dialog; everything around it stays quiet.
|
||||
@@ -113,7 +113,7 @@ export default function DocSyncFlow({
|
||||
/**
|
||||
* One end of the flow: the mark, the count, the name.
|
||||
*
|
||||
* The count is the largest thing in the box on purpose — "is my stuff over
|
||||
* The count is the largest thing in the box on purpose: "is my stuff over
|
||||
* there" is the question that makes somebody open this dialog, and it should be
|
||||
* answered before they read a word.
|
||||
*/
|
||||
@@ -197,8 +197,8 @@ function Lane({
|
||||
{caption}
|
||||
</span>
|
||||
|
||||
{/* Only while a run is in flight. An idle lane needs no track — that it is
|
||||
on or off is already in its fill — and a permanent line just asks the
|
||||
{/* Only while a run is in flight. An idle lane needs no track (that it is
|
||||
on or off is already in its fill), and a permanent line just asks the
|
||||
reader what it means. */}
|
||||
{moving && (
|
||||
<span
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* pretending the archive is empty.
|
||||
*
|
||||
* The component takes the `useDocSync` hook as a prop, so the double here is the
|
||||
* real hook with `api/client` mocked underneath — the assertions are then about
|
||||
* real hook with `api/client` mocked underneath, so the assertions are about
|
||||
* the requests that actually leave, not about a hand-written stand-in. The hook
|
||||
* is created with `enabled: false` because the panel-level lists it would fetch
|
||||
* belong to the dialog around this modal, not to the picker.
|
||||
@@ -299,7 +299,7 @@ describe('DocSyncScopeModal creating a folder', () => {
|
||||
|
||||
it('FE-DOCSYNC-SCOPE-021: a refused create says why instead of doing nothing', async () => {
|
||||
// The create had no failure path at all: the rejection escaped the click
|
||||
// handler unhandled, nothing was bound, and the dialog said nothing — the
|
||||
// handler unhandled, nothing was bound, and the dialog said nothing: the
|
||||
// button blinked and the person was left guessing. Every other write in the
|
||||
// hook reports, and so does this one now.
|
||||
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useConflicts(tripId: number | string, sync: ReturnType<typeof us
|
||||
setWorking(itemId)
|
||||
try {
|
||||
await sync.resolveConflict(itemId, keep)
|
||||
// The row is gone from this list either way — it was resolved, or the
|
||||
// The row is gone from this list either way: it was resolved, or the
|
||||
// call failed and `sync.error` says so. Re-reading is what tells the
|
||||
// difference, rather than removing it here and hoping.
|
||||
await reload()
|
||||
|
||||
@@ -50,7 +50,7 @@ export function useConnectForm(
|
||||
): ConnectForm {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
// Null until touched, so a connection that arrives after the form opened is
|
||||
// still reflected — seeding from `existing` at mount showed a stored "allow
|
||||
// still reflected. Seeding from `existing` at mount showed a stored "allow
|
||||
// self-signed" as off and wrote that back on the next save.
|
||||
const [insecure, setInsecure] = useState<boolean | null>(null)
|
||||
|
||||
@@ -75,7 +75,7 @@ export function useConnectForm(
|
||||
// The address stays in here as well as in its own argument: the server walks
|
||||
// the provider's fields over `credentials` and would otherwise store no
|
||||
// `base_url` setting. It special-cases the key when checking completeness, so
|
||||
// dropping it changes nothing it needs — but it is an established request
|
||||
// dropping it changes nothing it needs, but it is an established request
|
||||
// shape and this is not the change to alter it in.
|
||||
const credentials = Object.fromEntries(
|
||||
Object.entries(values).filter(([k]) => k !== INSECURE_TLS_FIELD),
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react'
|
||||
* they sit in a row of lucide icons and in a list of addon toggles, where a
|
||||
* full-colour badge among line icons reads as the one thing that is switched
|
||||
* on. Following the text colour also means they invert with the theme without a
|
||||
* second asset — black on light, white on dark.
|
||||
* second asset: black on light, white on dark.
|
||||
*
|
||||
* Not in `public/brands/` beside AirTrail and Dawarich, because those are the
|
||||
* files each project ships, served unmodified. These are deliberately modified:
|
||||
|
||||
@@ -507,7 +507,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
}
|
||||
|
||||
/* Dokumentenabgleich: die aktive Bahn laeuft, WAEHREND ein Lauf laeuft.
|
||||
Bewegung als Antwort auf eine Handlung, nicht als Dekoration — sie hoert auf,
|
||||
Bewegung als Antwort auf eine Handlung, nicht als Dekoration: sie hoert auf,
|
||||
sobald der Lauf durch ist, und sagt in der Zwischenzeit, in welche Richtung
|
||||
gerade etwas unterwegs ist. */
|
||||
@keyframes trek-docsync-lane {
|
||||
@@ -526,7 +526,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
/* Die abgeschaltete Richtung bekommt eine Schraffur — dasselbe Zeichen, das eine
|
||||
/* Die abgeschaltete Richtung bekommt eine Schraffur, dasselbe Zeichen, das eine
|
||||
gesperrte Flaeche ueberall traegt. Es liest sich als "hier geht nichts durch",
|
||||
ohne dass die Bahn verschwindet und die Zeile zusammenklappt. */
|
||||
.trek-docsync-lane-off {
|
||||
@@ -636,7 +636,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
.trek-lowfuel {
|
||||
animation: none;
|
||||
}
|
||||
/* Die Bahn bleibt sichtbar, sie laeuft nur nicht mehr — die Aussage
|
||||
/* Die Bahn bleibt sichtbar, sie laeuft nur nicht mehr. Die Aussage
|
||||
"hier ist gerade etwas unterwegs" traegt auch im Stillstand. */
|
||||
.trek-docsync-lane-run {
|
||||
animation: none;
|
||||
|
||||
@@ -5,8 +5,8 @@ import { isEffectivelyOnline, onNetworkModeChange } from './networkMode'
|
||||
/**
|
||||
* How long pings are collected before one refetch goes out.
|
||||
*
|
||||
* Long enough to fold a burst into a single request — clearing twelve checked
|
||||
* items issues twelve deletes, and an import writes a whole list — short enough
|
||||
* Long enough to fold a burst into a single request (clearing twelve checked
|
||||
* items issues twelve deletes, and an import writes a whole list), short enough
|
||||
* that a single edit still reads as instant.
|
||||
*/
|
||||
const COALESCE_MS = 250
|
||||
@@ -19,7 +19,7 @@ const COALESCE_MS = 250
|
||||
* whose items this client may not see, and a document-sync run reports counts
|
||||
* that are only meaningful read back from the server. Every such ping is a
|
||||
* refetch, which is why this coalesces bursts and never lets two requests
|
||||
* overlap — the server pings the whole room including the originating socket, so
|
||||
* overlap: the server pings the whole room including the originating socket, so
|
||||
* a busy trip would otherwise have every client refetching once per written row.
|
||||
*
|
||||
* A ping sent while this client is disconnected is gone for good, so the two
|
||||
|
||||
@@ -8,7 +8,7 @@ import type Database from 'better-sqlite3';
|
||||
* 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
|
||||
* `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>`.
|
||||
*
|
||||
@@ -77,7 +77,7 @@ const PROVIDERS: ProviderRow[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* `allow_insecure_tls` defaults to 0 on every provider — unlike the Synology
|
||||
* `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.
|
||||
@@ -94,7 +94,7 @@ const FIELDS: ProviderFieldRow[] = [
|
||||
{ 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
|
||||
// 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 },
|
||||
|
||||
@@ -4957,7 +4957,7 @@ function runMigrations(db: Database.Database): void {
|
||||
* 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
|
||||
* 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 field columns follow
|
||||
* `photo_provider_fields` except for `settings_key` and `payload_key`.
|
||||
@@ -5002,7 +5002,7 @@ function runMigrations(db: Database.Database): void {
|
||||
* 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
|
||||
* "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.
|
||||
@@ -5014,7 +5014,7 @@ function runMigrations(db: Database.Database): void {
|
||||
* 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.
|
||||
* someone will forget, and a forgotten column does not survive a rotation.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
@@ -5082,7 +5082,7 @@ function runMigrations(db: Database.Database): void {
|
||||
* 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`.
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -110,7 +110,7 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
|
||||
// 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.
|
||||
// run. It never reads the request body as data.
|
||||
'DocSyncWebhookController.nudge',
|
||||
'FeaturesController.features',
|
||||
// The container/uptime probe.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DocSyncConfigService } from './doc-sync-config.service';
|
||||
import { DocSyncService } from './doc-sync.service';
|
||||
|
||||
/**
|
||||
* `/api/docsync/webhook/:token` — the one endpoint a provider calls.
|
||||
* `/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
|
||||
@@ -22,7 +22,7 @@ import { DocSyncService } from './doc-sync.service';
|
||||
*
|
||||
* `@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
|
||||
* 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')
|
||||
@@ -32,7 +32,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
*
|
||||
* Providers fire per document: dropping twenty files into a watched Nextcloud
|
||||
* folder is twenty calls within a second or two. Each one used to start its
|
||||
* own run, which the service's in-flight guard then answered with `busy` —
|
||||
* own run, which the service's in-flight guard then answered with `busy`,
|
||||
* so nineteen changes were announced and thrown away, and the one run that
|
||||
* did start had begun before most of them landed. Collecting them for a beat
|
||||
* and then running once is both less work and more correct.
|
||||
@@ -50,7 +50,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
* The same two switches the scheduler obeys.
|
||||
*
|
||||
* Without them "switch document sync off" meant "stop the poll", while every
|
||||
* provider holding a webhook kept driving full runs — an admin turning the
|
||||
* provider holding a webhook kept driving full runs: an admin turning the
|
||||
* addon off would have watched it carry on. Checked when the timer fires as
|
||||
* well as on arrival, so a switch thrown during the debounce window still
|
||||
* takes effect.
|
||||
@@ -85,7 +85,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
|
||||
// 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 — so the answer goes out now and the run happens
|
||||
// there is anything to do, so the answer goes out now and the run happens
|
||||
// after the debounce window, by which time the rest of the burst has
|
||||
// arrived and been folded into this same timer.
|
||||
this.schedule(link.id, () => this.config.getLink(link.id));
|
||||
@@ -110,7 +110,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
void this.sync.syncLink(fresh).then((res) => {
|
||||
// A run that was already in flight answers `busy`, and the changes this
|
||||
// nudge was about may have landed after that run read the folder. Ask
|
||||
// again once rather than waiting out a whole poll interval — once, and
|
||||
// again once rather than waiting out a whole poll interval: once, and
|
||||
// only for `busy`, so this cannot become a loop.
|
||||
if (res?.state === 'busy' && !isRetry) this.schedule(linkId, reload, true);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export const PROVIDER_MAX_PAGES = 40;
|
||||
* 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
|
||||
* `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;
|
||||
@@ -66,7 +66,7 @@ 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
|
||||
* 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;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
* answer.
|
||||
*
|
||||
* Which is why the cron itself runs every minute and the tick decides whether it
|
||||
* is due: baking the interval into the cron expression at bootstrap — as this
|
||||
* did until the claim above was checked against the code — meant a changed
|
||||
* is due: baking the interval into the cron expression at bootstrap (as this
|
||||
* did until the claim above was checked against the code) meant a changed
|
||||
* interval did nothing until a restart, quietly, while the admin screen said
|
||||
* otherwise.
|
||||
*/
|
||||
@@ -67,7 +67,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
|
||||
private isDue(now: number): boolean {
|
||||
// Null rather than 0: comparing against the epoch means "due" only once the
|
||||
// clock has passed the interval since 1970, which is true in production and
|
||||
// false for any test that picks a small timestamp — a difference that would
|
||||
// false for any test that picks a small timestamp, a difference that would
|
||||
// have hidden here rather than in the behaviour it is supposed to describe.
|
||||
if (this.lastRunAt === null) return true;
|
||||
return now - this.lastRunAt >= this.intervalSeconds() * 1000;
|
||||
@@ -93,7 +93,7 @@ export class DocSyncJob implements OnApplicationBootstrap {
|
||||
this.lastRunAt = now;
|
||||
|
||||
// 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.
|
||||
// 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`);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DOCUMENT_PROVIDERS, type DocumentProvider } from './document-provider';
|
||||
* 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.
|
||||
* `documents` addon. This only knows what the server can talk to at all.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocumentProviderRegistry {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
* 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:
|
||||
* 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
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
* 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
|
||||
* 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
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
*
|
||||
* 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
|
||||
* `docker pull`. That is exactly how the `checksum` field moved out of the
|
||||
* document serializer.
|
||||
*/
|
||||
|
||||
@@ -177,7 +177,7 @@ 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
|
||||
* 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;
|
||||
@@ -185,7 +185,7 @@ 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
|
||||
* 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];
|
||||
@@ -343,8 +343,8 @@ function transportError(failure: TransportFailure): PaperlessError {
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* 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 {
|
||||
@@ -394,7 +394,7 @@ function headerSafe(value: string): string {
|
||||
* 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
|
||||
* 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(
|
||||
@@ -471,7 +471,7 @@ export class PaperlessClient {
|
||||
|
||||
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
|
||||
// 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(
|
||||
@@ -571,7 +571,7 @@ export class PaperlessClient {
|
||||
*
|
||||
* `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 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.
|
||||
*
|
||||
@@ -622,7 +622,7 @@ export class PaperlessClient {
|
||||
*
|
||||
* 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
|
||||
* have landed and a blind retry would leave two: Paperless does not refuse
|
||||
* duplicate bytes.
|
||||
*/
|
||||
async findByChecksum(creds: PaperlessCreds, sha256: string): Promise<PaperlessDocument[]> {
|
||||
@@ -665,7 +665,7 @@ export class PaperlessClient {
|
||||
*
|
||||
* 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
|
||||
* 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> {
|
||||
@@ -747,7 +747,7 @@ export class PaperlessClient {
|
||||
* 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`
|
||||
* whether the upload landed (the checksum filter), so this reports `timeout`
|
||||
* rather than waiting longer.
|
||||
*/
|
||||
async awaitConsume(
|
||||
@@ -833,7 +833,7 @@ export class PaperlessClient {
|
||||
* 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
|
||||
* 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> {
|
||||
|
||||
@@ -31,14 +31,14 @@ import { DOWNLOAD_MAX_BYTES, guardDownload } from './provider-http';
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
@@ -70,7 +70,7 @@ const PUSH_FALLBACK_MAX_BYTES = 64 * 1024 * 1024;
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@@ -356,7 +356,7 @@ export class PaperlessDocumentProvider implements DocumentProvider {
|
||||
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 —
|
||||
// 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);
|
||||
@@ -526,7 +526,7 @@ export class PaperlessDocumentProvider implements DocumentProvider {
|
||||
*
|
||||
* 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
|
||||
* most: after a human deleted the field, when every later upload would fail
|
||||
* with a validation error nobody can read.
|
||||
*/
|
||||
private async create(
|
||||
@@ -546,15 +546,15 @@ export class PaperlessDocumentProvider implements DocumentProvider {
|
||||
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,
|
||||
// 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);
|
||||
// Only a document carrying this scope's tag can be the one this push
|
||||
// made: the tag goes on at upload. Anything else with the same bytes
|
||||
// belongs to someone else's filing, and adopting it would put a
|
||||
// stranger's document in the trip — and delete it on the next
|
||||
// stranger's document in the trip, and delete it on the next
|
||||
// delete-through.
|
||||
const mine = existing.find((doc) => doc.tagIds.includes(tagId));
|
||||
if (mine !== undefined) return mine.id;
|
||||
|
||||
@@ -20,12 +20,12 @@ import { Readable } from 'node:stream';
|
||||
*
|
||||
* - **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
|
||||
* `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
|
||||
* `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
|
||||
@@ -53,7 +53,7 @@ const TRANSFER_TIMEOUT_MS = 180000;
|
||||
const MAX_JSON_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* A runaway guard on a download, not the sync core's file-size policy — that
|
||||
* 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.
|
||||
*/
|
||||
@@ -74,7 +74,7 @@ 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. */
|
||||
/** `org_` + 24 lowercase alphanumerics. Papra 400s on anything else. */
|
||||
const ORG_ID_PATTERN = /^org_[a-z0-9]{24}$/;
|
||||
|
||||
export interface PapraCreds {
|
||||
@@ -140,7 +140,7 @@ export interface PapraDownload {
|
||||
* 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,
|
||||
* 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 {
|
||||
@@ -248,8 +248,8 @@ export function snapshotVersion(doc: PapraDocument): string {
|
||||
* 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
|
||||
* 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 {
|
||||
@@ -440,9 +440,9 @@ export class PapraClient {
|
||||
* 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
|
||||
* 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.
|
||||
* than ignoring it), so the sort and the query are dropped there.
|
||||
*/
|
||||
async listDocuments(
|
||||
creds: PapraCreds,
|
||||
@@ -548,7 +548,7 @@ export class PapraClient {
|
||||
*
|
||||
* 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
|
||||
* 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(
|
||||
@@ -669,7 +669,7 @@ export class PapraClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a 200 from the upload route describes a document Papra already held —
|
||||
* 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
|
||||
@@ -683,7 +683,7 @@ export class PapraClient {
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -29,7 +29,7 @@ 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
|
||||
* 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
|
||||
@@ -73,7 +73,7 @@ export class PapraDocumentProvider implements DocumentProvider {
|
||||
): 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
|
||||
// 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) };
|
||||
@@ -164,7 +164,7 @@ export class PapraDocumentProvider implements DocumentProvider {
|
||||
* `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.
|
||||
* 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
|
||||
@@ -197,7 +197,7 @@ export class PapraDocumentProvider implements DocumentProvider {
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
@@ -257,7 +257,7 @@ export class PapraDocumentProvider implements DocumentProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Papra has no single-tag route, so existence is answered by the list — the
|
||||
// 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);
|
||||
@@ -329,7 +329,7 @@ function toRemote(doc: PapraDocument): RemoteDocument {
|
||||
/**
|
||||
* Whether a listed document really carries the bound tag.
|
||||
*
|
||||
* The search already filters server-side, so this is belt and braces — except
|
||||
* 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
|
||||
@@ -345,7 +345,7 @@ function inScope(doc: PapraDocument, tagId: string, serverFiltered: boolean): bo
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DOWNLOAD_MAX_BYTES, guardDownload, providerFetch, statusErrorCode } fro
|
||||
* 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
|
||||
* 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
|
||||
@@ -152,7 +152,7 @@ const PROPFIND_BODY =
|
||||
'</d:prop></d:propfind>';
|
||||
|
||||
/**
|
||||
* Namespace for the dead properties TREK writes. A URI, not an address — it is
|
||||
* 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.
|
||||
*/
|
||||
@@ -279,7 +279,7 @@ function classifyStatus(status: number): DocsyncErrorCode {
|
||||
* 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
|
||||
* `truncated` is for. The alternative is telling someone with one big folder
|
||||
* that nothing works at all.
|
||||
*/
|
||||
function repairTruncatedMultistatus(xml: string): string | null {
|
||||
@@ -401,7 +401,7 @@ function escapeXmlText(value: string): string {
|
||||
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
|
||||
* application, and hands the caller the response otherwise: a 404 on DELETE
|
||||
* and a 405 on MKCOL are answers, not failures.
|
||||
*/
|
||||
private async send(
|
||||
@@ -477,7 +477,7 @@ export class WebdavClient {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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(
|
||||
@@ -498,7 +498,7 @@ export class WebdavClient {
|
||||
* 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 —
|
||||
* 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> {
|
||||
@@ -676,7 +676,7 @@ export class WebdavClient {
|
||||
return drives;
|
||||
}
|
||||
|
||||
/** One space, or null when it is gone — which is what makes `scope_missing` detectable. */
|
||||
/** 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)}`));
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
* 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,
|
||||
* 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
|
||||
@@ -42,8 +42,8 @@ import {
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,7 @@ const WEBHOOK_EVENTS = [
|
||||
/**
|
||||
* Header the subscription carries the shared secret in.
|
||||
*
|
||||
* It has to be the one the endpoint reads — `doc-sync-webhook.controller.ts`
|
||||
* It has to be the one the endpoint reads: `doc-sync-webhook.controller.ts`
|
||||
* looks for `x-trek-docsync-secret`. It used to say `X-TREK-Docsync-Signature`
|
||||
* here, so every webhook Nextcloud actually sent arrived without a secret the
|
||||
* controller could find and was silently dropped: the subscription existed, the
|
||||
@@ -131,7 +131,7 @@ function credsOf(conn: DocumentConnectionRef): WebdavCreds | null {
|
||||
return { origin, username, password, allowInsecureTls: conn.allowInsecureTls, flavor };
|
||||
}
|
||||
|
||||
/** `/remote.php/dav/files/<login>` — the per-account root of Nextcloud's file DAV. */
|
||||
/** `/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)}`;
|
||||
}
|
||||
@@ -154,7 +154,7 @@ function scopeIdOf(scopeKey: string, flavor: WebdavFlavor): string | null {
|
||||
*
|
||||
* 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
|
||||
* 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 {
|
||||
@@ -204,7 +204,7 @@ function documentOf(entry: WebdavEntry, rootDecoded: string): RemoteDocument {
|
||||
*
|
||||
* `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
|
||||
* 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> {
|
||||
@@ -236,9 +236,9 @@ export class WebdavDocumentProvider implements DocumentProvider {
|
||||
// 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
|
||||
// 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
|
||||
// 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,
|
||||
@@ -811,7 +811,7 @@ export class WebdavDocumentProvider implements DocumentProvider {
|
||||
const relative = remoteId.slice(PATH_ID_PREFIX.length);
|
||||
// The path comes out of a listing, which is the provider's word and not
|
||||
// TREK's. A `..` in it would address a file outside the folder the trip is
|
||||
// bound to — every other id here is opaque, this one is a path and has to
|
||||
// bound to. Every other id here is opaque, this one is a path and has to
|
||||
// be treated like one.
|
||||
if (!relative || relative.split('/').some(seg => seg === '..' || seg === '.')) return null;
|
||||
return `${resolved.rootPath}/${encodePath(relative)}`;
|
||||
@@ -836,7 +836,7 @@ export class WebdavDocumentProvider implements DocumentProvider {
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Document sync e2e — drives /api/trips/:tripId/docsync through the REAL
|
||||
* 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.
|
||||
*
|
||||
@@ -14,7 +14,7 @@
|
||||
* 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 —
|
||||
* 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';
|
||||
@@ -51,7 +51,7 @@ 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
|
||||
* 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.
|
||||
@@ -381,7 +381,7 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () =>
|
||||
});
|
||||
|
||||
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
|
||||
// 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`)
|
||||
|
||||
@@ -25,14 +25,14 @@ import type { DatabaseService } from '../../../../src/nest/database/database.ser
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* The signature is computed here from a fixed id and timestamp, exactly as the
|
||||
* provider would. Timers are faked, because the endpoint now collects a burst
|
||||
* before it runs: providers fire once per document, and twenty files dropped
|
||||
* into a watched folder used to be twenty runs — nineteen of which the service
|
||||
* into a watched folder used to be twenty runs, nineteen of which the service
|
||||
* answered `busy` and threw away.
|
||||
*/
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('a shared-secret header', () => {
|
||||
expect(sync.syncLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not demanded from a binding that carries no secret — the token alone authenticates there', () => {
|
||||
it('is not demanded from a binding that carries no secret: the token alone authenticates there', () => {
|
||||
config.webhookSecret.mockReturnValue('');
|
||||
controller.nudge('tok-live', makeReq());
|
||||
settle();
|
||||
@@ -268,7 +268,7 @@ describe('a credential of the wrong length', () => {
|
||||
* A burst of calls is one run.
|
||||
*
|
||||
* Every provider here fires per document: a folder of twenty files is twenty
|
||||
* calls within a second or two. Each used to start its own run — the service's
|
||||
* calls within a second or two. Each used to start its own run. The service's
|
||||
* in-flight guard then answered `busy` to nineteen of them, so nineteen
|
||||
* announcements were thrown away and the one run that did start had begun
|
||||
* before most of the changes landed.
|
||||
@@ -375,7 +375,7 @@ describe('the admin switches', () => {
|
||||
/**
|
||||
* A nudge that lands on a run already in flight.
|
||||
*
|
||||
* `syncLink` answers `busy` and returns, so the nudge was thrown away — and the
|
||||
* `syncLink` answers `busy` and returns, so the nudge was thrown away, and the
|
||||
* changes it was about may well have landed after the running pass read the
|
||||
* folder, which means waiting out a whole poll interval for them. Asked again
|
||||
* once, and only once, so two clients cannot keep each other going.
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('DocSyncJob bootstrap', () => {
|
||||
it('registers one cron under a name of its own, on the minute', () => {
|
||||
// Every minute, with the tick deciding whether it is due. Baking the
|
||||
// interval into the expression at bootstrap meant a changed setting did
|
||||
// nothing until a restart — which is the opposite of what this job's own
|
||||
// nothing until a restart, which is the opposite of what this job's own
|
||||
// comment promises, and nothing re-registers it (auto-backup has a start()
|
||||
// its settings save calls; this has no such path).
|
||||
const { job, registrar } = makeJob();
|
||||
@@ -243,7 +243,7 @@ describe('DocSyncJob tick', () => {
|
||||
it('recovers on the next due tick after a failed one', async () => {
|
||||
// A failed pass still spends its interval, on purpose: a tick that throws
|
||||
// every time would otherwise run on every minute the cron fires. It does not
|
||||
// block anything permanently — the next due tick works normally.
|
||||
// block anything permanently: the next due tick works normally.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(10_000_000));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { idSchema } from '../common/primitives.schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Document provider contracts — two-way sync between a trip's documents and a
|
||||
* Document provider contracts: two-way sync between a trip's documents and a
|
||||
* self-hosted document store (Paperless-ngx, Papra, Nextcloud, OpenCloud,
|
||||
* Synology Drive).
|
||||
*
|
||||
@@ -16,7 +16,7 @@ import { z } from 'zod';
|
||||
* 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
|
||||
* 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,
|
||||
@@ -63,9 +63,9 @@ export const docsyncConnectionInputSchema = z.object({
|
||||
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.
|
||||
* them. A secret that is omitted, blank or the mask keeps the stored value.
|
||||
* That is 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. */
|
||||
@@ -157,7 +157,7 @@ export type DocsyncLinkInput = z.infer<typeof docsyncLinkInputSchema>;
|
||||
* Which folder or tag a binding points at is fixed once it exists: every
|
||||
* pairing in `document_sync_items` was made against that container, and moving
|
||||
* it would leave them all pointing somewhere else. The root fields are left out
|
||||
* rather than ignored — they were accepted and silently dropped, so a client
|
||||
* rather than ignored: they were accepted and silently dropped, so a client
|
||||
* sending one was told the move had happened.
|
||||
*
|
||||
* Spelled out instead of derived from the input schema with `.partial()`. In
|
||||
@@ -183,7 +183,7 @@ export const docsyncLinkUpdateSchema = z
|
||||
* `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
|
||||
* 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([
|
||||
|
||||
Reference in New Issue
Block a user