[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:
253
infrastructure/services/adapters/S3Adapter.ts
Normal file
253
infrastructure/services/adapters/S3Adapter.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user