/**
* Huawei Cloud OBS (Object Storage Service) Provider
*
* This provider implements the Provider interface for Huawei Cloud OBS.
* SDK Repository: https://github.com/huaweicloud/huaweicloud-sdk-nodejs-obs
* Documentation: https://support.huaweicloud.com/obs/index.html
*/
import ObsClient from "esdk-obs-nodejs";
import { Provider, ProviderRegistry } from "../interface.js";
import type { SourceConfig, ProviderType } from "../../types/config.js";
import type {
BucketInfo,
ObjectInfo,
ObjectContent,
ObjectMetadata,
ListObjectsResult,
SearchFilter,
SearchResult,
} from "../../types/storage.js";
import type { ListObjectsOptions, GetObjectOptions } from "../interface.js";
import * as mime from "mime-types";
/**
* Huawei Cloud OBS Provider Implementation
*/
class HuaweiOBSProvider implements Provider {
readonly id: ProviderType = "obs";
readonly name = "Huawei Cloud OBS";
private client: ObsClient | null = null;
private sourceId: string = "";
private config: SourceConfig | null = null;
getId(): string {
return this.sourceId;
}
clone(): Provider {
return new HuaweiOBSProvider();
}
async connect(config: SourceConfig): Promise<void> {
this.config = config;
this.sourceId = config.id;
// Initialize OBS client
this.client = new ObsClient({
access_key_id: config.access_key,
secret_access_key: config.secret_key,
server: config.endpoint,
timeout: config.connection_timeout ? config.connection_timeout * 1000 : 60000,
ssl_verify: config.ssl !== false,
security_token: config.security_token,
});
// Test connection by listing buckets
try {
const result = await this.client.listBuckets();
if (result.CommonMsg.Status >= 300) {
throw new Error(`Connection failed: ${result.CommonMsg.Message}`);
}
} catch (error) {
this.client = null;
throw new Error(`Failed to connect to OBS: ${(error as Error).message}`);
}
}
async disconnect(): Promise<void> {
if (this.client) {
this.client.close();
this.client = null;
}
}
private ensureClient(): ObsClient {
if (!this.client) {
throw new Error("OBS client not connected. Call connect() first.");
}
return this.client;
}
async listBuckets(): Promise<BucketInfo[]> {
const client = this.ensureClient();
const result = await client.listBuckets();
if (result.CommonMsg.Status >= 300) {
throw new Error(`Failed to list buckets: ${result.CommonMsg.Message}`);
}
return (result.InterfaceResult?.Buckets || []).map((bucket: any) => ({
name: bucket.BucketName,
creationDate: bucket.CreationDate,
location: bucket.Location,
}));
}
async listObjects(bucket: string, options?: ListObjectsOptions): Promise<ListObjectsResult> {
const client = this.ensureClient();
const params: any = {
Bucket: bucket,
MaxKeys: options?.maxKeys || 1000,
};
if (options?.prefix) {
params.Prefix = options.prefix;
}
if (options?.delimiter) {
params.Delimiter = options.delimiter;
}
if (options?.continuationToken) {
params.Marker = options.continuationToken;
}
const result = await client.listObjects(params);
if (result.CommonMsg.Status >= 300) {
throw new Error(`Failed to list objects: ${result.CommonMsg.Message}`);
}
const ir = result.InterfaceResult;
const objects: ObjectInfo[] = (ir?.Contents || []).map((obj: any) => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified,
etag: obj.ETag?.replace(/"/g, ""),
storageClass: obj.StorageClass,
owner: obj.Owner
? {
id: obj.Owner.ID,
displayName: obj.Owner.DisplayName,
}
: undefined,
}));
const commonPrefixes = (ir?.CommonPrefixes || []).map((p: any) => p.Prefix);
return {
objects,
prefix: ir?.Prefix,
delimiter: ir?.Delimiter,
isTruncated: ir?.IsTruncated === "true" || ir?.IsTruncated === true,
nextContinuationToken: ir?.NextMarker,
commonPrefixes: commonPrefixes.length > 0 ? commonPrefixes : undefined,
keyCount: objects.length,
maxKeys: ir?.MaxKeys || params.MaxKeys,
};
}
async getObject(bucket: string, key: string, options?: GetObjectOptions): Promise<ObjectContent> {
const client = this.ensureClient();
const params: any = {
Bucket: bucket,
Key: key,
};
// Handle range request
if (options?.rangeStart !== undefined || options?.rangeEnd !== undefined) {
const start = options.rangeStart ?? 0;
const end = options.rangeEnd ?? "";
params.Range = `bytes=${start}-${end}`;
}
const result = await client.getObject(params);
if (result.CommonMsg.Status >= 300) {
throw new Error(`Failed to get object: ${result.CommonMsg.Message}`);
}
const ir = result.InterfaceResult;
const contentType = ir?.ContentType || mime.lookup(key) || "application/octet-stream";
const contentLength = parseInt(ir?.ContentLength || "0", 10);
const body = ir?.Content;
// Determine if content is text-based
const isText = this.isTextContent(contentType);
let content: string | undefined;
let contentBase64: string | undefined;
let truncated = false;
if (body) {
const maxSize = options?.maxSize || 10 * 1024 * 1024; // Default 10MB
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
if (buffer.length > maxSize) {
truncated = true;
const truncatedBuffer = buffer.subarray(0, maxSize);
if (isText) {
content = truncatedBuffer.toString("utf-8");
} else {
contentBase64 = truncatedBuffer.toString("base64");
}
} else {
if (isText) {
content = buffer.toString("utf-8");
} else {
contentBase64 = buffer.toString("base64");
}
}
}
return {
key,
content,
contentBase64,
contentType,
contentLength,
truncated,
lastModified: ir?.LastModified,
etag: ir?.ETag?.replace(/"/g, ""),
metadata: ir?.Metadata,
};
}
async getObjectMetadata(bucket: string, key: string): Promise<ObjectMetadata> {
const client = this.ensureClient();
const result = await client.getObjectMetadata({
Bucket: bucket,
Key: key,
});
if (result.CommonMsg.Status >= 300) {
throw new Error(`Failed to get object metadata: ${result.CommonMsg.Message}`);
}
const ir = result.InterfaceResult;
return {
key,
contentType: ir?.ContentType || "application/octet-stream",
contentLength: parseInt(ir?.ContentLength || "0", 10),
lastModified: ir?.LastModified,
etag: ir?.ETag?.replace(/"/g, ""),
storageClass: ir?.StorageClass,
metadata: ir?.Metadata,
};
}
async bucketExists(bucket: string): Promise<boolean> {
const client = this.ensureClient();
try {
const result = await client.headBucket({ Bucket: bucket });
return result.CommonMsg.Status < 300;
} catch {
return false;
}
}
async objectExists(bucket: string, key: string): Promise<boolean> {
const client = this.ensureClient();
try {
const result = await client.getObjectMetadata({
Bucket: bucket,
Key: key,
});
return result.CommonMsg.Status < 300;
} catch {
return false;
}
}
async searchObjects(bucket: string, filter: SearchFilter): Promise<SearchResult> {
const client = this.ensureClient();
const matchingObjects: ObjectInfo[] = [];
let continuationToken: string | undefined;
let hasMore = false;
const maxResults = filter.maxResults || 100;
// List all objects and filter
do {
const params: any = {
Bucket: bucket,
MaxKeys: 1000,
};
if (filter.prefix) {
params.Prefix = filter.prefix;
}
if (continuationToken) {
params.Marker = continuationToken;
}
const result = await client.listObjects(params);
if (result.CommonMsg.Status >= 300) {
throw new Error(`Failed to search objects: ${result.CommonMsg.Message}`);
}
const ir = result.InterfaceResult;
const objects = ir?.Contents || [];
for (const obj of objects) {
if (matchingObjects.length >= maxResults) {
hasMore = true;
break;
}
const objInfo: ObjectInfo = {
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified,
etag: obj.ETag?.replace(/"/g, ""),
storageClass: obj.StorageClass,
};
if (this.matchesFilter(objInfo, filter)) {
matchingObjects.push(objInfo);
}
}
const isTruncated = ir?.IsTruncated === "true" || ir?.IsTruncated === true;
if (!isTruncated || matchingObjects.length >= maxResults) {
break;
}
continuationToken = ir?.NextMarker || objects[objects.length - 1]?.Key;
} while (continuationToken);
return {
objects: matchingObjects,
totalCount: matchingObjects.length,
hasMore,
filter,
};
}
getSampleEndpoint(): string {
return "https://obs.cn-east-2.myhuaweicloud.com";
}
isValidEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return url.hostname.includes("obs") || url.hostname.includes("myhuaweicloud");
} catch {
return false;
}
}
/**
* Check if content type is text-based
*/
private isTextContent(contentType: string): boolean {
const textTypes = [
"text/",
"application/json",
"application/xml",
"application/javascript",
"application/typescript",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-python",
];
return textTypes.some((t) => contentType.startsWith(t) || contentType.includes(t));
}
/**
* Check if an object matches the search filter
*/
private matchesFilter(obj: ObjectInfo, filter: SearchFilter): boolean {
// Extension filter
if (filter.extensions && filter.extensions.length > 0) {
const ext = "." + obj.key.split(".").pop()?.toLowerCase();
if (!filter.extensions.some((e) => e.toLowerCase() === ext)) {
return false;
}
}
// Suffix filter
if (filter.suffix && !obj.key.endsWith(filter.suffix)) {
return false;
}
// Size filters
if (filter.minSize !== undefined && obj.size < filter.minSize) {
return false;
}
if (filter.maxSize !== undefined && obj.size > filter.maxSize) {
return false;
}
// Date filters
if (filter.modifiedAfter && obj.lastModified) {
if (new Date(obj.lastModified) < new Date(filter.modifiedAfter)) {
return false;
}
}
if (filter.modifiedBefore && obj.lastModified) {
if (new Date(obj.lastModified) > new Date(filter.modifiedBefore)) {
return false;
}
}
// Pattern filter (simple glob matching)
if (filter.pattern) {
const regex = this.globToRegex(filter.pattern);
if (!regex.test(obj.key)) {
return false;
}
}
return true;
}
/**
* Convert glob pattern to regex
*/
private globToRegex(glob: string): RegExp {
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`, "i");
}
}
// Register the provider
const obsProvider = new HuaweiOBSProvider();
ProviderRegistry.register(obsProvider);
export { HuaweiOBSProvider };