mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(sync): add OneDrive sync provider with PKCE auth (#7523)
* feat(sync): integrate onedrive with pkce and operation log sync * fix(sync): add oauth state validation and token refresh concurrency control * fix(sync): refactor onedrive oauth cleanup and reduce download API calls - Replace setInterval-based OAuth state pruning with on-demand _pruneExpiredOAuthStates() to avoid background timer overhead - Optimize downloadFile to read ETag from content response headers first, falling back to a metadata request only when needed (reduces API calls from 2 to 1 in the common case) - Narrow OneDrive class interface from SyncProviderServiceInterface to FileSyncProvider for stronger type guarantees - Accept optional devPath constructor parameter for non-production folder namespacing - Extract isOneDriveClientIdRequired to a helper function in sync-form.const.ts for readability - Change default OneDrive sync folder name to 'Super Productivity' Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 review feedback for OneDrive sync Critical fixes: - Detect invalid_grant in token refresh responses and clear credentials - Reset _tokenRefreshInFlightPromise in clearAuthCredentials to prevent stale refresh results from overwriting cleared state - Re-read config before persisting refreshed tokens to avoid race with concurrent clearAuthCredentials calls Important fixes: - Extract OAuth state management to shared oauth-state.util.ts to fix circular dependency between OneDrive provider and callback handler - Add 401 retry pattern (_is401Retry) matching Dropbox's approach: on 401, proactively refresh token and retry, only clear credentials if retry fails - Remove unused auth status translation keys (AUTH_SUCCESS, BTN_AUTHENTICATE, BTN_REAUTHENTICATE, REAUTH_SUCCESS, STATUS_CONFIGURED, STATUS_NOT_CONFIGURED) and form props - Remove unused requireAuth parameter from _cfgOrError Minor fixes: - Replace path-exposing log messages with structured logging - Remove unused _formatHttpErrorDetails helper - Return empty ETag fallback in getFileRev instead of throwing - Add folder cache invalidation comment for path changes - Add afterEach to restore global fetch in tests - Add PKCE defence-in-depth comment in auth code dialog Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): use eslint-disable-next-line for @microsoft.graph.conflictBehavior Replace spread+computed-property workaround with a plain property plus an explicit eslint-disable-next-line comment. Clearer intent: the naming violation is a Graph API requirement, not accidental. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): pre-save OneDrive form config in reauth() to prevent MissingCredentialsSPError Re-auth was failing silently on Linux because getAuthHelper() reads clientId from IndexedDB (privateCfg), but the form values were never written before calling configuredAuthForSyncProviderIfNecessary(). - Extract shared BTN_REAUTHENTICATE and REAUTH_SUCCESS translation keys - Add OneDrive to OAUTH_SYNC_PROVIDERS - Pre-save form config in reauth() matching the existing save() flow Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): adapt TooManyRequestsAPIError to new constructor signature after rebase * refactor(sync): migrate OneDrive provider to packages/sync-providers Move OneDrive core logic into the shared sync-providers package, matching the pattern used by Dropbox, WebDAV, and other providers. The app-side code is now a thin adapter with a createOneDriveProvider() factory function. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): re-throw MissingRefreshTokenAPIError from _requestOAuthToken catch block The catch block in _requestOAuthToken was swallowing the MissingRefreshTokenAPIError thrown after clearing credentials on invalid_grant, causing it to fall through to a generic HttpNotOkAPIError instead. Now re-throws MissingRefreshTokenAPIError explicitly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 second review critical and important items - Replace _is401Retry instance field with per-call parameter to prevent concurrent 401 races (Critical #1) - Fix _requestOAuthToken catch block to re-throw MissingRefreshTokenAPIError instead of swallowing it (Critical #2) - Fix _parseOAuthCallback defaulting unknown provider to 'unknown' instead of 'dropbox' to prevent misrouting (Critical #3 + Important #6) - Change conflictBehavior from 'replace' to 'fail' to prevent data loss if a file exists with the same name as the folder (Critical #4) - Await in-flight token refresh promise in clearAuthCredentials before clearing, to close the refresh-during-clear race (Important #5) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 review important and minor items - Extract duplicate OneDrive pre-auth save block into _persistOneDriveFormCfgBeforeAuth() method (Important #7) - Add GET probe in _ensureSyncFolderExists to skip per-segment creation when folder already exists (Important #9) - Redact sensitive params (code, code_verifier, refresh_token, access_token) from token endpoint and Graph error bodies (Important #11) - Remove targetPath from _mapAndThrow error messages to prevent logging user content (Important #12) - Add OAuth state validation in manual paste flow for OneDrive (Important #13) - Fix unknown provider routing in _parseOAuthCallback (Important #14) - Guard Electron IPC listener against post-destroy emissions (Important #15) - Remove dead authStatus Formly field from sync-form.const.ts (Minor) - Simplify Headers builder in _ensureSyncFolderExists (Minor) - Fix spec afterEach to restore original fetch instead of undefined (Minor) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): fix clearAuthCredentials race and add unit tests for OneDrive Fix a race condition in clearAuthCredentials where _tokenRefreshInFlightPromise was nulled AFTER awaiting, causing the invalid_grant handler's clearAuthCredentials to wait on itself. Now captures the promise and nulls the field before awaiting. Add 5 new unit tests covering critical code paths: - 401 token refresh and retry - 401 retry failure with credential clearing - 400 invalid_grant credential clearing - 412 precondition failed mapping - Concurrent token refresh deduplication Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 third review items - Simplify clearAuthCredentials: null _tokenRefreshInFlightPromise without awaiting to avoid deadlock when called from inside the refresh IIFE - Add superproductivity:// scheme validation in Electron OAuth listener - Fix invalid_grant test to assert MissingRefreshTokenAPIError - Fix folder cache test to match GET-probe optimization - Set setComplete default in beforeEach Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(sync): fix OAuthCallbackHandler spec to expect 'unknown' provider The _parseOAuthCallback now returns 'unknown' instead of 'dropbox' for URLs without an explicit provider path segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 fourth review items - Handle generic 401 from token endpoint (clear creds + MissingRefreshTokenAPIError) - Remove user-supplied paths from error constructors (rule 9 compliance) - Soften clearAuthCredentials comment to reflect actual TOCTOU guarantee - Tighten Electron scheme gate to match native path prefix - Add scheme info to Electron rejection log - Fix folder-cache test to assert GET probe count - Remove redundant per-test setComplete stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 fifth review items - Scope invalid_grant/401 credential clearing to refresh_token grant only (bad auth code exchange no longer wipes existing credentials) - Redact OAuth code/state from Electron protocol handler logs - Add @sp/sync-providers/onedrive TS path alias to tsconfig files - Default undefined sync config fields instead of overwriting with undefined - Handle OneDrive in provider-switch branch (preserve encryptKey) - Update sync-config test expectations to match default values Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 sixth review items - Use If-None-Match: * for null-rev uploads to prevent concurrent overwrite - Guard in-flight refresh against stale credentials (match refreshToken/clientId/tenantId) - Gate OneDrive provider option behind Electron/Native (web CORS unsupported) - Don't clear credentials on transient token endpoint errors (429/5xx) - Preserve null syncProvider instead of defaulting to WebDAV - Hydrate full OneDrive privateCfg on provider switch - Remove duplicate syncInterval/isManualSyncOnly Formly controls - Revert non-English locale changes (zh.json, zh-tw.json) - Redact URL fragments (#) in Electron protocol handler logs - listFiles rethrows non-404 errors instead of swallowing all - Update 401 test expectations for new rethrow behavior Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): guard credential clearing against stale concurrent refresh Prevent a stale in-flight refresh from wiping newer credentials after a concurrent re-auth. The refresh IIFE now throws a plain Error (not MissingRefreshTokenAPIError) when it detects config drift, and all clearAuthCredentials() call sites now verify the stored config still matches before clearing. Also fix listFiles() to handle 404 from _requestJson (which throws HttpNotOkAPIError, not RemoteFileNotFoundAPIError). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 eighth review items - Replace If-None-Match:* with @microsoft.graph.conflictBehavior=fail query param for upload create-only semantics - Add identity change detection in _persistOneDriveFormCfgBeforeAuth to clear tokens when useCustomApp/clientId/tenantId change - Extract _clearIfConfigMatches helper to guard credential clearing against stale concurrent refresh - Restrict folder probe fallthrough to 404 only, propagate other errors - Restore locale files from upstream master - Centralize IS_ONEDRIVE_SUPPORTED in onedrive-auth-mode.const.ts, use in factory and form config Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 ninth review items - Paginate listFiles via @odata.nextLink to avoid silent data loss - Fix logger misuse (log -> normal with proper string message) - Replace as any with OneDrivePrivateCfg in dialog-sync-cfg - Throw NoRevAPIError when uploadFile response lacks eTag - Omit undefined optional booleans from global config to prevent overwrite - Move OneDrive dynamic import inside IS_ONEDRIVE_SUPPORTED gate - Require state param for OneDrive full-URL paste (CSRF) - Add id_token to sensitive keys for body redaction Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): add OneDrive type alias to electron tsconfig paths The @sp/sync-providers/onedrive path alias was missing from electron/tsconfig.electron.json, causing the Electron build to fail with "Cannot find module '@sp/sync-providers/onedrive'". The alias was already present in tsconfig.base.json but electron uses its own paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f1146613b3
commit
9910d30fca
36 changed files with 1873 additions and 59 deletions
|
|
@ -11,7 +11,9 @@ export const PROTOCOL_PREFIX = `${PROTOCOL_NAME}://`;
|
|||
let pendingUrls: string[] = [];
|
||||
|
||||
export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): void => {
|
||||
log('Processing protocol URL:', url);
|
||||
// Redact query params before logging — OAuth code/state are credentials
|
||||
const redactedUrl = url.split('?')[0].split('#')[0];
|
||||
log('Processing protocol URL:', redactedUrl);
|
||||
|
||||
// Only process after window is ready
|
||||
if (!mainWin || !mainWin.webContents) {
|
||||
|
|
@ -34,6 +36,13 @@ export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null):
|
|||
log('Protocol path parts:', pathParts);
|
||||
|
||||
switch (action) {
|
||||
case 'oauth-callback':
|
||||
log('Received OAuth callback URL via app protocol');
|
||||
if (mainWin && mainWin.webContents) {
|
||||
mainWin.webContents.send(IPC.OAUTH_CALLBACK, { url });
|
||||
showOrFocus(mainWin);
|
||||
}
|
||||
break;
|
||||
case 'create-task':
|
||||
if (pathParts.length > 0) {
|
||||
const taskTitle = decodeURIComponent(pathParts[0]);
|
||||
|
|
@ -76,8 +85,16 @@ export const initializeProtocolHandling = (
|
|||
// Register protocol handler
|
||||
if (IS_DEV && process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
const launchArgsForProtocol = [path.resolve(process.argv[1])];
|
||||
const userDataDirArg = process.argv.find((arg) =>
|
||||
arg.startsWith('--user-data-dir='),
|
||||
);
|
||||
if (userDataDirArg) {
|
||||
launchArgsForProtocol.push(userDataDirArg);
|
||||
}
|
||||
|
||||
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
...launchArgsForProtocol,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -111,7 +128,7 @@ export const initializeProtocolHandling = (
|
|||
// Handle protocol URL passed as command line argument for testing
|
||||
process.argv.forEach((val) => {
|
||||
if (val && val.startsWith(PROTOCOL_PREFIX)) {
|
||||
log('Protocol URL from command line:', val);
|
||||
log('Protocol URL from command line:', val.split('?')[0].split('#')[0]);
|
||||
// Process after app is ready
|
||||
appInstance.whenReady().then(() => {
|
||||
processProtocolUrl(val, getMainWindow());
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ export enum IPC {
|
|||
PLUGIN_OAUTH_START = 'PLUGIN_OAUTH_START',
|
||||
PLUGIN_OAUTH_CB = 'PLUGIN_OAUTH_CB',
|
||||
|
||||
// Generic OAuth callback for app protocol redirects (e.g. OneDrive)
|
||||
OAUTH_CALLBACK = 'OAUTH_CALLBACK',
|
||||
|
||||
LOCAL_REST_API_REQUEST = 'LOCAL_REST_API_REQUEST',
|
||||
LOCAL_REST_API_RESPONSE = 'LOCAL_REST_API_RESPONSE',
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@
|
|||
"@sp/sync-providers/provider-types": [
|
||||
"packages/sync-providers/dist/provider-types.d.ts"
|
||||
],
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"]
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"],
|
||||
"@sp/sync-providers/onedrive": ["packages/sync-providers/dist/onedrive.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"],
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@
|
|||
"default": "./dist/super-sync.js"
|
||||
}
|
||||
},
|
||||
"./onedrive": {
|
||||
"import": {
|
||||
"types": "./dist/onedrive.d.mts",
|
||||
"default": "./dist/onedrive.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/onedrive.d.ts",
|
||||
"default": "./dist/onedrive.js"
|
||||
}
|
||||
},
|
||||
"./http": {
|
||||
"import": {
|
||||
"types": "./dist/http.d.mts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
export interface OneDrivePrivateCfg {
|
||||
encryptKey?: string;
|
||||
useCustomApp?: boolean;
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
syncFolderPath?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
tokenExpiresAt?: number;
|
||||
}
|
||||
|
||||
export interface OneDriveItem {
|
||||
id: string;
|
||||
name: string;
|
||||
eTag?: string;
|
||||
folder?: Record<string, unknown>;
|
||||
file?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OneDriveListResponse {
|
||||
value?: OneDriveItem[];
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'@odata.nextLink'?: string;
|
||||
}
|
||||
|
||||
export interface OneDriveTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
770
packages/sync-providers/src/file-based/onedrive/onedrive.ts
Normal file
770
packages/sync-providers/src/file-based/onedrive/onedrive.ts
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
import type { SyncLogger } from '@sp/sync-core';
|
||||
import type { ProviderPlatformInfo } from '../../platform/provider-platform-info';
|
||||
import type { WebFetchFactory } from '../../platform/web-fetch-factory';
|
||||
import type { SyncCredentialStorePort } from '../../credential-store-port';
|
||||
import type { FileSyncProvider, SyncProviderAuthHelper } from '../../provider-types';
|
||||
import {
|
||||
AuthFailSPError,
|
||||
HttpNotOkAPIError,
|
||||
MissingRefreshTokenAPIError,
|
||||
NoRevAPIError,
|
||||
RemoteFileNotFoundAPIError,
|
||||
TooManyRequestsAPIError,
|
||||
UploadRevToMatchMismatchAPIError,
|
||||
} from '../../errors';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '../../pkce';
|
||||
import type {
|
||||
OneDrivePrivateCfg,
|
||||
OneDriveListResponse,
|
||||
OneDriveTokenResponse,
|
||||
} from './onedrive.model';
|
||||
|
||||
export const PROVIDER_ID_ONEDRIVE = 'OneDrive' as const;
|
||||
|
||||
export interface OneDriveDeps {
|
||||
logger: SyncLogger;
|
||||
platformInfo: ProviderPlatformInfo;
|
||||
webFetch: WebFetchFactory;
|
||||
credentialStore: SyncCredentialStorePort<
|
||||
typeof PROVIDER_ID_ONEDRIVE,
|
||||
OneDrivePrivateCfg
|
||||
>;
|
||||
officialClientId: string | null;
|
||||
hasOfficialClientId: boolean;
|
||||
addOAuthState: (provider: string, state: string) => void;
|
||||
isElectron: boolean;
|
||||
}
|
||||
|
||||
const ONEDRIVE_PROTOCOL = {
|
||||
graphApiBaseUrl: 'https://graph.microsoft.com/v1.0',
|
||||
scope: 'offline_access Files.ReadWrite.AppFolder',
|
||||
redirectUri: 'https://login.microsoftonline.com/common/oauth2/nativeclient',
|
||||
electronRedirectUri: 'superproductivity://oauth-callback/onedrive',
|
||||
tokenRefreshSkewMs: 60_000,
|
||||
} as const;
|
||||
|
||||
const ONEDRIVE_DEFAULTS = {
|
||||
tenantId: 'common',
|
||||
syncFolderPath: 'Super Productivity',
|
||||
} as const;
|
||||
|
||||
export class OneDrive implements FileSyncProvider<
|
||||
typeof PROVIDER_ID_ONEDRIVE,
|
||||
OneDrivePrivateCfg
|
||||
> {
|
||||
readonly id = PROVIDER_ID_ONEDRIVE;
|
||||
readonly isUploadForcePossible = true;
|
||||
readonly maxConcurrentRequests = 4;
|
||||
|
||||
readonly privateCfg: SyncCredentialStorePort<
|
||||
typeof PROVIDER_ID_ONEDRIVE,
|
||||
OneDrivePrivateCfg
|
||||
>;
|
||||
|
||||
private _ensuredFolderPath: string | null = null;
|
||||
private _folderEnsureInFlightPath: string | null = null;
|
||||
private _folderEnsureInFlightPromise: Promise<void> | null = null;
|
||||
private _tokenRefreshInFlightPromise: Promise<string> | null = null;
|
||||
private readonly _devPath?: string;
|
||||
private readonly _deps: OneDriveDeps;
|
||||
|
||||
constructor(cfg: { devPath?: string }, deps: OneDriveDeps) {
|
||||
this._devPath = cfg.devPath;
|
||||
this._deps = deps;
|
||||
this.privateCfg = deps.credentialStore;
|
||||
}
|
||||
|
||||
async isReady(): Promise<boolean> {
|
||||
const cfg = await this.privateCfg.load();
|
||||
const resolvedClientId = this._resolveClientId(cfg || {});
|
||||
return !!(resolvedClientId && cfg?.accessToken && cfg?.refreshToken);
|
||||
}
|
||||
|
||||
async setPrivateCfg(privateCfg: OneDrivePrivateCfg): Promise<void> {
|
||||
await this.privateCfg.setComplete(privateCfg);
|
||||
}
|
||||
|
||||
async clearAuthCredentials(): Promise<void> {
|
||||
const cfg = await this.privateCfg.load();
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
// Null without awaiting: clearAuthCredentials can be called from inside
|
||||
// the refresh IIFE (invalid_grant path), so awaiting would deadlock.
|
||||
// NOTE: an external concurrent call (e.g. user disconnect) has a narrow
|
||||
// TOCTOU window — the in-flight IIFE may still overwrite cleared creds
|
||||
// if its refresh succeeds. Risk is low in practice and would self-correct
|
||||
// on the next sync cycle.
|
||||
this._tokenRefreshInFlightPromise = null;
|
||||
await this.privateCfg.setComplete({
|
||||
...cfg,
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: 0,
|
||||
});
|
||||
}
|
||||
|
||||
private async _clearIfConfigMatches(cfg: Partial<OneDrivePrivateCfg>): Promise<void> {
|
||||
const currentCfg = await this.privateCfg.load();
|
||||
if (
|
||||
currentCfg?.refreshToken === cfg.refreshToken &&
|
||||
currentCfg?.clientId === cfg.clientId &&
|
||||
currentCfg?.tenantId === cfg.tenantId
|
||||
) {
|
||||
await this.clearAuthCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthHelper(): Promise<SyncProviderAuthHelper> {
|
||||
const cfg = await this._cfgOrError();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
const tenant = cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId;
|
||||
const redirectUri = this._getRedirectUri();
|
||||
|
||||
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
this._deps.addOAuthState('onedrive', state);
|
||||
|
||||
const authUrl =
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/authorize` +
|
||||
`?client_id=${encodeURIComponent(cfg.clientId)}` +
|
||||
'&response_type=code' +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&scope=${encodeURIComponent(ONEDRIVE_PROTOCOL.scope)}` +
|
||||
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
|
||||
'&code_challenge_method=S256' +
|
||||
`&state=${encodeURIComponent(state)}`;
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
codeVerifier,
|
||||
verifyCodeChallenge: async <T>(authCode: string) => {
|
||||
return (await this._exchangeAuthCode(authCode, codeVerifier, cfg)) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getFileRev(targetPath: string, _localRev: string): Promise<{ rev: string }> {
|
||||
const cfg = await this._cfgOrError();
|
||||
try {
|
||||
const item = await this._requestJson<{ eTag?: string }>(
|
||||
this._getDriveItemPath(targetPath, cfg),
|
||||
);
|
||||
return { rev: item.eTag || '' };
|
||||
} catch (e) {
|
||||
this._mapAndThrow(e);
|
||||
}
|
||||
throw new RemoteFileNotFoundAPIError();
|
||||
}
|
||||
|
||||
async downloadFile(targetPath: string): Promise<{ rev: string; dataStr: string }> {
|
||||
const cfg = await this._cfgOrError();
|
||||
try {
|
||||
const driveItemPath = this._getDriveItemPath(targetPath, cfg);
|
||||
const response = await this._request({
|
||||
method: 'GET',
|
||||
path: `${driveItemPath}/content`,
|
||||
});
|
||||
const dataStr = await response.text();
|
||||
const revFromContentHeaders = this._getResponseETag(response);
|
||||
if (revFromContentHeaders) {
|
||||
return {
|
||||
rev: revFromContentHeaders,
|
||||
dataStr,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = await this._requestJson<{ eTag?: string }>(driveItemPath);
|
||||
return {
|
||||
rev: metadata.eTag || '',
|
||||
dataStr,
|
||||
};
|
||||
} catch (e) {
|
||||
this._mapAndThrow(e);
|
||||
}
|
||||
throw new RemoteFileNotFoundAPIError();
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
targetPath: string,
|
||||
dataStr: string,
|
||||
revToMatch: string | null,
|
||||
isForceOverwrite = false,
|
||||
): Promise<{ rev: string }> {
|
||||
const cfg = await this._cfgOrError();
|
||||
try {
|
||||
await this._ensureSyncFolderExistsCached(cfg);
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'text/plain');
|
||||
let uploadPath = `${this._getDriveItemPath(targetPath, cfg)}/content`;
|
||||
if (!isForceOverwrite && revToMatch) {
|
||||
headers.set('If-Match', revToMatch);
|
||||
} else if (!isForceOverwrite && !revToMatch) {
|
||||
// Microsoft documents conflictBehavior=fail as the way to prevent
|
||||
// overwriting existing items on the small-upload endpoint.
|
||||
// https://learn.microsoft.com/en-gb/onedrive/developer/rest-api/api/driveitem_put_content
|
||||
uploadPath += '?@microsoft.graph.conflictBehavior=fail';
|
||||
}
|
||||
|
||||
const response = await this._request({
|
||||
method: 'PUT',
|
||||
path: uploadPath,
|
||||
headers,
|
||||
body: dataStr,
|
||||
});
|
||||
const result = (await response.json()) as { eTag?: string };
|
||||
if (!result.eTag) {
|
||||
throw new NoRevAPIError('OneDrive upload missing eTag');
|
||||
}
|
||||
return { rev: result.eTag };
|
||||
} catch (e) {
|
||||
this._mapAndThrow(e);
|
||||
}
|
||||
throw new UploadRevToMatchMismatchAPIError();
|
||||
}
|
||||
|
||||
async removeFile(targetPath: string): Promise<void> {
|
||||
const cfg = await this._cfgOrError();
|
||||
try {
|
||||
await this._request({
|
||||
method: 'DELETE',
|
||||
path: this._getDriveItemPath(targetPath, cfg),
|
||||
});
|
||||
} catch (e) {
|
||||
this._mapAndThrow(e);
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(dirPath: string): Promise<string[]> {
|
||||
const cfg = await this._cfgOrError();
|
||||
const names: string[] = [];
|
||||
// Graph /children paginates by ~200 items; follow @odata.nextLink
|
||||
// to collect the full listing. Missing entries would cause replay drift
|
||||
// when the op-log folder grows beyond a single page.
|
||||
let nextUrl: string | undefined = `${this._getDriveItemPath(dirPath, cfg)}/children`;
|
||||
try {
|
||||
while (nextUrl) {
|
||||
const result: OneDriveListResponse =
|
||||
await this._requestJson<OneDriveListResponse>(nextUrl);
|
||||
for (const item of result.value || []) {
|
||||
if (item.file && item.name) {
|
||||
names.push(item.name);
|
||||
}
|
||||
}
|
||||
// @odata.nextLink is a full URL — strip the Graph base prefix
|
||||
// so _request can re-add it without doubling.
|
||||
const raw: string | undefined = result['@odata.nextLink'];
|
||||
nextUrl = raw?.startsWith(ONEDRIVE_PROTOCOL.graphApiBaseUrl)
|
||||
? raw.slice(ONEDRIVE_PROTOCOL.graphApiBaseUrl.length)
|
||||
: raw;
|
||||
}
|
||||
return names;
|
||||
} catch (e) {
|
||||
if (e instanceof RemoteFileNotFoundAPIError) {
|
||||
return [];
|
||||
}
|
||||
if (e instanceof HttpNotOkAPIError && e.response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- private helpers ----
|
||||
|
||||
private _getDriveItemPath(targetPath: string, cfg: OneDrivePrivateCfg): string {
|
||||
const relativeTargetPath = this._normalizeRelativePath(targetPath);
|
||||
const cfgPath = this._getSyncFolderPath(cfg);
|
||||
const fullPath = this._joinPathSegments(cfgPath, relativeTargetPath);
|
||||
const encodedPath = this._encodePath(fullPath);
|
||||
return `/me/drive/special/approot:/${encodedPath}:`;
|
||||
}
|
||||
|
||||
private _getSyncFolderPath(cfg: OneDrivePrivateCfg): string {
|
||||
return this._joinPathSegments(this._devPath || '', cfg?.syncFolderPath || '');
|
||||
}
|
||||
|
||||
private _normalizeRelativePath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
private _joinPathSegments(...parts: string[]): string {
|
||||
return parts
|
||||
.map((part) => this._normalizeRelativePath(part))
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
private _encodePath(path: string): string {
|
||||
return this._normalizeRelativePath(path)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
private _getResponseETag(response: Response): string {
|
||||
return response.headers.get('etag') || response.headers.get('ETag') || '';
|
||||
}
|
||||
|
||||
private async _ensureSyncFolderExistsCached(cfg: OneDrivePrivateCfg): Promise<void> {
|
||||
const folderPath = this._getSyncFolderPath(cfg);
|
||||
if (!folderPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._ensuredFolderPath === folderPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this._folderEnsureInFlightPromise &&
|
||||
this._folderEnsureInFlightPath === folderPath
|
||||
) {
|
||||
await this._folderEnsureInFlightPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this._folderEnsureInFlightPath = folderPath;
|
||||
this._folderEnsureInFlightPromise = this._ensureSyncFolderExists(cfg)
|
||||
.then(() => {
|
||||
this._ensuredFolderPath = folderPath;
|
||||
})
|
||||
.finally(() => {
|
||||
this._folderEnsureInFlightPath = null;
|
||||
this._folderEnsureInFlightPromise = null;
|
||||
});
|
||||
|
||||
await this._folderEnsureInFlightPromise;
|
||||
}
|
||||
|
||||
private async _ensureSyncFolderExists(cfg: OneDrivePrivateCfg): Promise<void> {
|
||||
const folderPath = this._getSyncFolderPath(cfg);
|
||||
if (!folderPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the full path first — if it exists, skip per-segment creation entirely.
|
||||
const fullPath = `/me/drive/special/approot:/${this._encodePath(folderPath)}`;
|
||||
try {
|
||||
await this._request({ method: 'GET', path: fullPath });
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof HttpNotOkAPIError && e.response.status === 404) {
|
||||
// Folder not found — fall through to create the folder chain.
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const segments = folderPath.split('/').filter(Boolean);
|
||||
let currentPath = '';
|
||||
|
||||
for (const segment of segments) {
|
||||
const parentPath = currentPath;
|
||||
currentPath = parentPath ? `${parentPath}/${segment}` : segment;
|
||||
|
||||
const createPath = parentPath
|
||||
? `/me/drive/special/approot:/${this._encodePath(parentPath)}:/children`
|
||||
: '/me/drive/special/approot/children';
|
||||
|
||||
try {
|
||||
await this._request({
|
||||
method: 'POST',
|
||||
path: createPath,
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }), // eslint-disable-line @typescript-eslint/naming-convention
|
||||
body: JSON.stringify({
|
||||
name: segment,
|
||||
folder: {},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'@microsoft.graph.conflictBehavior': 'fail',
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as HttpNotOkAPIError;
|
||||
if (err?.response?.status === 409) {
|
||||
const parsed = this._parseGraphError(err.body);
|
||||
if (parsed.code === 'nameAlreadyExists') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _cfgOrError(): Promise<OneDrivePrivateCfg> {
|
||||
const cfg = (await this.privateCfg.load()) || ({} as Partial<OneDrivePrivateCfg>);
|
||||
const resolvedClientId = this._resolveClientId(cfg);
|
||||
if (!resolvedClientId) {
|
||||
throw new Error('OneDrive clientId is required');
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
useCustomApp:
|
||||
cfg.useCustomApp !== undefined
|
||||
? cfg.useCustomApp
|
||||
: !this._deps.hasOfficialClientId,
|
||||
clientId: resolvedClientId,
|
||||
tenantId: cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId,
|
||||
syncFolderPath: cfg.syncFolderPath || ONEDRIVE_DEFAULTS.syncFolderPath,
|
||||
};
|
||||
}
|
||||
|
||||
private _resolveClientId(cfg: Partial<OneDrivePrivateCfg>): string | null {
|
||||
if (cfg.useCustomApp === true) {
|
||||
return cfg.clientId || null;
|
||||
}
|
||||
|
||||
if (cfg.useCustomApp === false) {
|
||||
return this._deps.officialClientId || cfg.clientId || null;
|
||||
}
|
||||
|
||||
return cfg.clientId || this._deps.officialClientId || null;
|
||||
}
|
||||
|
||||
private async _exchangeAuthCode(
|
||||
authCode: string,
|
||||
codeVerifier: string,
|
||||
cfg: OneDrivePrivateCfg,
|
||||
): Promise<OneDrivePrivateCfg> {
|
||||
const tokenData = await this._requestOAuthToken(cfg, {
|
||||
grantType: 'authorization_code',
|
||||
authCode,
|
||||
codeVerifier,
|
||||
redirectUri: this._getRedirectUri(),
|
||||
});
|
||||
const expiresInMs = tokenData.expires_in * 1000;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token || cfg.refreshToken,
|
||||
tokenExpiresAt: Date.now() + expiresInMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async _refreshAccessTokenIfNeeded(cfg: OneDrivePrivateCfg): Promise<string> {
|
||||
const expiresAt = cfg.tokenExpiresAt || 0;
|
||||
if (
|
||||
cfg.accessToken &&
|
||||
Date.now() < expiresAt - ONEDRIVE_PROTOCOL.tokenRefreshSkewMs
|
||||
) {
|
||||
return cfg.accessToken;
|
||||
}
|
||||
if (!cfg.refreshToken) {
|
||||
throw new MissingRefreshTokenAPIError();
|
||||
}
|
||||
|
||||
if (this._tokenRefreshInFlightPromise) {
|
||||
return this._tokenRefreshInFlightPromise;
|
||||
}
|
||||
|
||||
this._tokenRefreshInFlightPromise = (async () => {
|
||||
try {
|
||||
const tokenData = await this._requestOAuthToken(cfg, {
|
||||
grantType: 'refresh_token',
|
||||
refreshToken: cfg.refreshToken,
|
||||
});
|
||||
const expiresInMs = tokenData.expires_in * 1000;
|
||||
|
||||
const currentCfg = await this.privateCfg.load();
|
||||
if (
|
||||
!currentCfg?.refreshToken ||
|
||||
currentCfg.refreshToken !== cfg.refreshToken ||
|
||||
currentCfg.clientId !== cfg.clientId ||
|
||||
currentCfg.tenantId !== cfg.tenantId
|
||||
) {
|
||||
this._deps.logger.warn(
|
||||
'[OneDrive] Credentials changed during token refresh, discarding refresh result',
|
||||
);
|
||||
// Throw a plain Error — MissingRefreshTokenAPIError would trigger
|
||||
// clearAuthCredentials() in the 401-retry path, wiping the *newer* creds.
|
||||
throw new Error('OneDrive: stale refresh discarded');
|
||||
}
|
||||
|
||||
const updatedCfg: OneDrivePrivateCfg = {
|
||||
...currentCfg,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token || currentCfg.refreshToken,
|
||||
tokenExpiresAt: Date.now() + expiresInMs,
|
||||
};
|
||||
|
||||
await this.privateCfg.setComplete(updatedCfg);
|
||||
return updatedCfg.accessToken || '';
|
||||
} finally {
|
||||
this._tokenRefreshInFlightPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this._tokenRefreshInFlightPromise;
|
||||
}
|
||||
|
||||
private _buildOAuthTokenUrl(tenantId: string): string {
|
||||
return `https://login.microsoftonline.com/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
|
||||
}
|
||||
|
||||
private _buildOAuthTokenRequestBody(
|
||||
cfg: OneDrivePrivateCfg,
|
||||
req: OAuthTokenRequest,
|
||||
): URLSearchParams {
|
||||
const bodyBase = {
|
||||
client_id: cfg.clientId,
|
||||
scope: ONEDRIVE_PROTOCOL.scope,
|
||||
};
|
||||
|
||||
if (req.grantType === 'authorization_code') {
|
||||
return new URLSearchParams({
|
||||
...bodyBase,
|
||||
grant_type: 'authorization_code',
|
||||
code: req.authCode || '',
|
||||
redirect_uri: req.redirectUri || ONEDRIVE_PROTOCOL.redirectUri,
|
||||
code_verifier: req.codeVerifier || '',
|
||||
});
|
||||
}
|
||||
|
||||
return new URLSearchParams({
|
||||
...bodyBase,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: req.refreshToken || '',
|
||||
});
|
||||
}
|
||||
|
||||
private async _requestOAuthToken(
|
||||
cfg: OneDrivePrivateCfg,
|
||||
req: OAuthTokenRequest,
|
||||
): Promise<OneDriveTokenResponse> {
|
||||
const tenant = cfg.tenantId || ONEDRIVE_DEFAULTS.tenantId;
|
||||
const response = await this._deps.webFetch()(this._buildOAuthTokenUrl(tenant), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // eslint-disable-line @typescript-eslint/naming-convention
|
||||
body: this._buildOAuthTokenRequestBody(cfg, req),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
if (response.status === 400) {
|
||||
let parsed: { error?: string } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(body) as { error?: string };
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (parsed?.error === 'invalid_grant') {
|
||||
if (req.grantType === 'refresh_token') {
|
||||
this._deps.logger.warn(
|
||||
'[OneDrive] Refresh token revoked (invalid_grant), clearing credentials',
|
||||
);
|
||||
await this._clearIfConfigMatches({
|
||||
refreshToken: req.refreshToken,
|
||||
clientId: cfg.clientId,
|
||||
tenantId: cfg.tenantId,
|
||||
});
|
||||
throw new MissingRefreshTokenAPIError();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && req.grantType === 'refresh_token') {
|
||||
this._deps.logger.warn(
|
||||
'[OneDrive] Token endpoint returned 401, clearing credentials',
|
||||
);
|
||||
await this._clearIfConfigMatches({
|
||||
refreshToken: req.refreshToken,
|
||||
clientId: cfg.clientId,
|
||||
tenantId: cfg.tenantId,
|
||||
});
|
||||
throw new MissingRefreshTokenAPIError();
|
||||
}
|
||||
const redactedBody = this._redactTokenBody(body);
|
||||
throw new HttpNotOkAPIError(response, redactedBody);
|
||||
}
|
||||
|
||||
return (await response.json()) as OneDriveTokenResponse;
|
||||
}
|
||||
|
||||
private _getRedirectUri(): string {
|
||||
if (!this._deps.isElectron) {
|
||||
return ONEDRIVE_PROTOCOL.redirectUri;
|
||||
}
|
||||
return ONEDRIVE_PROTOCOL.electronRedirectUri;
|
||||
}
|
||||
|
||||
private async _request(options: ApiRequestOptions, isRetry = false): Promise<Response> {
|
||||
const cfg = await this._cfgOrError();
|
||||
const accessToken = await this._refreshAccessTokenIfNeeded(cfg);
|
||||
const isUploadRequest = options.method === 'PUT' && options.path.endsWith('/content');
|
||||
|
||||
this._deps.logger.normal('OneDrive.request', {
|
||||
method: options.method,
|
||||
isUpload: isUploadRequest,
|
||||
});
|
||||
|
||||
const requestHeaders = new Headers(options.headers);
|
||||
requestHeaders.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
const response = await this._deps.webFetch()(
|
||||
`${ONEDRIVE_PROTOCOL.graphApiBaseUrl}${options.path}`,
|
||||
{
|
||||
method: options.method,
|
||||
headers: requestHeaders,
|
||||
body: options.body,
|
||||
},
|
||||
);
|
||||
|
||||
const responseBody = response.ok ? '' : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const parsed = this._parseGraphError(responseBody);
|
||||
this._deps.logger.normal('OneDrive request error', {
|
||||
status: response.status,
|
||||
code: parsed.code || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
if (!isRetry) {
|
||||
try {
|
||||
await this._refreshAccessTokenIfNeeded({
|
||||
...cfg,
|
||||
tokenExpiresAt: 0,
|
||||
});
|
||||
return this._request(options, true);
|
||||
} catch (refreshErr) {
|
||||
// Only clear credentials for permanent auth failures, not transient ones.
|
||||
// Stale-refresh (plain Error) is ignored — newer creds may exist.
|
||||
if (
|
||||
refreshErr instanceof MissingRefreshTokenAPIError ||
|
||||
refreshErr instanceof AuthFailSPError
|
||||
) {
|
||||
await this._clearIfConfigMatches(cfg);
|
||||
}
|
||||
throw refreshErr;
|
||||
}
|
||||
}
|
||||
await this._clearIfConfigMatches(cfg);
|
||||
throw new AuthFailSPError('OneDrive 401');
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const parsed = this._parseGraphError(responseBody);
|
||||
if (parsed.code === 'InvalidAuthenticationToken') {
|
||||
await this._clearIfConfigMatches(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpNotOkAPIError(response, this._redactResponseBody(responseBody));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async _requestJson<T>(path: string): Promise<T> {
|
||||
const response = await this._request({ method: 'GET', path });
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private _parseGraphError(body?: string): ParsedGraphError {
|
||||
if (!body) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
return {
|
||||
code: parsed.error?.code,
|
||||
message: parsed.error?.message,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private _mapAndThrow(error: unknown): never {
|
||||
if (error instanceof RemoteFileNotFoundAPIError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof AuthFailSPError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof HttpNotOkAPIError) {
|
||||
const status = error.response.status;
|
||||
if (status === 404) {
|
||||
this._ensuredFolderPath = null;
|
||||
throw new RemoteFileNotFoundAPIError();
|
||||
}
|
||||
if (status === 429) {
|
||||
throw new TooManyRequestsAPIError({
|
||||
status,
|
||||
});
|
||||
}
|
||||
if (status === 409 || status === 412) {
|
||||
throw new UploadRevToMatchMismatchAPIError();
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
throw new AuthFailSPError(`OneDrive auth failed (status=${status})`);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
private _redactTokenBody(body: string): string {
|
||||
return this._redactSensitiveFields(body);
|
||||
}
|
||||
|
||||
private _redactResponseBody(body: string): string {
|
||||
return this._redactSensitiveFields(body);
|
||||
}
|
||||
|
||||
private _redactSensitiveFields(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
const sensitiveKeys = [
|
||||
'code',
|
||||
'code_verifier',
|
||||
'refresh_token',
|
||||
'access_token',
|
||||
'id_token',
|
||||
];
|
||||
let changed = false;
|
||||
for (const key of sensitiveKeys) {
|
||||
if (key in parsed) {
|
||||
parsed[key] = '[REDACTED]';
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? JSON.stringify(parsed) : body;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiRequestOptions {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
headers?: HeadersInit;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
interface ParsedGraphError {
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenRequest {
|
||||
grantType: 'authorization_code' | 'refresh_token';
|
||||
authCode?: string;
|
||||
codeVerifier?: string;
|
||||
refreshToken?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
11
packages/sync-providers/src/onedrive.ts
Normal file
11
packages/sync-providers/src/onedrive.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export {
|
||||
OneDrive,
|
||||
PROVIDER_ID_ONEDRIVE,
|
||||
type OneDriveDeps,
|
||||
} from './file-based/onedrive/onedrive';
|
||||
export type {
|
||||
OneDrivePrivateCfg,
|
||||
OneDriveItem,
|
||||
OneDriveListResponse,
|
||||
OneDriveTokenResponse,
|
||||
} from './file-based/onedrive/onedrive.model';
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
'src/webdav.ts',
|
||||
'src/local-file.ts',
|
||||
'src/super-sync.ts',
|
||||
'src/onedrive.ts',
|
||||
'src/http.ts',
|
||||
'src/errors.ts',
|
||||
'src/credential-store.ts',
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<button
|
||||
class="sync-btn"
|
||||
matTooltip="{{ T.MH.TRIGGER_SYNC | translate }}"
|
||||
(click)="syncIsEnabledAndReady() ? sync() : setupSync()"
|
||||
(click)="onSyncButtonClick()"
|
||||
(longPress)="setupSync()"
|
||||
(contextmenu)="$event.preventDefault(); setupSync()"
|
||||
mat-icon-button
|
||||
|
|
|
|||
|
|
@ -297,6 +297,15 @@ export class MainHeaderComponent implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
onSyncButtonClick(): void {
|
||||
const ready = !!this.syncIsEnabledAndReady();
|
||||
if (ready) {
|
||||
this.sync();
|
||||
} else {
|
||||
this.setupSync();
|
||||
}
|
||||
}
|
||||
|
||||
private dialogSyncCfgRef: MatDialogRef<unknown> | null = null;
|
||||
|
||||
async setupSync(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
} from '../../imex/sync/onedrive-auth-mode.const';
|
||||
|
||||
import { TaskReminderOptionId } from '../tasks/task.model';
|
||||
import { GlobalConfigState } from './global-config.model';
|
||||
|
|
@ -246,5 +250,12 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
password: null,
|
||||
syncFolderPath: 'super-productivity',
|
||||
},
|
||||
|
||||
oneDrive: {
|
||||
useCustomApp: !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
clientId: OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
tenantId: 'common',
|
||||
syncFolderPath: 'Super Productivity',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import {
|
|||
openEncryptionPasswordChangeDialog,
|
||||
openEncryptionPasswordChangeDialogForFileBased,
|
||||
} from '../../../imex/sync/encryption-password-dialog-opener.service';
|
||||
import {
|
||||
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
IS_ONEDRIVE_SUPPORTED,
|
||||
} from '../../../imex/sync/onedrive-auth-mode.const';
|
||||
|
||||
/**
|
||||
* Creates form fields for WebDAV-based sync providers.
|
||||
|
|
@ -119,6 +123,17 @@ const createWebdavFormFields = (options: {
|
|||
];
|
||||
};
|
||||
|
||||
const isOneDriveClientIdRequired = (field: FormlyFieldConfig): boolean => {
|
||||
if (field?.parent?.parent?.model?.syncProvider !== SyncProviderId.OneDrive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no official app client ID is available, users must provide their own.
|
||||
// If an official app client ID is available, clientId is only required when
|
||||
// users explicitly switch to custom app mode.
|
||||
return !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID || !!field?.parent?.model?.useCustomApp;
|
||||
};
|
||||
|
||||
export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
||||
title: T.F.SYNC.FORM.TITLE,
|
||||
key: 'sync',
|
||||
|
|
@ -141,6 +156,9 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
options: [
|
||||
{ label: 'SuperSync (Beta)', value: SyncProviderId.SuperSync },
|
||||
{ label: SyncProviderId.Dropbox, value: SyncProviderId.Dropbox },
|
||||
...(IS_ONEDRIVE_SUPPORTED
|
||||
? [{ label: 'Microsoft 365 (OneDrive)', value: SyncProviderId.OneDrive }]
|
||||
: []),
|
||||
{ label: 'Nextcloud', value: SyncProviderId.Nextcloud },
|
||||
{
|
||||
label: 'WebDAV (not recommended / no support)',
|
||||
|
|
@ -339,6 +357,82 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
},
|
||||
],
|
||||
},
|
||||
// OneDrive provider form fields
|
||||
{
|
||||
hideExpression: (m, v, field) =>
|
||||
field?.parent?.model.syncProvider !== SyncProviderId.OneDrive,
|
||||
resetOnHide: false,
|
||||
key: 'oneDrive',
|
||||
fieldGroup: [
|
||||
...(HAS_OFFICIAL_ONEDRIVE_CLIENT_ID
|
||||
? [
|
||||
{
|
||||
type: 'tpl',
|
||||
hideExpression: (m: any) => !!m?.useCustomApp,
|
||||
templateOptions: {
|
||||
tag: 'p',
|
||||
text: T.F.SYNC.FORM.ONEDRIVE.OFFICIAL_MODE_INFO,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'useCustomApp',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
templateOptions: {
|
||||
label: T.F.SYNC.FORM.ONEDRIVE.L_USE_CUSTOM_APP,
|
||||
description: T.F.SYNC.FORM.ONEDRIVE.USE_CUSTOM_APP_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'clientId',
|
||||
type: 'input',
|
||||
hideExpression: (m: any) => HAS_OFFICIAL_ONEDRIVE_CLIENT_ID && !m?.useCustomApp,
|
||||
templateOptions: {
|
||||
label: T.F.SYNC.FORM.ONEDRIVE.L_CLIENT_ID,
|
||||
description: T.F.SYNC.FORM.ONEDRIVE.CLIENT_ID_DESCRIPTION,
|
||||
},
|
||||
expressions: {
|
||||
'props.required': (field: FormlyFieldConfig) =>
|
||||
isOneDriveClientIdRequired(field),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.F.SYNC.FORM.ONEDRIVE.L_TENANT_ID,
|
||||
description: T.F.SYNC.FORM.ONEDRIVE.TENANT_ID_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'syncFolderPath',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.F.SYNC.FORM.ONEDRIVE.L_SYNC_FOLDER_PATH,
|
||||
description: T.F.SYNC.FORM.ONEDRIVE.SYNC_FOLDER_PATH_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// OneDrive provider authentication panel
|
||||
{
|
||||
hideExpression: (m, v, field) =>
|
||||
field?.parent?.model.syncProvider !== SyncProviderId.OneDrive,
|
||||
resetOnHide: false,
|
||||
props: {},
|
||||
fieldGroup: [
|
||||
{
|
||||
type: 'tpl',
|
||||
templateOptions: {
|
||||
tag: 'p',
|
||||
text: T.F.SYNC.FORM.ONEDRIVE.INFO_TEXT,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Encryption status box - shown when encryption is enabled (for any provider)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -160,6 +160,14 @@ export interface NextcloudConfig {
|
|||
syncFolderPath?: string | null;
|
||||
}
|
||||
|
||||
export interface OneDriveConfig {
|
||||
/** View-model only: false = use built-in official app (if available), true = use custom app */
|
||||
useCustomApp?: boolean | null;
|
||||
clientId?: string | null;
|
||||
tenantId?: string | null;
|
||||
syncFolderPath?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalFileSyncConfig {
|
||||
// TODO remove and migrate
|
||||
syncFilePath?: string | null;
|
||||
|
|
@ -209,6 +217,8 @@ export type SyncConfig = Readonly<{
|
|||
localFileSync?: LocalFileSyncConfig;
|
||||
/* NOTE: view model for form only*/
|
||||
nextcloud?: NextcloudConfig;
|
||||
/* NOTE: view model for form only*/
|
||||
oneDrive?: OneDriveConfig;
|
||||
}>;
|
||||
|
||||
export type ScheduleConfig = Readonly<{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { OAuthCallbackHandlerService } from '../oauth-callback-handler.service';
|
|||
import { Subscription } from 'rxjs';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { validateOAuthState } from '../oauth-state.util';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-get-and-enter-auth-code',
|
||||
|
|
@ -68,35 +70,37 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
|
|||
// field is rendered at the top of the dialog — keeps the field above the
|
||||
// iOS on-screen keyboard (discussion #7340).
|
||||
readonly codeRequested = signal(false);
|
||||
readonly isElectron = IS_ELECTRON;
|
||||
private _authCodeSub?: Subscription;
|
||||
|
||||
constructor() {
|
||||
this._matDialogRef.disableClose = true;
|
||||
|
||||
// On mobile, listen for OAuth callback
|
||||
if (this.isNativePlatform) {
|
||||
// Listen for OAuth callback on native and Electron for automatic auth completion.
|
||||
if (this.isNativePlatform || this.isElectron) {
|
||||
const expectedProvider = this.data.providerName.toLowerCase();
|
||||
this._authCodeSub = this._oauthCallbackHandler.authCodeReceived$.subscribe(
|
||||
(data) => {
|
||||
if (data.provider === 'dropbox') {
|
||||
if (data.error) {
|
||||
// Handle error from OAuth provider
|
||||
const errorMsg = data.error_description || data.error;
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Authentication failed: ${errorMsg}`,
|
||||
});
|
||||
this.close();
|
||||
} else if (data.code) {
|
||||
this.token = data.code;
|
||||
this.close(this.token);
|
||||
} else {
|
||||
// Unexpected case - no code and no error
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Authentication failed: No authorization code received',
|
||||
});
|
||||
this.close();
|
||||
}
|
||||
if (data.provider !== expectedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const errorMsg = data.error_description || data.error;
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Authentication failed: ${errorMsg}`,
|
||||
});
|
||||
this.close();
|
||||
} else if (data.code) {
|
||||
this.token = data.code;
|
||||
this.close(this.token);
|
||||
} else {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Authentication failed: No authorization code received',
|
||||
});
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -108,7 +112,63 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
|
|||
}
|
||||
|
||||
close(token?: string): void {
|
||||
this._matDialogRef.close(token?.trim());
|
||||
this._matDialogRef.close(this._normalizeAuthCodeInput(token));
|
||||
}
|
||||
|
||||
private _normalizeAuthCodeInput(token?: string): string | undefined {
|
||||
const trimmed = token?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let codeFromInput: string | undefined;
|
||||
|
||||
// Allow pasting the full callback URL and extract `code` automatically.
|
||||
try {
|
||||
const parsedUrl = new URL(trimmed);
|
||||
codeFromInput = parsedUrl.searchParams.get('code') ?? undefined;
|
||||
|
||||
// Validate state parameter when present (CSRF protection for manual paste).
|
||||
// For OneDrive full-URL paste, state is required — missing state means the
|
||||
// callback URL is malformed or attacker-crafted. Raw code-only paste (no URL)
|
||||
// is fine: it won't match the PKCE verifier on token exchange.
|
||||
const stateFromUrl = parsedUrl.searchParams.get('state');
|
||||
if (this.data.providerName.toLowerCase() === 'onedrive') {
|
||||
if (!stateFromUrl) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'OAuth state missing from callback URL. Please try again.',
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!validateOAuthState('onedrive', stateFromUrl)) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'OAuth state validation failed. Please try again.',
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a URL, continue with other extraction attempts.
|
||||
}
|
||||
|
||||
if (codeFromInput) {
|
||||
return codeFromInput;
|
||||
}
|
||||
|
||||
// An attacker-supplied code is harmless here — it won't match the user's
|
||||
// PKCE verifier, so the token exchange will fail with invalid_grant.
|
||||
const codeMatch = trimmed.match(/(?:^|[?&#])code=([^&#]+)/i);
|
||||
if (codeMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(codeMatch[1]);
|
||||
} catch {
|
||||
return codeMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// iOS Safari only opens the keyboard when .focus() runs synchronously in the
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
type WebdavPrivateCfg,
|
||||
} from '@sp/sync-providers/webdav';
|
||||
import { testWebdavConnection } from '../../../op-log/sync-providers/file-based/webdav/test-webdav-connection';
|
||||
import type { OneDrivePrivateCfg } from '../../../op-log/sync-providers/file-based/onedrive/onedrive.model';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-sync-cfg',
|
||||
|
|
@ -207,7 +208,7 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
// in lockstep with the provider-side definition without an async probe.
|
||||
private _reauthBtn(): FormlyFieldConfig {
|
||||
return this._actionBtn({
|
||||
text: T.F.SYNC.FORM.DROPBOX.BTN_REAUTHENTICATE,
|
||||
text: T.F.SYNC.FORM.BTN_REAUTHENTICATE,
|
||||
onClick: () => this.reauth(),
|
||||
hideExpression: (m, v, field) => {
|
||||
const id = field?.parent?.parent?.model?.syncProvider as
|
||||
|
|
@ -381,6 +382,11 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
providerSpecificUpdate = {
|
||||
encryptKey: privateCfg.encryptKey || '',
|
||||
};
|
||||
} else if (newProvider === SyncProviderId.OneDrive && privateCfg) {
|
||||
providerSpecificUpdate = {
|
||||
oneDrive: privateCfg as any,
|
||||
encryptKey: privateCfg.encryptKey || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Update the model, preserving non-provider-specific fields
|
||||
|
|
@ -412,6 +418,45 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
this._matDialogRef.close();
|
||||
}
|
||||
|
||||
private async _persistOneDriveFormCfgBeforeAuth(
|
||||
providerId: SyncProviderId,
|
||||
): Promise<void> {
|
||||
if (providerId !== SyncProviderId.OneDrive) {
|
||||
return;
|
||||
}
|
||||
const oneDriveProvider = await this._providerManager.getProviderById(providerId);
|
||||
if (oneDriveProvider) {
|
||||
const existingCfg = (await oneDriveProvider.privateCfg.load()) as
|
||||
| OneDrivePrivateCfg
|
||||
| null
|
||||
| undefined;
|
||||
const formOneDriveCfg = this._tmpUpdatedCfg.oneDrive || {};
|
||||
|
||||
// If useCustomApp / clientId / tenantId changed, existing tokens are
|
||||
// bound to the old identity — clear them to force a fresh OAuth flow.
|
||||
let identityChanged = !existingCfg;
|
||||
if (existingCfg && !identityChanged) {
|
||||
const formClientId = formOneDriveCfg.clientId ?? existingCfg.clientId;
|
||||
const formTenantId = formOneDriveCfg.tenantId ?? existingCfg.tenantId;
|
||||
const formUseCustomApp = formOneDriveCfg.useCustomApp ?? existingCfg.useCustomApp;
|
||||
|
||||
identityChanged =
|
||||
formUseCustomApp !== existingCfg.useCustomApp ||
|
||||
formClientId !== existingCfg.clientId ||
|
||||
formTenantId !== existingCfg.tenantId;
|
||||
}
|
||||
|
||||
const mergedCfg: OneDrivePrivateCfg = {
|
||||
...(existingCfg || {}),
|
||||
...formOneDriveCfg,
|
||||
...(identityChanged
|
||||
? { accessToken: '', refreshToken: '', tokenExpiresAt: 0 }
|
||||
: {}),
|
||||
} as OneDrivePrivateCfg;
|
||||
await oneDriveProvider.privateCfg.setComplete(mergedCfg);
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
// Check if form is valid
|
||||
if (!this.form.valid) {
|
||||
|
|
@ -438,6 +483,10 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
|
||||
const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider);
|
||||
if (providerId && this._tmpUpdatedCfg.isEnabled) {
|
||||
if (providerId === SyncProviderId.OneDrive) {
|
||||
await this._persistOneDriveFormCfgBeforeAuth(providerId);
|
||||
}
|
||||
|
||||
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(providerId);
|
||||
|
||||
// If the provider requires auth (e.g. Dropbox) and is still not ready,
|
||||
|
|
@ -470,6 +519,10 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
if (providerId === SyncProviderId.OneDrive) {
|
||||
await this._persistOneDriveFormCfgBeforeAuth(providerId);
|
||||
}
|
||||
|
||||
const result =
|
||||
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(
|
||||
providerId,
|
||||
|
|
@ -478,7 +531,7 @@ export class DialogSyncCfgComponent implements AfterViewInit {
|
|||
if (result.wasConfigured) {
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.SYNC.FORM.DROPBOX.REAUTH_SUCCESS,
|
||||
msg: T.F.SYNC.FORM.REAUTH_SUCCESS,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe('OAuthCallbackHandlerService', () => {
|
|||
const result = service['_parseOAuthCallback'](url);
|
||||
|
||||
expect(result.code).toBe('ABC123');
|
||||
expect(result.provider).toBe('dropbox');
|
||||
expect(result.provider).toBe('unknown');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ describe('OAuthCallbackHandlerService', () => {
|
|||
expect(result.code).toBeUndefined();
|
||||
expect(result.error).toBe('access_denied');
|
||||
expect(result.error_description).toBe('User denied access');
|
||||
expect(result.provider).toBe('dropbox');
|
||||
expect(result.provider).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle URL without code or error', () => {
|
||||
|
|
@ -36,7 +36,7 @@ describe('OAuthCallbackHandlerService', () => {
|
|||
|
||||
expect(result.code).toBeUndefined();
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.provider).toBe('dropbox');
|
||||
expect(result.provider).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle malformed URL', () => {
|
||||
|
|
@ -45,7 +45,7 @@ describe('OAuthCallbackHandlerService', () => {
|
|||
|
||||
expect(result.error).toBe('parse_error');
|
||||
expect(result.error_description).toBe('Failed to parse OAuth callback URL');
|
||||
expect(result.provider).toBe('dropbox');
|
||||
expect(result.provider).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should decode URL-encoded parameters', () => {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import { Subject } from 'rxjs';
|
|||
import { App, URLOpenListenerEvent } from '@capacitor/app';
|
||||
import { PluginListenerHandle } from '@capacitor/core';
|
||||
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { PluginOAuthService } from '../../plugins/oauth/plugin-oauth.service';
|
||||
import { IPC } from '../../../../electron/shared-with-frontend/ipc-events.const';
|
||||
import { validateOAuthState } from './oauth-state.util';
|
||||
|
||||
type OAuthProvider = 'dropbox' | 'onedrive' | 'plugin' | 'unknown';
|
||||
|
||||
export interface OAuthCallbackData {
|
||||
code?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
provider: 'dropbox' | 'plugin';
|
||||
provider: OAuthProvider;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
|
@ -20,6 +25,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
private _pluginOAuthService = inject(PluginOAuthService);
|
||||
private _authCodeReceived$ = new Subject<OAuthCallbackData>();
|
||||
private _urlListenerHandle?: PluginListenerHandle;
|
||||
private _isDestroyed = false;
|
||||
|
||||
readonly authCodeReceived$ = this._authCodeReceived$.asObservable();
|
||||
|
||||
|
|
@ -27,9 +33,13 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
if (IS_NATIVE_PLATFORM) {
|
||||
this._setupAppUrlListener();
|
||||
}
|
||||
if (IS_ELECTRON && typeof window !== 'undefined' && !!window.ea?.on) {
|
||||
this._setupElectronOAuthListener();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._isDestroyed = true;
|
||||
this._urlListenerHandle?.remove();
|
||||
this._authCodeReceived$.complete();
|
||||
}
|
||||
|
|
@ -38,11 +48,14 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
this._urlListenerHandle = await App.addListener(
|
||||
'appUrlOpen',
|
||||
(event: URLOpenListenerEvent) => {
|
||||
SyncLog.log('OAuthCallbackHandler: Received URL', event.url);
|
||||
SyncLog.log('OAuthCallbackHandler: Received URL');
|
||||
|
||||
if (event.url.includes('plugin-oauth-callback')) {
|
||||
this._handlePluginOAuthCallback(event.url);
|
||||
} else if (event.url.startsWith('com.super-productivity.app://oauth-callback')) {
|
||||
} else if (
|
||||
event.url.startsWith('com.super-productivity.app://oauth-callback') ||
|
||||
event.url.startsWith('superproductivity://oauth-callback')
|
||||
) {
|
||||
const callbackData = this._parseOAuthCallback(event.url);
|
||||
|
||||
if (callbackData.code) {
|
||||
|
|
@ -54,7 +67,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
callbackData.error_description,
|
||||
);
|
||||
} else {
|
||||
SyncLog.warn('OAuthCallbackHandler: No auth code or error in URL', event.url);
|
||||
SyncLog.warn('OAuthCallbackHandler: No auth code or error in URL');
|
||||
}
|
||||
|
||||
this._authCodeReceived$.next(callbackData);
|
||||
|
|
@ -63,25 +76,82 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
private _setupElectronOAuthListener(): void {
|
||||
window.ea.on(IPC.OAUTH_CALLBACK, (_event, payload) => {
|
||||
if (this._isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const callbackUrl =
|
||||
typeof payload === 'string'
|
||||
? payload
|
||||
: (payload as { url?: string } | undefined)?.url;
|
||||
|
||||
if (!callbackUrl) {
|
||||
SyncLog.warn('OAuthCallbackHandler: Missing callback URL payload from Electron');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callbackUrl.startsWith('superproductivity://oauth-callback')) {
|
||||
SyncLog.warn(
|
||||
'OAuthCallbackHandler: Rejected callback URL with unexpected scheme',
|
||||
callbackUrl.split(':')[0],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
SyncLog.log('OAuthCallbackHandler: Received Electron OAuth callback URL');
|
||||
this._authCodeReceived$.next(this._parseOAuthCallback(callbackUrl));
|
||||
});
|
||||
}
|
||||
|
||||
private _parseOAuthCallback(url: string): OAuthCallbackData {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const code = urlObj.searchParams.get('code');
|
||||
const error = urlObj.searchParams.get('error');
|
||||
const errorDescription = urlObj.searchParams.get('error_description');
|
||||
const state = urlObj.searchParams.get('state');
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
const providerFromPath = pathParts[0]?.toLowerCase();
|
||||
const providerFromQuery = urlObj.searchParams.get('provider')?.toLowerCase();
|
||||
const providerRaw = providerFromPath || providerFromQuery;
|
||||
|
||||
// Validate state for OneDrive CSRF protection.
|
||||
let provider: OAuthProvider;
|
||||
if (providerRaw === 'onedrive') {
|
||||
const stateValid = validateOAuthState('onedrive', state);
|
||||
if (!stateValid) {
|
||||
SyncLog.warn(
|
||||
'OAuthCallbackHandler: Invalid or missing state for OneDrive callback',
|
||||
);
|
||||
return {
|
||||
error: 'invalid_state',
|
||||
error_description: 'OAuth state validation failed',
|
||||
provider: 'onedrive',
|
||||
};
|
||||
}
|
||||
provider = 'onedrive';
|
||||
} else if (providerRaw === 'dropbox') {
|
||||
provider = 'dropbox';
|
||||
} else if (providerRaw === 'plugin') {
|
||||
provider = 'plugin';
|
||||
} else {
|
||||
SyncLog.warn('OAuthCallbackHandler: Unknown provider in callback', providerRaw);
|
||||
provider = 'unknown';
|
||||
}
|
||||
|
||||
return {
|
||||
code: code || undefined,
|
||||
error: error || undefined,
|
||||
error_description: errorDescription || undefined,
|
||||
provider: 'dropbox',
|
||||
provider,
|
||||
};
|
||||
} catch (e) {
|
||||
SyncLog.err('OAuthCallbackHandler: Failed to parse URL', url, e);
|
||||
SyncLog.err('OAuthCallbackHandler: Failed to parse URL');
|
||||
return {
|
||||
error: 'parse_error',
|
||||
error_description: 'Failed to parse OAuth callback URL',
|
||||
provider: 'dropbox',
|
||||
provider: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +170,7 @@ export class OAuthCallbackHandlerService implements OnDestroy {
|
|||
SyncLog.warn('OAuthCallbackHandler: Plugin OAuth error', error);
|
||||
this._pluginOAuthService.handleRedirectError(error, state);
|
||||
} else {
|
||||
SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL', url);
|
||||
SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL');
|
||||
this._pluginOAuthService.handleRedirectError('no_code_or_error', state);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
38
src/app/imex/sync/oauth-state.util.ts
Normal file
38
src/app/imex/sync/oauth-state.util.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const TOKEN_STATE_VALIDITY_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// OAuth state storage for CSRF protection: state -> { provider, expiresAt }
|
||||
const OAUTH_STATES_MAP = new Map<string, { provider: string; expiresAt: number }>();
|
||||
|
||||
const _pruneExpiredOAuthStates = (): void => {
|
||||
const now = Date.now();
|
||||
for (const [state, data] of OAUTH_STATES_MAP.entries()) {
|
||||
if (now > data.expiresAt) {
|
||||
OAUTH_STATES_MAP.delete(state);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store an OAuth state for a provider to protect against CSRF.
|
||||
*/
|
||||
export const addOAuthState = (provider: string, state: string): void => {
|
||||
_pruneExpiredOAuthStates();
|
||||
OAUTH_STATES_MAP.set(state, {
|
||||
provider,
|
||||
expiresAt: Date.now() + TOKEN_STATE_VALIDITY_MS,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate an OAuth state parameter against stored states for a given provider.
|
||||
* Returns true if state is valid, false otherwise.
|
||||
* Consuming the state removes it (one-time use).
|
||||
*/
|
||||
export const validateOAuthState = (provider: string, state: string | null): boolean => {
|
||||
_pruneExpiredOAuthStates();
|
||||
if (!state) return false;
|
||||
const stored = OAUTH_STATES_MAP.get(state);
|
||||
if (!stored || stored.provider !== provider) return false;
|
||||
OAUTH_STATES_MAP.delete(state);
|
||||
return true;
|
||||
};
|
||||
10
src/app/imex/sync/onedrive-auth-mode.const.ts
Normal file
10
src/app/imex/sync/onedrive-auth-mode.const.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { getEnvOptional } from '../../util/env';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
|
||||
|
||||
const _rawOfficialClientId = getEnvOptional('ONEDRIVE_CLIENT_ID') || '';
|
||||
|
||||
export const OFFICIAL_ONEDRIVE_CLIENT_ID = _rawOfficialClientId || null;
|
||||
export const HAS_OFFICIAL_ONEDRIVE_CLIENT_ID = !!_rawOfficialClientId;
|
||||
|
||||
export const IS_ONEDRIVE_SUPPORTED = IS_ELECTRON || IS_NATIVE_PLATFORM;
|
||||
|
|
@ -98,12 +98,14 @@ describe('SyncConfigService', () => {
|
|||
|
||||
await service.updateSettingsFromForm(settings);
|
||||
|
||||
// Should only pass non-private data to global config
|
||||
// Should only pass non-private data to global config.
|
||||
// Optional booleans that were not set in the form are omitted entirely,
|
||||
// so partial form updates don't silently overwrite prior true values.
|
||||
expect(globalConfigService.updateSection).toHaveBeenCalledWith('sync', {
|
||||
isEnabled: true,
|
||||
isEncryptionEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isEncryptionEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { SyncLog } from '../../core/log';
|
|||
import { clearSessionKeyCache } from '@sp/sync-core';
|
||||
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
import { HAS_OFFICIAL_ONEDRIVE_CLIENT_ID } from './onedrive-auth-mode.const';
|
||||
|
||||
// Maps sync providers to their corresponding form field in SyncConfig
|
||||
// Dropbox is null because it doesn't store settings in the form (uses OAuth)
|
||||
|
|
@ -22,6 +23,7 @@ const PROP_MAP_TO_FORM: Record<SyncProviderId, keyof SyncConfig | null> = {
|
|||
[SyncProviderId.WebDAV]: 'webDav',
|
||||
[SyncProviderId.SuperSync]: 'superSync',
|
||||
[SyncProviderId.Nextcloud]: 'nextcloud',
|
||||
[SyncProviderId.OneDrive]: 'oneDrive',
|
||||
[SyncProviderId.Dropbox]: null,
|
||||
};
|
||||
|
||||
|
|
@ -65,7 +67,7 @@ const redactSensitiveFields = (obj: unknown): unknown => {
|
|||
|
||||
const PROVIDER_FIELD_DEFAULTS: Record<
|
||||
SyncProviderId,
|
||||
Record<string, string | boolean>
|
||||
Record<string, string | boolean | number>
|
||||
> = {
|
||||
[SyncProviderId.WebDAV]: {
|
||||
baseUrl: '',
|
||||
|
|
@ -98,6 +100,16 @@ const PROVIDER_FIELD_DEFAULTS: Record<
|
|||
[SyncProviderId.Dropbox]: {
|
||||
encryptKey: '',
|
||||
},
|
||||
[SyncProviderId.OneDrive]: {
|
||||
useCustomApp: !HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
clientId: '',
|
||||
tenantId: 'common',
|
||||
syncFolderPath: 'Super Productivity',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: 0,
|
||||
encryptKey: '',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
|
|
@ -174,6 +186,10 @@ export class SyncConfigService {
|
|||
...DEFAULT_GLOBAL_CONFIG.sync.nextcloud,
|
||||
...syncCfg?.nextcloud,
|
||||
},
|
||||
oneDrive: {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync.oneDrive,
|
||||
...syncCfg?.oneDrive,
|
||||
},
|
||||
};
|
||||
|
||||
// If no provider is active, return base config with empty encryption key
|
||||
|
|
@ -227,6 +243,7 @@ export class SyncConfigService {
|
|||
webDav: DEFAULT_GLOBAL_CONFIG.sync.webDav,
|
||||
superSync: DEFAULT_GLOBAL_CONFIG.sync.superSync,
|
||||
nextcloud: DEFAULT_GLOBAL_CONFIG.sync.nextcloud,
|
||||
oneDrive: DEFAULT_GLOBAL_CONFIG.sync.oneDrive,
|
||||
};
|
||||
|
||||
// Add current provider config if applicable
|
||||
|
|
@ -295,12 +312,30 @@ export class SyncConfigService {
|
|||
this._lastSettings = newSettings;
|
||||
|
||||
const providerId = newSettings.syncProvider as SyncProviderId | null;
|
||||
type SyncPublicConfig = Omit<
|
||||
SyncConfig,
|
||||
'encryptKey' | 'webDav' | 'localFileSync' | 'superSync' | 'nextcloud' | 'oneDrive'
|
||||
>;
|
||||
|
||||
// Split settings into public (global config) and private (credentials/secrets)
|
||||
// to maintain security boundaries - credentials never go to global config
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { encryptKey, webDav, localFileSync, superSync, nextcloud, ...globalConfig } =
|
||||
newSettings;
|
||||
const superSync = newSettings.superSync;
|
||||
// Only include optional booleans when explicitly set, so partial form
|
||||
// updates don't silently overwrite prior true values with undefined.
|
||||
let globalConfig: SyncPublicConfig = {
|
||||
isEnabled: newSettings.isEnabled ?? false,
|
||||
syncProvider: newSettings.syncProvider ?? null,
|
||||
syncInterval: newSettings.syncInterval ?? 300000,
|
||||
...(newSettings.isEncryptionEnabled !== undefined
|
||||
? { isEncryptionEnabled: newSettings.isEncryptionEnabled }
|
||||
: {}),
|
||||
...(newSettings.isCompressionEnabled !== undefined
|
||||
? { isCompressionEnabled: newSettings.isCompressionEnabled }
|
||||
: {}),
|
||||
...(newSettings.isManualSyncOnly !== undefined
|
||||
? { isManualSyncOnly: newSettings.isManualSyncOnly }
|
||||
: {}),
|
||||
};
|
||||
// Provider-specific settings (URLs, credentials) must be stored securely
|
||||
if (providerId) {
|
||||
await this._updatePrivateConfig(providerId, newSettings);
|
||||
|
|
@ -319,10 +354,13 @@ export class SyncConfigService {
|
|||
(savedPrivateCfg as { isEncryptionEnabled?: boolean } | null)
|
||||
?.isEncryptionEnabled ??
|
||||
false;
|
||||
globalConfig.isEncryptionEnabled = isEncryptionEnabled;
|
||||
globalConfig = {
|
||||
...globalConfig,
|
||||
isEncryptionEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
this._globalConfigService.updateSection('sync', globalConfig);
|
||||
this._globalConfigService.updateSection('sync', globalConfig as SyncConfig);
|
||||
}
|
||||
|
||||
private async _updatePrivateConfig(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface LocalSyncMetaForProvider {
|
|||
export interface LocalSyncMetaModel {
|
||||
[SyncProviderId.WebDAV]: LocalSyncMetaForProvider;
|
||||
[SyncProviderId.Dropbox]: LocalSyncMetaForProvider;
|
||||
[SyncProviderId.OneDrive]: LocalSyncMetaForProvider;
|
||||
[SyncProviderId.LocalFile]: LocalSyncMetaForProvider;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,11 +81,13 @@ import type { DropboxPrivateCfg } from '@sp/sync-providers/dropbox';
|
|||
import type { LocalFileSyncPrivateCfg as PackageLocalFileSyncPrivateCfg } from '@sp/sync-providers/local-file';
|
||||
import type { NextcloudPrivateCfg, WebdavPrivateCfg } from '@sp/sync-providers/webdav';
|
||||
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
|
||||
import type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
|
||||
|
||||
export type LocalFileSyncPrivateCfg = PackageLocalFileSyncPrivateCfg;
|
||||
|
||||
export type SyncProviderPrivateCfg =
|
||||
| DropboxPrivateCfg
|
||||
| OneDrivePrivateCfg
|
||||
| WebdavPrivateCfg
|
||||
| SuperSyncPrivateCfg
|
||||
| LocalFileSyncPrivateCfg
|
||||
|
|
@ -98,11 +100,13 @@ export type PrivateCfgByProviderId<T extends SyncProviderId> =
|
|||
? WebdavPrivateCfg
|
||||
: T extends SyncProviderId.Dropbox
|
||||
? DropboxPrivateCfg
|
||||
: T extends SyncProviderId.SuperSync
|
||||
? SuperSyncPrivateCfg
|
||||
: T extends SyncProviderId.Nextcloud
|
||||
? NextcloudPrivateCfg
|
||||
: never;
|
||||
: T extends SyncProviderId.OneDrive
|
||||
? OneDrivePrivateCfg
|
||||
: T extends SyncProviderId.SuperSync
|
||||
? SuperSyncPrivateCfg
|
||||
: T extends SyncProviderId.Nextcloud
|
||||
? NextcloudPrivateCfg
|
||||
: never;
|
||||
|
||||
// ============================================================================
|
||||
// Current Provider Config (for observable emissions)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export {
|
|||
|
||||
// Provider types
|
||||
export type { DropboxPrivateCfg } from '@sp/sync-providers/dropbox';
|
||||
export type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
|
||||
|
||||
// VectorClock from core
|
||||
export { VectorClock } from '../core/util/vector-clock';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export type {
|
||||
OneDrivePrivateCfg,
|
||||
OneDriveItem,
|
||||
OneDriveListResponse,
|
||||
OneDriveTokenResponse,
|
||||
} from '@sp/sync-providers/onedrive';
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
import {
|
||||
OneDrive as PackageOneDrive,
|
||||
PROVIDER_ID_ONEDRIVE,
|
||||
type OneDriveDeps,
|
||||
} from '@sp/sync-providers/onedrive';
|
||||
import { OneDrivePrivateCfg } from './onedrive.model';
|
||||
import type { SyncCredentialStorePort } from '@sp/sync-providers/credential-store';
|
||||
|
||||
describe('OneDrive', () => {
|
||||
let provider: PackageOneDrive;
|
||||
let fetchSpy: jasmine.Spy;
|
||||
let cfgStoreSpy: jasmine.SpyObj<
|
||||
SyncCredentialStorePort<typeof PROVIDER_ID_ONEDRIVE, OneDrivePrivateCfg>
|
||||
>;
|
||||
const tokenExpiryMs = 5 * 60 * 1000;
|
||||
|
||||
const baseCfg: OneDrivePrivateCfg = {
|
||||
clientId: 'client-id',
|
||||
tenantId: 'common',
|
||||
syncFolderPath: 'Super Productivity',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
tokenExpiresAt: Date.now() + tokenExpiryMs,
|
||||
encryptKey: 'enc',
|
||||
};
|
||||
|
||||
const noop = (): void => undefined;
|
||||
const mockDeps: OneDriveDeps = {
|
||||
logger: {
|
||||
log: noop,
|
||||
error: noop,
|
||||
err: noop,
|
||||
normal: noop,
|
||||
verbose: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
critical: noop,
|
||||
debug: noop,
|
||||
},
|
||||
platformInfo: {
|
||||
isNativePlatform: false,
|
||||
isAndroidWebView: false,
|
||||
isIosNative: false,
|
||||
},
|
||||
webFetch: () => fetch as typeof fetch,
|
||||
credentialStore: null as unknown as OneDriveDeps['credentialStore'],
|
||||
officialClientId: null,
|
||||
hasOfficialClientId: false,
|
||||
addOAuthState: noop,
|
||||
isElectron: false,
|
||||
};
|
||||
|
||||
let originalFetch: typeof fetch | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
cfgStoreSpy = jasmine.createSpyObj('SyncCredentialStore', ['load', 'setComplete']);
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
const deps: OneDriveDeps = {
|
||||
...mockDeps,
|
||||
credentialStore: cfgStoreSpy as unknown as OneDriveDeps['credentialStore'],
|
||||
};
|
||||
provider = new PackageOneDrive({}, deps);
|
||||
|
||||
originalFetch = (globalThis as any).fetch;
|
||||
fetchSpy = jasmine.createSpy('fetch');
|
||||
(globalThis as any).fetch = fetchSpy;
|
||||
});
|
||||
|
||||
it('should report ready when credentials are present', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
await expectAsync(provider.isReady()).toBeResolvedTo(true);
|
||||
});
|
||||
|
||||
it('should report not ready when refresh token is missing', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo({ ...baseCfg, refreshToken: '' });
|
||||
|
||||
await expectAsync(provider.isReady()).toBeResolvedTo(false);
|
||||
});
|
||||
|
||||
it('should clear only auth credentials', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
|
||||
await provider.clearAuthCredentials();
|
||||
|
||||
expect(cfgStoreSpy.setComplete).toHaveBeenCalledWith({
|
||||
...baseCfg,
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear credentials and throw MissingRefreshTokenAPIError on 401', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
fetchSpy.and.resolveTo({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '',
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
|
||||
}
|
||||
|
||||
expect(cfgStoreSpy.setComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw MissingRefreshTokenAPIError when token is expired and refresh token is missing', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo({
|
||||
...baseCfg,
|
||||
accessToken: 'stale',
|
||||
refreshToken: '',
|
||||
tokenExpiresAt: Date.now() - 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should clear credentials on 403 InvalidAuthenticationToken', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
|
||||
fetchSpy.and.resolveTo({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'InvalidAuthenticationToken',
|
||||
message: 'Access token has expired or is invalid',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('AuthFailSPError');
|
||||
}
|
||||
|
||||
expect(cfgStoreSpy.setComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should map 429 responses to TooManyRequestsAPIError', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
fetchSpy.and.resolveTo({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'tooManyRequests',
|
||||
message: 'Rate limit exceeded',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('TooManyRequestsAPIError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should refresh expired token and persist new credentials', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo({
|
||||
...baseCfg,
|
||||
accessToken: 'old-token',
|
||||
tokenExpiresAt: Date.now() - 1000,
|
||||
});
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
|
||||
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
|
||||
if (url.includes('/oauth2/v2.0/token')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (init?.method === 'DELETE') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 204,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
});
|
||||
|
||||
await expectAsync(provider.removeFile('test.json')).toBeResolved();
|
||||
|
||||
expect(cfgStoreSpy.setComplete).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
accessToken: 'new-token',
|
||||
refreshToken: 'new-refresh',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should avoid repeated folder existence checks after first successful upload', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
let getCount = 0;
|
||||
let postCount = 0;
|
||||
fetchSpy.and.callFake(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'GET') {
|
||||
getCount++;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (init?.method === 'POST') {
|
||||
postCount++;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (init?.method === 'PUT') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ eTag: 'etag-1' }),
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
provider.uploadFile('file-1.json', '{"a":1}', null, true),
|
||||
).toBeResolved();
|
||||
await expectAsync(
|
||||
provider.uploadFile('file-2.json', '{"a":2}', null, true),
|
||||
).toBeResolved();
|
||||
|
||||
// First upload probes the folder; second upload hits the cache
|
||||
expect(getCount).toBe(1);
|
||||
expect(postCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should refresh token and retry on 401', async () => {
|
||||
let firstRequest = true;
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
|
||||
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
|
||||
// Token refresh endpoint
|
||||
if (url.includes('/oauth2/v2.0/token')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'refreshed-token',
|
||||
refresh_token: 'refreshed-refresh',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
// First API request returns 401, second succeeds
|
||||
if (firstRequest && init?.method === 'DELETE') {
|
||||
firstRequest = false;
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 204,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
});
|
||||
|
||||
await expectAsync(provider.removeFile('test.json')).toBeResolved();
|
||||
// Token refresh was called
|
||||
expect(
|
||||
fetchSpy.calls.all().some((c) => String(c.args[0]).includes('/oauth2/v2.0/token')),
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('should throw HttpNotOkAPIError when 401 retry also fails with transient error', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
|
||||
if (url.includes('/oauth2/v2.0/token')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
// Transient 500 from token endpoint should not clear credentials
|
||||
expect((e as Error).name).toBe('HttpNotOkAPIError');
|
||||
}
|
||||
|
||||
// Credentials should NOT be cleared for transient failures
|
||||
const clearCalls = cfgStoreSpy.setComplete.calls
|
||||
.all()
|
||||
.filter(
|
||||
(call) => call.args[0]?.accessToken === '' && call.args[0]?.refreshToken === '',
|
||||
);
|
||||
expect(clearCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear credentials on 400 invalid_grant from token endpoint', async () => {
|
||||
const expiredCfg = {
|
||||
...baseCfg,
|
||||
accessToken: 'stale',
|
||||
tokenExpiresAt: Date.now() - 1000,
|
||||
};
|
||||
// load() returns expired cfg; clearAuthCredentials also calls load() before setComplete
|
||||
cfgStoreSpy.load.and.resolveTo(expiredCfg);
|
||||
|
||||
fetchSpy.and.callFake(async (url: string) => {
|
||||
if (url.includes('/oauth2/v2.0/token')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Token has been revoked',
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.removeFile('test.json');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
// invalid_grant → clearAuthCredentials → throw MissingRefreshTokenAPIError.
|
||||
// Propagates: refresh IIFE → _request → removeFile catch → _mapAndThrow
|
||||
// which re-throws as-is (not an HttpNotOkAPIError).
|
||||
expect((e as Error).name).toBe('MissingRefreshTokenAPIError');
|
||||
}
|
||||
|
||||
// Credentials were cleared by clearAuthCredentials()
|
||||
const clearCalls = cfgStoreSpy.setComplete.calls
|
||||
.all()
|
||||
.filter(
|
||||
(call) => call.args[0]?.accessToken === '' && call.args[0]?.refreshToken === '',
|
||||
);
|
||||
expect(clearCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should map 412 responses to UploadRevToMatchMismatchAPIError', async () => {
|
||||
cfgStoreSpy.load.and.resolveTo(baseCfg);
|
||||
|
||||
fetchSpy.and.resolveTo({
|
||||
ok: false,
|
||||
status: 412,
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'preconditionFailed',
|
||||
message: 'ETag does not match',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
await provider.uploadFile('test.json', '{"a":1}', 'rev-old');
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('UploadRevToMatchMismatchAPIError');
|
||||
}
|
||||
});
|
||||
|
||||
it('should deduplicate concurrent token refresh requests', async () => {
|
||||
let refreshCallCount = 0;
|
||||
cfgStoreSpy.load.and.resolveTo({
|
||||
...baseCfg,
|
||||
accessToken: 'old-token',
|
||||
tokenExpiresAt: Date.now() - 1000,
|
||||
});
|
||||
cfgStoreSpy.setComplete.and.resolveTo();
|
||||
|
||||
fetchSpy.and.callFake(async (url: string, init?: RequestInit) => {
|
||||
if (url.includes('/oauth2/v2.0/token')) {
|
||||
refreshCallCount++;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'new-token',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
// API requests succeed
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => ({ eTag: 'etag-1' }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
// Fire two concurrent requests that both need a token refresh
|
||||
await Promise.all([
|
||||
provider.removeFile('file-1.json'),
|
||||
provider.removeFile('file-2.json'),
|
||||
]);
|
||||
|
||||
// Only one token refresh should have been made
|
||||
expect(refreshCallCount).toBe(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
OneDrive as PackageOneDrive,
|
||||
PROVIDER_ID_ONEDRIVE,
|
||||
type OneDriveDeps,
|
||||
} from '@sp/sync-providers/onedrive';
|
||||
import { SyncProviderId } from '../../provider.const';
|
||||
import { SyncCredentialStore } from '../../credential-store.service';
|
||||
import { OP_LOG_SYNC_LOGGER } from '../../../core/sync-logger.adapter';
|
||||
import { APP_PROVIDER_PLATFORM_INFO } from '../../platform/app-provider-platform-info';
|
||||
import { APP_WEB_FETCH } from '../../platform/app-web-fetch';
|
||||
import {
|
||||
OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
} from '../../../../imex/sync/onedrive-auth-mode.const';
|
||||
import { addOAuthState } from '../../../../imex/sync/oauth-state.util';
|
||||
import { IS_ELECTRON } from '../../../../app.constants';
|
||||
|
||||
type AssertOneDriveId = SyncProviderId.OneDrive extends typeof PROVIDER_ID_ONEDRIVE
|
||||
? true
|
||||
: never;
|
||||
const _idCheck: AssertOneDriveId = true;
|
||||
void _idCheck;
|
||||
|
||||
export type { OneDrivePrivateCfg } from '@sp/sync-providers/onedrive';
|
||||
|
||||
export const createOneDriveProvider = (
|
||||
cfg: { devPath?: string } = {},
|
||||
): PackageOneDrive => {
|
||||
const deps: OneDriveDeps = {
|
||||
logger: OP_LOG_SYNC_LOGGER,
|
||||
platformInfo: APP_PROVIDER_PLATFORM_INFO,
|
||||
webFetch: APP_WEB_FETCH,
|
||||
credentialStore: new SyncCredentialStore(
|
||||
SyncProviderId.OneDrive,
|
||||
) as OneDriveDeps['credentialStore'],
|
||||
officialClientId: OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
hasOfficialClientId: HAS_OFFICIAL_ONEDRIVE_CLIENT_ID,
|
||||
addOAuthState,
|
||||
isElectron: IS_ELECTRON,
|
||||
};
|
||||
return new PackageOneDrive(cfg, deps);
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// NOTE: do not change!!
|
||||
export enum SyncProviderId {
|
||||
'Dropbox' = 'Dropbox',
|
||||
'OneDrive' = 'OneDrive',
|
||||
'WebDAV' = 'WebDAV',
|
||||
'LocalFile' = 'LocalFile',
|
||||
'SuperSync' = 'SuperSync',
|
||||
|
|
@ -14,6 +15,7 @@ export enum SyncProviderId {
|
|||
*/
|
||||
export const OAUTH_SYNC_PROVIDERS: ReadonlySet<SyncProviderId> = new Set([
|
||||
SyncProviderId.Dropbox,
|
||||
SyncProviderId.OneDrive,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SyncProviderBase } from './provider.interface';
|
|||
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { IS_ONEDRIVE_SUPPORTED } from '../../imex/sync/onedrive-auth-mode.const';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
let _providersPromise: Promise<SyncProviderBase<SyncProviderId>[]> | null = null;
|
||||
|
|
@ -56,6 +57,15 @@ const _createProviders = async (): Promise<SyncProviderBase<SyncProviderId>[]> =
|
|||
createNextcloudProvider(extraPath) as SyncProviderBase<SyncProviderId>,
|
||||
];
|
||||
|
||||
if (IS_ONEDRIVE_SUPPORTED) {
|
||||
const { createOneDriveProvider } = await import('./file-based/onedrive/onedrive');
|
||||
providers.push(
|
||||
createOneDriveProvider({
|
||||
devPath: extraPath,
|
||||
}) as SyncProviderBase<SyncProviderId>,
|
||||
);
|
||||
}
|
||||
|
||||
if (IS_ELECTRON) {
|
||||
const { createLocalFileSyncElectron } =
|
||||
await import('./file-based/local-file/local-file-sync-electron');
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ describe('operation-sync utility', () => {
|
|||
const KNOWN_FILE_BASED: ReadonlySet<SyncProviderId> = new Set([
|
||||
SyncProviderId.Dropbox,
|
||||
SyncProviderId.WebDAV,
|
||||
SyncProviderId.OneDrive,
|
||||
SyncProviderId.LocalFile,
|
||||
SyncProviderId.Nextcloud,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import {
|
|||
} from '../sync-providers/provider.interface';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
|
||||
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, LocalFile, Nextcloud) */
|
||||
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, OneDrive, LocalFile, Nextcloud) */
|
||||
const FILE_BASED_PROVIDER_IDS: Set<SyncProviderId> = new Set([
|
||||
SyncProviderId.WebDAV,
|
||||
SyncProviderId.Dropbox,
|
||||
SyncProviderId.OneDrive,
|
||||
SyncProviderId.LocalFile,
|
||||
SyncProviderId.Nextcloud,
|
||||
]);
|
||||
|
|
@ -39,7 +40,7 @@ export const isOperationSyncCapable = (
|
|||
|
||||
/**
|
||||
* Type guard to check if a provider uses file-based operation sync.
|
||||
* File-based providers (WebDAV, Dropbox, LocalFile, Nextcloud) use file storage for sync.
|
||||
* File-based providers (WebDAV, Dropbox, OneDrive, LocalFile, Nextcloud) use file storage for sync.
|
||||
*/
|
||||
export const isFileBasedProvider = (
|
||||
provider: SyncProviderBase<SyncProviderId>,
|
||||
|
|
|
|||
|
|
@ -1281,6 +1281,8 @@ const T = {
|
|||
FORM: {
|
||||
BTN_CHANGE_PASSWORD: 'F.SYNC.FORM.BTN_CHANGE_PASSWORD',
|
||||
BTN_DISABLE_ENCRYPTION: 'F.SYNC.FORM.BTN_DISABLE_ENCRYPTION',
|
||||
BTN_REAUTHENTICATE: 'F.SYNC.FORM.BTN_REAUTHENTICATE',
|
||||
REAUTH_SUCCESS: 'F.SYNC.FORM.REAUTH_SUCCESS',
|
||||
DROPBOX: {
|
||||
AUTH_SUCCESS: 'F.SYNC.FORM.DROPBOX.AUTH_SUCCESS',
|
||||
BTN_AUTHENTICATE: 'F.SYNC.FORM.DROPBOX.BTN_AUTHENTICATE',
|
||||
|
|
@ -1443,6 +1445,19 @@ const T = {
|
|||
L_APP_PASSWORD: 'F.SYNC.FORM.NEXTCLOUD.L_APP_PASSWORD',
|
||||
APP_PASSWORD_DESCRIPTION: 'F.SYNC.FORM.NEXTCLOUD.APP_PASSWORD_DESCRIPTION',
|
||||
},
|
||||
ONEDRIVE: {
|
||||
CLIENT_ID_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.CLIENT_ID_DESCRIPTION',
|
||||
INFO_TEXT: 'F.SYNC.FORM.ONEDRIVE.INFO_TEXT',
|
||||
L_CLIENT_ID: 'F.SYNC.FORM.ONEDRIVE.L_CLIENT_ID',
|
||||
L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.ONEDRIVE.L_SYNC_FOLDER_PATH',
|
||||
L_TENANT_ID: 'F.SYNC.FORM.ONEDRIVE.L_TENANT_ID',
|
||||
L_USE_CUSTOM_APP: 'F.SYNC.FORM.ONEDRIVE.L_USE_CUSTOM_APP',
|
||||
OFFICIAL_MODE_INFO: 'F.SYNC.FORM.ONEDRIVE.OFFICIAL_MODE_INFO',
|
||||
SYNC_FOLDER_PATH_DESCRIPTION:
|
||||
'F.SYNC.FORM.ONEDRIVE.SYNC_FOLDER_PATH_DESCRIPTION',
|
||||
TENANT_ID_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.TENANT_ID_DESCRIPTION',
|
||||
USE_CUSTOM_APP_DESCRIPTION: 'F.SYNC.FORM.ONEDRIVE.USE_CUSTOM_APP_DESCRIPTION',
|
||||
},
|
||||
TITLE: 'F.SYNC.FORM.TITLE',
|
||||
WEB_DAV: {
|
||||
CORS_INFO: 'F.SYNC.FORM.WEB_DAV.CORS_INFO',
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,8 @@
|
|||
"FORM": {
|
||||
"BTN_CHANGE_PASSWORD": "Change Password",
|
||||
"BTN_DISABLE_ENCRYPTION": "Disable Encryption",
|
||||
"BTN_REAUTHENTICATE": "Re-authenticate",
|
||||
"REAUTH_SUCCESS": "Re-authentication successful! Credentials updated.",
|
||||
"DROPBOX": {
|
||||
"AUTH_SUCCESS": "Dropbox authentication successful! Credentials saved.",
|
||||
"BTN_AUTHENTICATE": "Authenticate with Dropbox",
|
||||
|
|
@ -1406,6 +1408,18 @@
|
|||
"L_APP_PASSWORD": "App Password",
|
||||
"APP_PASSWORD_DESCRIPTION": "Go to Nextcloud → Settings → Security → App passwords to generate one"
|
||||
},
|
||||
"ONEDRIVE": {
|
||||
"CLIENT_ID_DESCRIPTION": "Application (client) ID from your Microsoft Entra app registration",
|
||||
"INFO_TEXT": "Microsoft 365 authentication uses OAuth. Fill client and tenant first, then authenticate.",
|
||||
"L_CLIENT_ID": "Application (Client) ID",
|
||||
"L_SYNC_FOLDER_PATH": "Sync Folder Path",
|
||||
"L_TENANT_ID": "Tenant ID",
|
||||
"L_USE_CUSTOM_APP": "Use my own Microsoft Entra app registration",
|
||||
"OFFICIAL_MODE_INFO": "Using built-in Microsoft 365 sign-in for this app. Switch to custom mode only if you need your own Entra app registration.",
|
||||
"SYNC_FOLDER_PATH_DESCRIPTION": "Stored under your OneDrive App Folder (e.g. super-productivity)",
|
||||
"TENANT_ID_DESCRIPTION": "Use 'common' for multi-tenant sign-in",
|
||||
"USE_CUSTOM_APP_DESCRIPTION": "Enable this if you want to provide your own Application (Client) ID"
|
||||
},
|
||||
"TITLE": "Sync",
|
||||
"WEB_DAV": {
|
||||
"CORS_INFO": "<strong>Making it work in a web browser:</strong> You need to allow Super Productivity to make CORS requests to your server. For Nextcloud, one approach is allowing \"https://app.super-productivity.com\" via the <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword</a> app. Refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information.",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"@sp/sync-providers/provider-types": [
|
||||
"packages/sync-providers/src/provider-types.ts"
|
||||
],
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
|
||||
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
|
||||
}
|
||||
},
|
||||
"files": ["test.ts", "polyfills.ts"],
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const REQUIRED_ENV_KEYS = [
|
|||
const OPTIONAL_ENV_KEYS = [
|
||||
'UNSPLASH_KEY',
|
||||
'UNSPLASH_CLIENT_ID',
|
||||
'ONEDRIVE_CLIENT_ID',
|
||||
'GOOGLE_DRIVE_TOKEN',
|
||||
'DROPBOX_API_KEY',
|
||||
'WEBDAV_URL',
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
"@sp/sync-providers/provider-types": [
|
||||
"packages/sync-providers/src/provider-types.ts"
|
||||
],
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
|
||||
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"],
|
||||
"@sp/sync-providers/onedrive": ["packages/sync-providers/src/onedrive.ts"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue