Files
NetMesh/infrastructure/services/adapters/WebDAVAdapter.ts

378 lines
11 KiB
TypeScript
Raw Normal View History

/**
* WebDAV Adapter - webdav client library
*/
import { AuthType, createClient } from 'webdav';
import {
SYNC_CONSTANTS,
type WebDAVConfig,
type SyncedFile,
type ProviderAccount,
type OAuthTokens,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
type WebDAVClient = ReturnType<typeof createClient>;
const normalizeEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const ensureLeadingSlash = (value: string): string =>
value.startsWith('/') ? value : `/${value}`;
/**
* Recover from trailing garbage left by non-truncating WebDAV PUT overwrites
* (#2223). Node/V8: "Unexpected non-whitespace character after JSON at position N".
*/
const parseSyncedFileJson = (raw: string): SyncedFile => {
try {
return JSON.parse(raw) as SyncedFile;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const match = /Unexpected non-whitespace character after JSON at position (\d+)/i.exec(
message,
);
if (match) {
const pos = Number(match[1]);
if (Number.isFinite(pos) && pos > 0 && pos <= raw.length) {
return JSON.parse(raw.slice(0, pos)) as SyncedFile;
}
}
throw error;
}
};
const utf8ByteLength = (value: string): number =>
typeof Buffer !== 'undefined'
? Buffer.byteLength(value, 'utf8')
: new TextEncoder().encode(value).length;
/** Strict: intended body plus optional trailing whitespace only. */
const remoteMatchesUploadedBody = (remoteText: string, body: string): boolean => {
if (!remoteText.startsWith(body)) return false;
return /^\s*$/.test(remoteText.slice(body.length));
};
/**
* Prefer fixed-name temp PUT + MOVE (with pad + verify); else padded in-place
* PUT with strict body match so non-truncating servers cannot leave garbage.
*/
const putWebdavFileReplacing = async (
client: WebDAVClient,
path: string,
body: string,
): Promise<void> => {
const tmpPath = `${path}.tmp`;
const bodyLen = utf8ByteLength(body);
const cleanupTemp = async () => {
try {
if (await client.exists(tmpPath)) {
await client.deleteFile(tmpPath);
}
} catch {
// best-effort
}
};
const readLen = async (target: string): Promise<number> => {
let exists = false;
try {
exists = await client.exists(target);
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not check existing file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (!exists) return 0;
try {
const existing = await client.getFileContents(target, { format: 'text' });
if (existing == null) return 0;
return utf8ByteLength(String(existing));
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not read existing file length (${
error instanceof Error ? error.message : String(error)
})`,
);
}
};
try {
let tmpMin = 0;
try {
tmpMin = await readLen(tmpPath);
} catch {
tmpMin = 0;
}
const tmpBody = tmpMin > bodyLen ? body + ' '.repeat(tmpMin - bodyLen) : body;
await client.putFileContents(tmpPath, tmpBody, { overwrite: true });
await client.moveFile(tmpPath, path, { overwrite: true });
const moved = String((await client.getFileContents(path, { format: 'text' })) ?? '');
if (remoteMatchesUploadedBody(moved, body)) {
return;
}
} catch {
// fall through
}
await cleanupTemp();
let minLen = await readLen(path);
minLen = Math.max(minLen, bodyLen);
for (let attempt = 0; attempt < 3; attempt++) {
const payload = minLen > bodyLen ? body + ' '.repeat(minLen - bodyLen) : body;
await client.putFileContents(path, payload, { overwrite: true });
let remoteText = '';
try {
remoteText = String((await client.getFileContents(path, { format: 'text' })) ?? '');
} catch (error) {
throw new Error(
`WebDAV upload verification failed: could not re-read file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (remoteMatchesUploadedBody(remoteText, body)) {
return;
}
minLen = Math.max(minLen, utf8ByteLength(remoteText), bodyLen);
}
throw new Error(
'WebDAV upload verification failed: remote file still does not match uploaded body after padded PUT',
);
};
export class WebDAVAdapter {
private config: WebDAVConfig | null;
private resource: string | null;
private account: ProviderAccount | null;
private client: WebDAVClient | null;
constructor(config?: WebDAVConfig, resourceId?: string) {
this.config = config
? { ...config, endpoint: normalizeEndpoint(config.endpoint) }
: null;
this.resource = resourceId || null;
this.account = this.buildAccountInfo(this.config);
this.client = this.config ? this.createClient(this.config) : null;
}
get isAuthenticated(): boolean {
return !!this.config;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.resource;
}
signOut(): void {
this.config = null;
this.resource = null;
this.account = null;
this.client = null;
}
async initializeSync(): Promise<string | null> {
return this.withWebdavErrorContext('initialize', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavInitialize) {
const result = await bridge.cloudSyncWebdavInitialize(this.config);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await client.exists(path);
this.resource = path;
return this.resource;
});
}
async upload(syncedFile: SyncedFile): Promise<string> {
return this.withWebdavErrorContext('upload', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavUpload) {
const result = await bridge.cloudSyncWebdavUpload(this.config, syncedFile);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await putWebdavFileReplacing(client, path, JSON.stringify(syncedFile));
this.resource = path;
return path;
});
}
async download(): Promise<SyncedFile | null> {
return this.withWebdavErrorContext('download', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDownload) {
const result = await bridge.cloudSyncWebdavDownload(this.config);
return (result?.syncedFile ?? null) as SyncedFile | null;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return null;
const data = await client.getFileContents(path, { format: 'text' });
if (!data) return null;
return parseSyncedFileJson(data as string);
});
}
async deleteSync(): Promise<void> {
return this.withWebdavErrorContext('delete', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDelete) {
await bridge.cloudSyncWebdavDelete(this.config);
return;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return;
await client.deleteFile(path);
});
}
getTokens(): OAuthTokens | null {
return null;
}
private getClient(): WebDAVClient {
if (!this.config || !this.client) {
throw new Error('Missing WebDAV config');
}
return this.client;
}
private createClient(config: WebDAVConfig): WebDAVClient {
const extraOpts: Record<string, unknown> = {};
if (config.allowInsecure && typeof globalThis.process !== 'undefined') {
const https = require('https');
extraOpts.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (config.authType === 'token') {
return createClient(config.endpoint, {
authType: AuthType.Token,
token: {
access_token: config.token || '',
token_type: 'Bearer',
},
...extraOpts,
});
}
if (config.authType === 'digest') {
return createClient(config.endpoint, {
authType: AuthType.Digest,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
return createClient(config.endpoint, {
authType: AuthType.Password,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
private async withWebdavErrorContext<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (error) {
throw this.buildWebdavError(operation, error);
}
}
private buildWebdavError(operation: string, error: unknown): Error {
const baseMessage = error instanceof Error ? error.message : String(error);
const details: Record<string, string | number | boolean | null | undefined> = {
operation,
};
const raw = error as {
status?: number;
statusText?: string;
url?: string;
method?: string;
code?: string;
response?: {
status?: number;
statusText?: string;
url?: string;
};
cause?: unknown;
};
if (raw?.status) details.status = raw.status;
if (raw?.statusText) details.statusText = raw.statusText;
if (raw?.url) details.url = raw.url;
if (raw?.method) details.method = raw.method;
if (raw?.code) details.code = raw.code;
if (raw?.response?.status) details.status = raw.response.status;
if (raw?.response?.statusText) details.statusText = raw.response.statusText;
if (raw?.response?.url) details.url = raw.response.url;
if (raw?.cause && typeof raw.cause === 'object') {
Object.assign(details, raw.cause as Record<string, unknown>);
details.operation = operation;
const cause = raw.cause as { code?: string };
if (cause?.code) details.causeCode = cause.code;
} else if (raw?.cause) {
details.cause = String(raw.cause);
}
const err = new Error(`WebDAV ${operation} failed: ${baseMessage}`);
(err as Error & { cause?: unknown }).cause = details;
return err;
}
private getSyncPath(): string {
return ensureLeadingSlash(SYNC_CONSTANTS.SYNC_FILE_NAME);
}
private buildAccountInfo(config: WebDAVConfig | null): ProviderAccount | null {
if (!config) return null;
try {
const url = new URL(config.endpoint);
const host = url.host;
const name = config.username ? `${config.username}@${host}` : host;
return { id: host, name };
} catch {
return { id: config.endpoint, name: config.endpoint };
}
}
}
export default WebDAVAdapter;