[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
377
infrastructure/services/adapters/WebDAVAdapter.ts
Normal file
377
infrastructure/services/adapters/WebDAVAdapter.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user