/**
* Alibaba Cloud OSS (Object Storage Service) Provider
*
* This provider implements the Provider interface for Alibaba Cloud OSS.
* SDK Repository: https://github.com/ali-sdk/ali-oss
* Documentation: https://help.aliyun.com/product/31815.html
*/
import OSS from "ali-oss";
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";
// Type helper for headers
type HeadersType = Record<string, string | number | undefined>;
/**
* Alibaba Cloud OSS Provider Implementation
*/
class AliyunOSSProvider implements Provider {
readonly id: ProviderType = "oss";
readonly name = "Alibaba Cloud OSS";
private client: OSS | null = null;
private sourceId: string = "";
private config: SourceConfig | null = null;
getId(): string {
return this.sourceId;
}
clone(): Provider {
return new AliyunOSSProvider();
}
async connect(config: SourceConfig): Promise<void> {
this.config = config;
this.sourceId = config.id;
// Initialize OSS client
this.client = new OSS({
accessKeyId: config.access_key,
accessKeySecret: config.secret_key,
endpoint: config.endpoint,
region: config.region,
bucket: config.default_bucket,
timeout: config.connection_timeout ? config.connection_timeout * 1000 : 60000,
secure: config.ssl !== false,
stsToken: config.security_token,
});
// Test connection by listing buckets
try {
await this.client.listBuckets({});
} catch (error) {
this.client = null;
throw new Error(`Failed to connect to OSS: ${(error as Error).message}`);
}
}
async disconnect(): Promise<void> {
this.client = null;
}
private ensureClient(): OSS {
if (!this.client) {
throw new Error("OSS client not connected. Call connect() first.");
}
return this.client;
}
/**
* Create a client for a specific bucket
*/
private getClientForBucket(bucket: string): OSS {
const client = this.ensureClient();
if (this.config?.default_bucket === bucket) {
return client;
}
// Clone client with different bucket
return new OSS({
accessKeyId: this.config!.access_key,
accessKeySecret: this.config!.secret_key,
endpoint: this.config!.endpoint,
region: this.config!.region,
bucket: bucket,
timeout: this.config!.connection_timeout ? this.config!.connection_timeout * 1000 : 60000,
secure: this.config!.ssl !== false,
stsToken: this.config!.security_token,
});
}
async listBuckets(): Promise<BucketInfo[]> {
const client = this.ensureClient();
const result = await client.listBuckets({});
// The result is an array of buckets directly or has a buckets property
const buckets = Array.isArray(result) ? result : (result as any).buckets || [];
return buckets.map((bucket: any) => ({
name: bucket.name,
creationDate: bucket.creationDate,
location: bucket.region || bucket.location,
}));
}
async listObjects(bucket: string, options?: ListObjectsOptions): Promise<ListObjectsResult> {
const client = this.getClientForBucket(bucket);
const params: OSS.ListObjectsQuery = {
"max-keys": String(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.list(params, {});
const objects: ObjectInfo[] = (result.objects || []).map((obj: any) => ({
key: obj.name,
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 = result.prefixes || [];
const resultAny = result as any;
return {
objects,
prefix: resultAny.prefix,
delimiter: resultAny.delimiter,
isTruncated: result.isTruncated || false,
nextContinuationToken: result.nextMarker,
commonPrefixes: commonPrefixes.length > 0 ? commonPrefixes : undefined,
keyCount: objects.length,
maxKeys: parseInt(String(params["max-keys"]) || "1000", 10),
};
}
async getObject(bucket: string, key: string, options?: GetObjectOptions): Promise<ObjectContent> {
const client = this.getClientForBucket(bucket);
const getOptions: any = {};
// Handle range request
if (options?.rangeStart !== undefined || options?.rangeEnd !== undefined) {
const start = options.rangeStart ?? 0;
const end = options.rangeEnd ?? "";
getOptions.headers = {
Range: `bytes=${start}-${end}`,
};
}
const result = await client.get(key, undefined as any, getOptions);
const headers = result.res.headers as HeadersType;
const contentType = String(headers["content-type"] || mime.lookup(key) || "application/octet-stream");
const contentLength = parseInt(String(headers["content-length"] || "0"), 10);
const body = result.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");
}
}
}
const etag = headers["etag"];
return {
key,
content,
contentBase64,
contentType,
contentLength,
truncated,
lastModified: headers["last-modified"] ? String(headers["last-modified"]) : undefined,
etag: etag ? String(etag).replace(/"/g, "") : undefined,
metadata: this.extractMetadata(headers),
};
}
async getObjectMetadata(bucket: string, key: string): Promise<ObjectMetadata> {
const client = this.getClientForBucket(bucket);
const result = await client.head(key);
const headers = result.res.headers as HeadersType;
const etag = headers["etag"];
return {
key,
contentType: String(headers["content-type"] || "application/octet-stream"),
contentLength: parseInt(String(headers["content-length"] || "0"), 10),
lastModified: headers["last-modified"] ? String(headers["last-modified"]) : undefined,
etag: etag ? String(etag).replace(/"/g, "") : undefined,
storageClass: headers["x-oss-storage-class"] ? String(headers["x-oss-storage-class"]) : undefined,
cacheControl: headers["cache-control"] ? String(headers["cache-control"]) : undefined,
contentDisposition: headers["content-disposition"] ? String(headers["content-disposition"]) : undefined,
contentEncoding: headers["content-encoding"] ? String(headers["content-encoding"]) : undefined,
metadata: this.extractMetadata(headers),
};
}
async bucketExists(bucket: string): Promise<boolean> {
const client = this.ensureClient();
try {
await client.getBucketInfo(bucket);
return true;
} catch {
return false;
}
}
async objectExists(bucket: string, key: string): Promise<boolean> {
const client = this.getClientForBucket(bucket);
try {
await client.head(key);
return true;
} catch {
return false;
}
}
async searchObjects(bucket: string, filter: SearchFilter): Promise<SearchResult> {
const client = this.getClientForBucket(bucket);
const matchingObjects: ObjectInfo[] = [];
let continuationToken: string | undefined;
let hasMore = false;
const maxResults = filter.maxResults || 100;
// List all objects and filter
do {
const params: OSS.ListObjectsQuery = {
"max-keys": "1000",
};
if (filter.prefix) {
params.prefix = filter.prefix;
}
if (continuationToken) {
params.marker = continuationToken;
}
const result = await client.list(params, {});
const objects = result.objects || [];
for (const obj of objects) {
if (matchingObjects.length >= maxResults) {
hasMore = true;
break;
}
const objInfo: ObjectInfo = {
key: obj.name,
size: obj.size,
lastModified: obj.lastModified,
etag: obj.etag?.replace(/"/g, ""),
storageClass: obj.storageClass,
};
if (this.matchesFilter(objInfo, filter)) {
matchingObjects.push(objInfo);
}
}
if (!result.isTruncated || matchingObjects.length >= maxResults) {
break;
}
continuationToken = result.nextMarker || objects[objects.length - 1]?.name;
} while (continuationToken);
return {
objects: matchingObjects,
totalCount: matchingObjects.length,
hasMore,
filter,
};
}
getSampleEndpoint(): string {
return "https://oss-cn-hangzhou.aliyuncs.com";
}
isValidEndpoint(endpoint: string): boolean {
try {
const url = new URL(endpoint);
return url.hostname.includes("oss") || url.hostname.includes("aliyuncs");
} catch {
return false;
}
}
/**
* Extract custom metadata from headers
*/
private extractMetadata(headers: HeadersType): Record<string, string> {
const metadata: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (key.startsWith("x-oss-meta-") && value !== undefined) {
metadata[key.replace("x-oss-meta-", "")] = String(value);
}
}
return metadata;
}
/**
* 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 ossProvider = new AliyunOSSProvider();
ProviderRegistry.register(ossProvider);
export { AliyunOSSProvider };