mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
fix(docsync): keep the Synology device token across a restart, tied to its row
The DSM trusted-device token lived in process memory only. After a restart every token was gone and the stored one-time code long spent, so every two-factor NAS binding stopped syncing until its owner typed a fresh code, and nothing on screen said why. The token is now kept in the connection's encrypted secrets, together with a digest of the instance and account it was issued for. A saved connection's ref carries a saveSecret callback that writes through DocSyncConfigService.saveEarnedSecret, which rereads the row inside a transaction so a sync run holding an old ref cannot undo a password the owner changed meanwhile. The key belongs to no form field, so maskSecrets never reports it and mergeSecrets never touches it: the GET and PUT payloads do not carry it, an edit keeps it, and a client cannot plant one. It survives a password edit and a spent or cleared code. A connection repointed at another NAS or account drops it on its next call, and a token DSM refuses with 403 or 404 is dropped in memory and in storage. A probe of form values and an unsaved form may use what is stored but never write. A refused token no longer shadows a fresh code: the code in the form gets its turn in the same login call. In memory the slot is keyed by the connection id together with the row's created_at. A backup restored in place or the demo reset rolls the id sequence back without a restart, and a new connection that reused an id must not log in on the token of the row it replaced. The Synology adapter receives its client through DI like Paperless and WebDAV, instead of building a second instance beside the one the module registers. Two readings of a failed request are settled as well: - INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an untrusted certificate again, so the settings screen offers the self-signed switch. Papra used to find them through the message text, and the shared classifier reads codes only. - A host that never completes the TCP handshake reads as unreachable on every client. WebDAV said timeout before the shared transport; the change is now documented and tested.
This commit is contained in:
@@ -9,7 +9,7 @@ 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
|
||||
* 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
|
||||
@@ -41,6 +41,7 @@ export interface ConnectionRow {
|
||||
last_probe_at: string | null;
|
||||
last_probe_state: string;
|
||||
last_probe_error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LinkRow {
|
||||
@@ -69,7 +70,7 @@ export interface LinkRow {
|
||||
|
||||
interface ProviderFieldRow {
|
||||
field_key: string;
|
||||
/** i18n key suffix, never display text — the client resolves `docsync.<label>`. */
|
||||
/** i18n key suffix, never display text: the client resolves `docsync.<label>`. */
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder: string | null;
|
||||
@@ -127,7 +128,8 @@ export class DocSyncConfigService {
|
||||
/**
|
||||
* 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.
|
||||
* leak them into a log. The ref can write back what the adapter earns, which
|
||||
* is what makes it a saved connection's ref; see `saveEarnedSecret`.
|
||||
*/
|
||||
toRef(row: ConnectionRow): DocumentConnectionRef {
|
||||
let settings: Record<string, string> = {};
|
||||
@@ -143,14 +145,44 @@ export class DocSyncConfigService {
|
||||
}
|
||||
return {
|
||||
connectionId: row.id,
|
||||
createdAt: row.created_at,
|
||||
ownerId: row.owner_user_id,
|
||||
baseUrl: row.base_url,
|
||||
secrets: decryptSecrets(row.secrets),
|
||||
settings,
|
||||
allowInsecureTls: row.allow_insecure_tls === 1,
|
||||
saveSecret: (key, value) => this.saveEarnedSecret(row.id, key, value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a secret the provider earned itself rather than one typed into the
|
||||
* form, or drop it with null. DSM's device token is the one there is: without
|
||||
* it, a restart would leave every two-factor NAS waiting for its owner to
|
||||
* type a fresh code.
|
||||
*
|
||||
* It lives in the same encrypted blob as the form's secrets, under a key no
|
||||
* form field has, and that is what keeps it out of every response
|
||||
* (`maskSecrets` reports form fields only) and out of the form's reach
|
||||
* (`mergeSecrets` writes form fields only).
|
||||
*
|
||||
* The blob is read back inside the transaction rather than taken from the
|
||||
* caller's ref: a sync run holds its ref for minutes, and writing that
|
||||
* snapshot back would undo a password the owner changed in the meantime.
|
||||
*/
|
||||
saveEarnedSecret(connectionId: number, key: string, value: string | null): void {
|
||||
this.db.transaction(() => {
|
||||
const row = this.getConnection(connectionId);
|
||||
if (!row) return;
|
||||
const secrets = decryptSecrets(row.secrets);
|
||||
if (value === null) delete secrets[key];
|
||||
else secrets[key] = value;
|
||||
this.db.connection
|
||||
.prepare('UPDATE document_connections SET secrets = ? WHERE id = ?')
|
||||
.run(encryptSecrets(secrets), connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -174,7 +206,7 @@ export class DocSyncConfigService {
|
||||
* 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
|
||||
* 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.
|
||||
@@ -194,6 +226,10 @@ export class DocSyncConfigService {
|
||||
*
|
||||
* 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.
|
||||
* A secret the provider earned itself is kept too, whatever the form sends.
|
||||
* DSM's device token is stored with the address and account it was issued
|
||||
* for, so an edit that points the connection somewhere else leaves it
|
||||
* unusable, and the adapter drops it on its next call.
|
||||
*/
|
||||
async upsertConnection(
|
||||
tripId: number,
|
||||
@@ -274,7 +310,7 @@ export class DocSyncConfigService {
|
||||
|
||||
/**
|
||||
* Deleting a connection leaves every document in place. It unbinds, nothing
|
||||
* more — the same promise Dawarich's disconnect makes, and the only version
|
||||
* 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 {
|
||||
@@ -376,7 +412,7 @@ export class DocSyncConfigService {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* document on either side. A user who wants that does it deliberately, in
|
||||
* the system that holds the file.
|
||||
*/
|
||||
deleteLink(id: number): void {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export function encryptSecrets(secrets: SecretMap): string | null {
|
||||
* 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
|
||||
* 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 {
|
||||
@@ -50,9 +50,13 @@ export function decryptSecrets(stored: string | null | undefined): SecretMap {
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* A stored key that is no form field is a secret the provider earned itself
|
||||
* (DSM's device token). It is carried over untouched and cannot be set from
|
||||
* here: the form never saw it, so an edit can neither wipe it nor plant one.
|
||||
*/
|
||||
export function mergeSecrets(stored: SecretMap, submitted: Record<string, string>, secretKeys: readonly string[]): SecretMap {
|
||||
const next: SecretMap = { ...stored };
|
||||
@@ -65,7 +69,11 @@ export function mergeSecrets(stored: SecretMap, submitted: Record<string, string
|
||||
return next;
|
||||
}
|
||||
|
||||
/** What a client is allowed to see: whether a secret is set, never its value. */
|
||||
/**
|
||||
* What a client is allowed to see: whether a secret is set, never its value.
|
||||
* Form fields only, so a secret the provider earned itself is not even
|
||||
* reported as present.
|
||||
*/
|
||||
export function maskSecrets(secrets: SecretMap, secretKeys: readonly string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of secretKeys) {
|
||||
|
||||
@@ -190,6 +190,7 @@ export class DocSyncController {
|
||||
deleteConnection(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('connectionId') connectionId: string,
|
||||
createdAt: existing?.created_at ?? '',
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
this.assertCanManage(tripId, user);
|
||||
|
||||
@@ -4,8 +4,8 @@ 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
|
||||
* Shaped after `PhotoProvider` (memories/photo-provider.ts): interface, symbol
|
||||
* token, registry. One difference is deliberate: 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
|
||||
@@ -16,7 +16,7 @@ import type { DocsyncErrorCode } from '@trek/shared';
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
@@ -58,17 +58,37 @@ export function docFailed<T>(result: DocResult<T>): result is DocFailure {
|
||||
export interface DocumentConnectionRef {
|
||||
connectionId: number;
|
||||
/**
|
||||
* The user whose credentials these are — the trip admin who set the binding
|
||||
* When the connection's row was created, or '' for form values that were
|
||||
* never saved. The id alone does not name one row for good: a backup
|
||||
* restored in place rolls the id sequence back, and the id comes round
|
||||
* again on the next connection saved, on any trip. The two together do.
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* 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. */
|
||||
/**
|
||||
* Decrypted `secret = 1` fields, plus whatever the provider earned and kept
|
||||
* through `saveSecret`. 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;
|
||||
/**
|
||||
* Keep a secret the provider earned itself (DSM's device token) with this
|
||||
* connection, or drop it with null. `secrets` is the snapshot this ref was
|
||||
* made from and does not change when this writes.
|
||||
*
|
||||
* Only a saved connection has one. A probe of form values may use what is
|
||||
* stored but never changes it: the values on screen may be for another
|
||||
* account, or never be saved at all.
|
||||
*/
|
||||
saveSecret?: (key: string, value: string | null) => void;
|
||||
}
|
||||
|
||||
/** The container at the provider that one trip is bound to. */
|
||||
@@ -96,7 +116,7 @@ export interface RemoteDocument {
|
||||
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
|
||||
* conclude from it. It is not a content hash, not comparable across
|
||||
* instances, and not ordered.
|
||||
*/
|
||||
remoteVersion: string;
|
||||
@@ -190,7 +210,7 @@ export interface DocumentProvider {
|
||||
|
||||
/**
|
||||
* Verify form values before anything is stored, and report what the account
|
||||
* can actually do. Always resolves — a failed probe is data, not an
|
||||
* 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).
|
||||
*/
|
||||
|
||||
@@ -59,20 +59,33 @@ export interface GuardedDownload {
|
||||
*/
|
||||
export const DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/** undici's own timers, plus the abort `AbortSignal.timeout` fires. */
|
||||
/**
|
||||
* The abort `AbortSignal.timeout` fires, and undici's header and body timers:
|
||||
* a host that took the request and then went quiet. undici's connect timer is
|
||||
* left out on purpose. A host that never completed the handshake (switched
|
||||
* off, or behind a firewall that drops the SYN) did not answer at all, so it
|
||||
* reads as unreachable.
|
||||
*/
|
||||
const TIMEOUT_NAME = /^(TimeoutError|AbortError)$/;
|
||||
const TIMEOUT_CODE = /ABORT_ERR|HEADERS_TIMEOUT|BODY_TIMEOUT/i;
|
||||
|
||||
/**
|
||||
* Codes that are a verdict on the certificate rather than on the connection,
|
||||
* which is what lets the settings screen offer the self-signed switch instead
|
||||
* of a dead end. Most carry CERT or TLS in the name; these two do not.
|
||||
* of a dead end. Most carry CERT or TLS in the name; the verify codes listed
|
||||
* here do not, and the switch gets past each of them all the same.
|
||||
*
|
||||
* ERR_SSL_* is deliberately not among them: a handshake that fails on the
|
||||
* protocol (https against a plain http port, say) is not something the switch
|
||||
* can fix, and `tls_untrusted` tells the user it is.
|
||||
*/
|
||||
const CERTIFICATE_CODES = new Set(['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'HOSTNAME_MISMATCH']);
|
||||
const CERTIFICATE_CODES = new Set([
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'HOSTNAME_MISMATCH',
|
||||
'INVALID_PURPOSE',
|
||||
'INVALID_CA',
|
||||
'PATH_LENGTH_EXCEEDED',
|
||||
]);
|
||||
const CERTIFICATE_CODE = /CERT|SELF_SIGNED|ERR_TLS/i;
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
* This is the ONLY place that talks to a user's NAS for document sync.
|
||||
*
|
||||
* Written against Synology's File Station Official API guide and verified
|
||||
* against the FileStation stub in `trek-docsync-testlab/syno-stub` — DSM itself
|
||||
* against the FileStation stub in `trek-docsync-testlab/syno-stub`. DSM itself
|
||||
* cannot be containerised, so the parts marked below as unverified are the ones
|
||||
* no reachable instance could confirm.
|
||||
*
|
||||
@@ -72,7 +72,7 @@ const SESSION_MAX_AGE_MS = 30 * 60 * 1000;
|
||||
* five failures in five minutes, and a link that polls every five minutes would
|
||||
* walk into it in under half an hour. A minute is long enough to keep any one
|
||||
* sync run from spending a second attempt, and short enough that an admin who
|
||||
* fixes the password does not wait — a corrected password changes the
|
||||
* fixes the password does not wait: a corrected password changes the
|
||||
* fingerprint and clears the lockout immediately.
|
||||
*/
|
||||
const CREDENTIAL_LOCKOUT_MS = 60 * 1000;
|
||||
@@ -209,6 +209,8 @@ export interface SynologyDriveCreds {
|
||||
* not been saved yet. It keys the device token; see `deviceTokens`.
|
||||
*/
|
||||
connectionId: number;
|
||||
/** When that connection's row was created. With the id, it keys the device token. */
|
||||
connectionCreatedAt: string;
|
||||
/** Instance origin including the DSM port, e.g. `https://nas.example.com:5001`. */
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
@@ -216,9 +218,17 @@ export interface SynologyDriveCreds {
|
||||
/**
|
||||
* A TOTP code from the connection form. Single-use by construction, so it can
|
||||
* only ever serve the first login; the device token that login returns is what
|
||||
* keeps later logins working, and it lives in this process's memory only.
|
||||
* keeps later logins working.
|
||||
*/
|
||||
otpCode?: string;
|
||||
/** The connection's device token as stored, in the form `storedDeviceToken` writes. */
|
||||
storedDeviceToken?: string;
|
||||
/**
|
||||
* Store a new token in that form, or drop it with null. Only a saved
|
||||
* connection passes one; a probe of form values uses the stored token without
|
||||
* being able to change it.
|
||||
*/
|
||||
saveDeviceToken?: (stored: string | null) => void;
|
||||
allowInsecureTls: boolean;
|
||||
}
|
||||
|
||||
@@ -296,6 +306,18 @@ interface CredentialLockout {
|
||||
synoCode: number;
|
||||
}
|
||||
|
||||
/** One connection's device token, and what its storage holds as far as this process knows. */
|
||||
interface DeviceTokenSlot {
|
||||
/** Digest of the instance and account the token was issued for. */
|
||||
account: string;
|
||||
token: string | null;
|
||||
/**
|
||||
* The stored form this process last read or wrote, or undefined until a
|
||||
* caller that can write has shown what storage holds.
|
||||
*/
|
||||
stored?: string | null;
|
||||
}
|
||||
|
||||
interface SynoEnvelope {
|
||||
success?: unknown;
|
||||
data?: unknown;
|
||||
@@ -430,6 +452,22 @@ function appError(synoCode: number, isAuth: boolean): SynologyDriveError {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A device token the way a connection stores it: `<account digest>:<token>`.
|
||||
* The account travels with it, so a connection repointed at another NAS or
|
||||
* another account reads its old token as foreign instead of offering it there.
|
||||
*/
|
||||
function storedDeviceToken(account: string, token: string): string {
|
||||
return `${account}:${token}`;
|
||||
}
|
||||
|
||||
/** The token out of its stored form, or null when none is stored for this account. */
|
||||
function deviceTokenFor(stored: string | undefined, account: string): string | null {
|
||||
const prefix = `${account}:`;
|
||||
if (!stored?.startsWith(prefix) || stored.length === prefix.length) return null;
|
||||
return stored.slice(prefix.length);
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class SynologyDriveClient {
|
||||
@@ -441,7 +479,7 @@ export class SynologyDriveClient {
|
||||
* displaces the first, so two TREK connections pointing at the same NAS with
|
||||
* the same account share one session instead of evicting each other. The
|
||||
* password is part of the key because a connection test with a corrected
|
||||
* password has to reach the NAS — riding the session the old password opened
|
||||
* password has to reach the NAS: riding the session the old password opened
|
||||
* would report success for a credential that no longer works. It is also what
|
||||
* makes an explicit "forget this session" unnecessary: an edited credential
|
||||
* is a different key and cannot reach the old entry.
|
||||
@@ -452,7 +490,7 @@ export class SynologyDriveClient {
|
||||
private readonly lockouts = new Map<string, CredentialLockout>();
|
||||
|
||||
/**
|
||||
* DSM's trusted-device tokens, one per TREK connection.
|
||||
* DSM's trusted-device tokens, one slot per TREK connection.
|
||||
*
|
||||
* The token is DSM's record that somebody passed the second factor, and that
|
||||
* somebody is whoever typed the code into this connection's form. Keyed by
|
||||
@@ -460,17 +498,27 @@ export class SynologyDriveClient {
|
||||
* never had the authenticator could point a connection at the same account
|
||||
* and log in on the first trip's token.
|
||||
*
|
||||
* The connection is its id together with the time its row was created. The
|
||||
* id alone comes round again: a backup restored in place, or the demo reset,
|
||||
* rolls the id sequence back without restarting the process, and the next
|
||||
* connection saved, on whatever trip, is handed an id this map may still hold
|
||||
* a token under.
|
||||
*
|
||||
* Not keyed by the password or the code: the token has to survive a password
|
||||
* edit and the single-use OTP going stale in the form, or the next session
|
||||
* expiry needs a human with an authenticator app. An edit keeps the
|
||||
* connection id, so it keeps the token. Instance and account stay in the key
|
||||
* so a connection repointed at another NAS or account starts without one.
|
||||
* expiry needs a human with an authenticator app. An edit updates the row in
|
||||
* place, so it keeps the token. The slot remembers the instance and
|
||||
* account the token was issued for, so a connection repointed at another NAS
|
||||
* or account starts without one.
|
||||
*
|
||||
* A form that has not been saved has no id to file a token under (every
|
||||
* unsaved form is connection 0), so what its probe earns rides on the session
|
||||
* only, and the saved connection adopts it from there; see `session()`.
|
||||
*
|
||||
* This is the working copy. The one that survives a restart is in the
|
||||
* connection's encrypted secrets; see `deviceSlot()`.
|
||||
*/
|
||||
private readonly deviceTokens = new Map<string, string>();
|
||||
private readonly deviceTokens = new Map<string, DeviceTokenSlot>();
|
||||
|
||||
/** The credential this session and lockout belong to, without storing it. */
|
||||
private sessionKey(creds: SynologyDriveCreds): string {
|
||||
@@ -483,9 +531,56 @@ export class SynologyDriveClient {
|
||||
return `${normalizeBaseUrl(creds.baseUrl)}\u0000${creds.username}`;
|
||||
}
|
||||
|
||||
/** Where this connection's device token lives, or null for a form not saved yet. */
|
||||
private deviceKey(creds: SynologyDriveCreds): string | null {
|
||||
return creds.connectionId > 0 ? `${creds.connectionId}\u0000${this.accountKey(creds)}` : null;
|
||||
/** What a device token is filed under: instance and account, never the password. */
|
||||
private accountDigest(creds: SynologyDriveCreds): string {
|
||||
return createHash('sha256').update(this.accountKey(creds)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* This connection's device token slot, with storage brought in line with it.
|
||||
* Null for a form that has not been saved.
|
||||
*
|
||||
* Memory is what a login reads, because a sync run holds one snapshot of the
|
||||
* stored secrets from its start, and a token earned or refused halfway
|
||||
* through would otherwise be lost to the rest of the run. Storage is what
|
||||
* survives a restart, and a process that has no slot for the connection yet
|
||||
* starts from it.
|
||||
*
|
||||
* Whenever the two disagree and the caller can write, storage follows memory.
|
||||
* That covers a token earned or refused here, one that a test of the saved
|
||||
* form earned, and one left behind for an instance or account the connection
|
||||
* no longer points at. The comparison is with what this process last read or
|
||||
* wrote rather than with the caller's snapshot, which goes stale the moment
|
||||
* anything is written, so a change is written once and not on every call of
|
||||
* the run that made it.
|
||||
*/
|
||||
private deviceSlot(creds: SynologyDriveCreds): DeviceTokenSlot | null {
|
||||
if (creds.connectionId <= 0) return null;
|
||||
const key = `${creds.connectionId}\u0000${creds.connectionCreatedAt}`;
|
||||
const account = this.accountDigest(creds);
|
||||
let slot = this.deviceTokens.get(key);
|
||||
if (!slot || slot.account !== account) {
|
||||
slot = { account, token: deviceTokenFor(creds.storedDeviceToken, account) };
|
||||
this.deviceTokens.set(key, slot);
|
||||
}
|
||||
this.persist(creds, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): void {
|
||||
if (!creds.saveDeviceToken) return;
|
||||
if (slot.stored === undefined) slot.stored = creds.storedDeviceToken ?? null;
|
||||
const wanted = slot.token === null ? null : storedDeviceToken(slot.account, slot.token);
|
||||
if (wanted === slot.stored) return;
|
||||
creds.saveDeviceToken(wanted);
|
||||
slot.stored = wanted;
|
||||
}
|
||||
|
||||
private setDeviceToken(creds: SynologyDriveCreds, token: string | null): void {
|
||||
const slot = this.deviceSlot(creds);
|
||||
if (!slot || slot.token === token) return;
|
||||
slot.token = token;
|
||||
this.persist(creds, slot);
|
||||
}
|
||||
|
||||
private async request(
|
||||
@@ -596,10 +691,8 @@ export class SynologyDriveClient {
|
||||
private async session(creds: SynologyDriveCreds): Promise<SynoSession> {
|
||||
const cached = this.sessions.get(this.sessionKey(creds));
|
||||
if (cached && Date.now() - cached.createdAt < SESSION_MAX_AGE_MS) {
|
||||
const deviceKey = this.deviceKey(creds);
|
||||
if (deviceKey && cached.deviceId && !this.deviceTokens.has(deviceKey)) {
|
||||
this.deviceTokens.set(deviceKey, cached.deviceId);
|
||||
}
|
||||
const slot = this.deviceSlot(creds);
|
||||
if (slot && slot.token === null && cached.deviceId) this.setDeviceToken(creds, cached.deviceId);
|
||||
return cached;
|
||||
}
|
||||
return await this.login(creds);
|
||||
@@ -610,13 +703,11 @@ export class SynologyDriveClient {
|
||||
*
|
||||
* The connection's device token is reused when there is one: the form's OTP
|
||||
* is a single TOTP code that expires in thirty seconds, so it can only ever
|
||||
* serve the first login of a process. Without the device token, every session
|
||||
* expiry would need a human to type a new code.
|
||||
* serve the first login. Without the device token, every session expiry would
|
||||
* need a human to type a new code.
|
||||
*/
|
||||
private async login(creds: SynologyDriveCreds): Promise<SynoSession> {
|
||||
const key = this.sessionKey(creds);
|
||||
const deviceKey = this.deviceKey(creds);
|
||||
const deviceId = deviceKey ? this.deviceTokens.get(deviceKey) ?? null : null;
|
||||
const lockout = this.lockouts.get(key);
|
||||
if (lockout && lockout.until > Date.now()) {
|
||||
throw new SynologyDriveError(lockout.code, 'The NAS rejected these credentials; not retrying yet', {
|
||||
@@ -625,6 +716,66 @@ export class SynologyDriveClient {
|
||||
});
|
||||
}
|
||||
|
||||
let answer: { data: unknown; deviceId: string | null };
|
||||
try {
|
||||
answer = await this.authenticate(creds);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SynologyDriveError && error.synoCode && error.synoCode >= 400) {
|
||||
this.lockouts.set(key, {
|
||||
until: Date.now() + (error.synoCode === 407 ? AUTOBLOCK_LOCKOUT_MS : CREDENTIAL_LOCKOUT_MS),
|
||||
code: error.code,
|
||||
synoCode: error.synoCode,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { data, deviceId } = answer;
|
||||
const sid = isRecord(data) && typeof data.sid === 'string' ? data.sid : null;
|
||||
if (!sid) {
|
||||
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
|
||||
}
|
||||
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
|
||||
if (issuedDeviceId) this.setDeviceToken(creds, issuedDeviceId);
|
||||
const session: SynoSession = {
|
||||
sid,
|
||||
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
|
||||
deviceId: issuedDeviceId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.lockouts.delete(key);
|
||||
this.sessions.set(key, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login request, on the connection's device token when it has one and on
|
||||
* the form's code otherwise.
|
||||
*
|
||||
* A token DSM refuses (someone removed TREK from the account's trusted
|
||||
* devices, or it expired) is dropped on the spot, and the code in the form
|
||||
* gets its turn in the same call. That code is usually what the owner just
|
||||
* typed to repair exactly this, and a dead token sent ahead of it would fail
|
||||
* every attempt, now that a stored token outlives the process. It is a second
|
||||
* credential rather than a second try of the first, and whichever fails last
|
||||
* is what the lockout records.
|
||||
*/
|
||||
private async authenticate(creds: SynologyDriveCreds): Promise<{ data: unknown; deviceId: string | null }> {
|
||||
const deviceId = this.deviceSlot(creds)?.token ?? null;
|
||||
try {
|
||||
return { data: await this.call(creds, this.loginParams(creds, deviceId), { cgi: AUTH_CGI }), deviceId };
|
||||
} catch (error: unknown) {
|
||||
if (!deviceId || !(error instanceof SynologyDriveError) || !DEVICE_TOKEN_REFUSED_CODES.has(error.synoCode ?? 0)) {
|
||||
throw error;
|
||||
}
|
||||
// Unless another login replaced it while this one was out.
|
||||
if (this.deviceSlot(creds)?.token === deviceId) this.setDeviceToken(creds, null);
|
||||
if (!creds.otpCode) throw error;
|
||||
return { data: await this.call(creds, this.loginParams(creds, null), { cgi: AUTH_CGI }), deviceId: null };
|
||||
}
|
||||
}
|
||||
|
||||
private loginParams(creds: SynologyDriveCreds, deviceId: string | null): Record<string, string> {
|
||||
const params: Record<string, string> = {
|
||||
api: 'SYNO.API.Auth',
|
||||
version: '6',
|
||||
@@ -645,49 +796,14 @@ export class SynologyDriveClient {
|
||||
params.otp_code = creds.otpCode;
|
||||
params.enable_device_token = 'yes';
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await this.call(creds, params, { cgi: AUTH_CGI });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SynologyDriveError && error.synoCode && error.synoCode >= 400) {
|
||||
// A token DSM no longer trusts would otherwise go out ahead of any fresh
|
||||
// code typed into the form, on every attempt, until the process restarts.
|
||||
if (deviceKey && deviceId && DEVICE_TOKEN_REFUSED_CODES.has(error.synoCode)
|
||||
&& this.deviceTokens.get(deviceKey) === deviceId) {
|
||||
this.deviceTokens.delete(deviceKey);
|
||||
}
|
||||
this.lockouts.set(key, {
|
||||
until: Date.now() + (error.synoCode === 407 ? AUTOBLOCK_LOCKOUT_MS : CREDENTIAL_LOCKOUT_MS),
|
||||
code: error.code,
|
||||
synoCode: error.synoCode,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sid = isRecord(data) && typeof data.sid === 'string' ? data.sid : null;
|
||||
if (!sid) {
|
||||
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
|
||||
}
|
||||
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
|
||||
if (deviceKey && issuedDeviceId) this.deviceTokens.set(deviceKey, issuedDeviceId);
|
||||
const session: SynoSession = {
|
||||
sid,
|
||||
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
|
||||
deviceId: issuedDeviceId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.lockouts.delete(key);
|
||||
this.sessions.set(key, session);
|
||||
return session;
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and report what this DSM offers.
|
||||
*
|
||||
* The API inventory is part of it because a DSM with File Station uninstalled
|
||||
* logs in perfectly and then answers 102 to everything — a failure that looks
|
||||
* logs in perfectly and then answers 102 to everything, a failure that looks
|
||||
* like a TREK bug unless the connection test says so.
|
||||
*/
|
||||
async probe(creds: SynologyDriveCreds): Promise<SynoProbe> {
|
||||
@@ -820,7 +936,7 @@ export class SynologyDriveClient {
|
||||
|
||||
// The session is resolved before the body is touched. A stream can only be
|
||||
// read once, so the 106/119 re-login retry that every other call gets is not
|
||||
// available here — better to spend the round trip up front than to fail an
|
||||
// available here: better to spend the round trip up front than to fail an
|
||||
// upload that already read half the file.
|
||||
const session = await this.session(creds);
|
||||
|
||||
@@ -992,7 +1108,7 @@ export class SynologyDriveClient {
|
||||
/**
|
||||
* MD5 of a file already on the NAS.
|
||||
*
|
||||
* MD5, not sha256, because MD5 is the only digest FileStation computes — and
|
||||
* MD5, not sha256, because MD5 is the only digest FileStation computes, and
|
||||
* it is a task per file, so this is only ever a targeted question about one
|
||||
* file, never part of a listing.
|
||||
*/
|
||||
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
@@ -62,10 +62,10 @@ const SCOPE_PREFIX = 'path:';
|
||||
const TRASH_FOLDER = '.trek-trash';
|
||||
|
||||
/**
|
||||
* The adapter's own ceiling for one transfer. Not a policy — the install's limit
|
||||
* The adapter's own ceiling for one transfer. Not a policy: the install's limit
|
||||
* is the upload limit (`MAX_FILE_SIZE`), enforced by the core before and during
|
||||
* every transfer — just a bound so a wrong path or a truncated header cannot
|
||||
* start an unbounded read.
|
||||
* every transfer. This is just a bound so a wrong path or a truncated header
|
||||
* cannot start an unbounded read.
|
||||
*/
|
||||
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
@@ -82,14 +82,21 @@ const MD5_VERIFY_MAX_BYTES = 64 * 1024 * 1024;
|
||||
/** How many scope options one picker call may answer with. */
|
||||
const MAX_SCOPE_OPTIONS = 200;
|
||||
|
||||
/**
|
||||
* Where the connection keeps DSM's device token among its secrets. No form
|
||||
* field has this key, which is what keeps it out of every response and out of
|
||||
* the form's reach; see `saveEarnedSecret` in the config service.
|
||||
*/
|
||||
const DEVICE_TOKEN_SECRET = 'device_token';
|
||||
|
||||
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.
|
||||
// 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,
|
||||
@@ -145,7 +152,7 @@ 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
|
||||
// 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,
|
||||
@@ -157,11 +164,11 @@ export class SynologyDriveDocumentProvider 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.
|
||||
* Injected, like the Paperless and WebDAV clients: it holds the SID cache,
|
||||
* the lockouts and the device token slots, so there has to be exactly one of
|
||||
* it per process, and the module's singleton is that one.
|
||||
*/
|
||||
private readonly client = new SynologyDriveClient();
|
||||
constructor(private readonly client: SynologyDriveClient) {}
|
||||
|
||||
capabilities(): DocumentProviderCapabilities {
|
||||
return CAPABILITIES;
|
||||
@@ -188,7 +195,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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[]>> {
|
||||
@@ -244,7 +251,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
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
|
||||
// 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`);
|
||||
@@ -383,8 +390,8 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
*
|
||||
* 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
|
||||
* 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(
|
||||
@@ -539,12 +546,16 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
const password = conn.secrets.password ?? '';
|
||||
if (!username || !password || !conn.baseUrl.trim()) return null;
|
||||
const otpCode = (conn.secrets.otp_code ?? '').trim();
|
||||
const save = conn.saveSecret;
|
||||
return {
|
||||
connectionId: conn.connectionId,
|
||||
connectionCreatedAt: conn.createdAt,
|
||||
baseUrl: conn.baseUrl,
|
||||
username,
|
||||
password,
|
||||
otpCode: otpCode === '' ? undefined : otpCode,
|
||||
storedDeviceToken: conn.secrets[DEVICE_TOKEN_SECRET],
|
||||
saveDeviceToken: save ? (stored) => save(DEVICE_TOKEN_SECRET, stored) : undefined,
|
||||
allowInsecureTls: conn.allowInsecureTls,
|
||||
};
|
||||
}
|
||||
@@ -582,7 +593,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
* 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
|
||||
* it. Every caller already runs inside the try that turns a
|
||||
* `SynologyDriveError` into a `DocResult`.
|
||||
*/
|
||||
private requireInScope(scope: DocumentScopeRef, remoteId: string): string {
|
||||
|
||||
Reference in New Issue
Block a user