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
254 lines
7.1 KiB
TypeScript
254 lines
7.1 KiB
TypeScript
/**
|
|
* S3 Compatible Adapter - AWS SDK v3
|
|
*/
|
|
|
|
import {
|
|
S3Client,
|
|
HeadObjectCommand,
|
|
PutObjectCommand,
|
|
GetObjectCommand,
|
|
DeleteObjectCommand,
|
|
} from '@aws-sdk/client-s3';
|
|
import {
|
|
SYNC_CONSTANTS,
|
|
type S3Config,
|
|
type SyncedFile,
|
|
type ProviderAccount,
|
|
type OAuthTokens,
|
|
} from '../../../domain/sync';
|
|
import { netcattyBridge } from '../netcattyBridge';
|
|
|
|
const normalizeEndpoint = (endpoint: string): string => {
|
|
const trimmed = endpoint.trim();
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
|
return `https://${trimmed}`;
|
|
}
|
|
return trimmed;
|
|
};
|
|
|
|
const toBodyString = async (body: unknown): Promise<string> => {
|
|
if (!body) return '';
|
|
if (typeof body === 'string') return body;
|
|
if (body instanceof Uint8Array) {
|
|
return new TextDecoder().decode(body);
|
|
}
|
|
if (body instanceof Blob) {
|
|
return await body.text();
|
|
}
|
|
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
|
|
return await new Response(body).text();
|
|
}
|
|
if (typeof (body as { transformToString?: () => Promise<string> }).transformToString === 'function') {
|
|
return await (body as { transformToString: () => Promise<string> }).transformToString();
|
|
}
|
|
throw new Error('Unsupported S3 response body');
|
|
};
|
|
|
|
export class S3Adapter {
|
|
private config: S3Config | null;
|
|
private resource: string | null;
|
|
private account: ProviderAccount | null;
|
|
private client: S3Client | null;
|
|
|
|
constructor(config?: S3Config, 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> {
|
|
if (!this.config) {
|
|
throw new Error('Missing S3 config');
|
|
}
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.cloudSyncS3Initialize) {
|
|
const result = await bridge.cloudSyncS3Initialize(this.config);
|
|
this.resource = result?.resourceId || this.getObjectKey();
|
|
return this.resource;
|
|
}
|
|
const client = this.getClient();
|
|
try {
|
|
await client.send(new HeadObjectCommand({
|
|
Bucket: this.config.bucket,
|
|
Key: this.getObjectKey(),
|
|
}));
|
|
} catch (error) {
|
|
if (this.isNotFound(error)) {
|
|
// File doesn't exist yet.
|
|
} else if (this.isAccessDenied(error)) {
|
|
throw new Error('S3 access denied');
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
this.resource = this.getObjectKey();
|
|
return this.resource;
|
|
}
|
|
|
|
async upload(syncedFile: SyncedFile): Promise<string> {
|
|
if (!this.config) {
|
|
throw new Error('Missing S3 config');
|
|
}
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.cloudSyncS3Upload) {
|
|
const result = await bridge.cloudSyncS3Upload(this.config, syncedFile);
|
|
this.resource = result?.resourceId || this.getObjectKey();
|
|
return this.resource;
|
|
}
|
|
const body = JSON.stringify(syncedFile);
|
|
const client = this.getClient();
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: this.config.bucket,
|
|
Key: this.getObjectKey(),
|
|
Body: body,
|
|
ContentType: 'application/json',
|
|
}));
|
|
this.resource = this.getObjectKey();
|
|
return this.resource;
|
|
}
|
|
|
|
async download(): Promise<SyncedFile | null> {
|
|
if (!this.config) {
|
|
throw new Error('Missing S3 config');
|
|
}
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.cloudSyncS3Download) {
|
|
const result = await bridge.cloudSyncS3Download(this.config);
|
|
return (result?.syncedFile ?? null) as SyncedFile | null;
|
|
}
|
|
const client = this.getClient();
|
|
try {
|
|
const response = await client.send(new GetObjectCommand({
|
|
Bucket: this.config.bucket,
|
|
Key: this.getObjectKey(),
|
|
}));
|
|
const text = await toBodyString(response.Body);
|
|
if (!text) return null;
|
|
return JSON.parse(text) as SyncedFile;
|
|
} catch (error) {
|
|
if (this.isNotFound(error)) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteSync(): Promise<void> {
|
|
if (!this.config) {
|
|
return;
|
|
}
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.cloudSyncS3Delete) {
|
|
await bridge.cloudSyncS3Delete(this.config);
|
|
return;
|
|
}
|
|
const client = this.getClient();
|
|
try {
|
|
await client.send(new DeleteObjectCommand({
|
|
Bucket: this.config.bucket,
|
|
Key: this.getObjectKey(),
|
|
}));
|
|
} catch (error) {
|
|
if (this.isNotFound(error)) {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
getTokens(): OAuthTokens | null {
|
|
return null;
|
|
}
|
|
|
|
private getClient(): S3Client {
|
|
if (!this.config || !this.client) {
|
|
if (this.config?.allowInsecure) {
|
|
throw new Error('S3 insecure connections require the Netcatty desktop sync bridge');
|
|
}
|
|
throw new Error('Missing S3 config');
|
|
}
|
|
return this.client;
|
|
}
|
|
|
|
private createClient(config: S3Config): S3Client | null {
|
|
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
|
|
region: config.region,
|
|
endpoint: config.endpoint,
|
|
forcePathStyle: config.forcePathStyle ?? true,
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
credentials: {
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
sessionToken: config.sessionToken,
|
|
},
|
|
};
|
|
|
|
if (
|
|
config.allowInsecure
|
|
&& typeof globalThis.process !== 'undefined'
|
|
&& typeof require === 'function'
|
|
) {
|
|
const https = require('https');
|
|
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
|
clientConfig.requestHandler = new NodeHttpHandler({
|
|
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
|
});
|
|
} else if (config.allowInsecure) {
|
|
return null;
|
|
}
|
|
|
|
return new S3Client(clientConfig);
|
|
}
|
|
|
|
private isNotFound(error: unknown): boolean {
|
|
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 404);
|
|
}
|
|
|
|
private isAccessDenied(error: unknown): boolean {
|
|
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 403);
|
|
}
|
|
|
|
private getObjectKey(): string {
|
|
if (!this.config) {
|
|
throw new Error('Missing S3 config');
|
|
}
|
|
const prefix = (this.config.prefix || '').trim().replace(/^\/+|\/+$/g, '');
|
|
if (!prefix) {
|
|
return SYNC_CONSTANTS.SYNC_FILE_NAME;
|
|
}
|
|
return `${prefix}/${SYNC_CONSTANTS.SYNC_FILE_NAME}`;
|
|
}
|
|
|
|
private buildAccountInfo(config: S3Config | null): ProviderAccount | null {
|
|
if (!config) return null;
|
|
const name = `${config.bucket} (${config.region})`;
|
|
const id = `${config.bucket}@${config.endpoint}`;
|
|
return { id, name };
|
|
}
|
|
}
|
|
|
|
export default S3Adapter;
|