remotely-save/pro/src/fsAzureBlobStorage.ts
2024-06-22 21:03:43 +08:00

343 lines
9.5 KiB
TypeScript

import * as path from "path";
import {
AnonymousCredential,
BaseRequestPolicy,
type BlobGetPropertiesResponse,
BlobServiceClient,
BlobUploadCommonResponse,
BlockBlobClient,
ContainerClient,
newPipeline,
} from "@azure/storage-blob";
import type { Entity } from "../../src/baseTypes";
import { FakeFs } from "../../src/fsAll";
import { arrayBufferToHex, getFolderLevels } from "../../src/misc";
import type { AzureBlobStorageConfig } from "./baseTypesPro";
export const simpleTransRemotePrefix = (x: string) => {
if (x === undefined) {
return "";
}
let y = path.posix.normalize(x.trim());
if (y === undefined || y === "" || y === "/" || y === ".") {
return "";
}
if (y.startsWith("/")) {
y = y.slice(1);
}
if (!y.endsWith("/")) {
y = `${y}/`;
}
return y;
};
export const DEFAULT_AZUREBLOBSTORAGE_CONFIG: AzureBlobStorageConfig = {
containerSasUrl: "",
containerName: "",
remotePrefix: "",
generateFolderObject: false,
partsConcurrency: 5,
kind: "azureblobstorage",
};
const getNormPath = (fileOrFolderPath: string, remotePrefix: string) => {
if (remotePrefix.startsWith("/") || !remotePrefix.endsWith("/")) {
throw Error(
`remotePrefix should not have leading slash but should have tailing slash: ${remotePrefix}`
);
}
if (!fileOrFolderPath.startsWith(remotePrefix)) {
throw Error(`${fileOrFolderPath} does not start with ${remotePrefix}!`);
}
return fileOrFolderPath.slice(remotePrefix.length);
};
const getBlobPath = (fileOrFolderPath: string, remotePrefix: string) => {
if (remotePrefix.startsWith("/") || !remotePrefix.endsWith("/")) {
throw Error(
`remotePrefix should not have leading slash but should have tailing slash: ${remotePrefix}`
);
}
return `${remotePrefix}${fileOrFolderPath}`;
};
const fromBlobPropsToEntity = (
name: string,
props: BlobGetPropertiesResponse,
remotePrefix: string
): Entity => {
const key = getNormPath(name, remotePrefix);
let mtimeCli = props.lastModified!.valueOf();
const mtimeStr = props.metadata?.mtime;
if (mtimeStr !== undefined && mtimeStr !== "") {
try {
mtimeCli = new Date(mtimeStr).valueOf();
} catch {}
}
let hash: undefined | string = undefined;
if (props.contentMD5 !== undefined) {
hash = arrayBufferToHex(props.contentMD5.buffer);
}
const entity: Entity = {
key: key,
keyRaw: key,
mtimeCli: mtimeCli,
mtimeSvr: props.lastModified!.valueOf(),
size: props.contentLength ?? 0,
sizeRaw: props.contentLength ?? 0,
hash: hash,
};
if (key.endsWith("/")) {
entity.synthesizedFolder = false;
}
return entity;
};
export class FakeFsAzureBlobStorage extends FakeFs {
kind: string;
config: AzureBlobStorageConfig;
vaultName: string;
containerClient: ContainerClient;
remotePrefix: string;
synthFoldersCache: Record<string, Entity>;
constructor(config: AzureBlobStorageConfig, vaultName: string) {
super();
this.kind = "azureblobstorage";
this.config = config;
this.vaultName = vaultName;
this.synthFoldersCache = {};
this.remotePrefix = `${vaultName}/`;
const k = simpleTransRemotePrefix(this.config.remotePrefix);
if (k !== "") {
// we have prefix
this.remotePrefix = k;
}
this.containerClient = new ContainerClient(this.config.containerSasUrl);
}
async walk(): Promise<Entity[]> {
const entities: Entity[] = [];
const realEntities = new Set<string>();
for await (const blob of this.containerClient.listBlobsFlat({
prefix: this.remotePrefix,
includeMetadata: true,
})) {
const blockBlobClient = this.containerClient.getBlockBlobClient(
blob.name
);
const props = await blockBlobClient.getProperties();
// console.debug(blob.name)
const entity = fromBlobPropsToEntity(blob.name, props, this.remotePrefix);
entities.push(entity);
// so we need to fake the folders
realEntities.add(entity.key!);
for (const f of getFolderLevels(entity.key!, true)) {
if (realEntities.has(f)) {
delete this.synthFoldersCache[f];
continue;
}
if (
!this.synthFoldersCache.hasOwnProperty(f) ||
entity.mtimeSvr! >= this.synthFoldersCache[f].mtimeSvr!
) {
this.synthFoldersCache[f] = {
key: f,
keyRaw: f,
size: 0,
sizeRaw: 0,
sizeEnc: 0,
mtimeSvr: entity.mtimeSvr,
mtimeSvrFmt: entity.mtimeSvrFmt,
mtimeCli: entity.mtimeCli,
mtimeCliFmt: entity.mtimeCliFmt,
synthesizedFolder: true,
};
}
}
}
for (const key of Object.keys(this.synthFoldersCache)) {
entities.push(this.synthFoldersCache[key]);
}
return entities;
}
async walkPartial(): Promise<Entity[]> {
const entities: Entity[] = [];
for await (const blob of this.containerClient.listBlobsByHierarchy("/", {
prefix: this.remotePrefix,
includeMetadata: true,
})) {
if (blob.kind === "prefix") {
continue;
}
const blockBlobClient = this.containerClient.getBlockBlobClient(
blob.name
);
const props = await blockBlobClient.getProperties();
const entity = fromBlobPropsToEntity(blob.name, props, this.remotePrefix);
entities.push(entity);
}
return entities;
}
async stat(key: string): Promise<Entity> {
const remotePath = getBlobPath(key, this.remotePrefix);
const blockBlobClient = this.containerClient.getBlockBlobClient(remotePath);
const props = await blockBlobClient.getProperties();
const entity = fromBlobPropsToEntity(remotePath, props, this.remotePrefix);
return entity;
}
async mkdir(
key: string,
mtime?: number | undefined,
ctime?: number | undefined
): Promise<Entity> {
if (!key.endsWith("/")) {
throw new Error(`You should not call mkdir on ${key}!`);
}
const generateFolderObject = this.config.generateFolderObject ?? false;
if (!generateFolderObject) {
const synth = {
key: key,
keyRaw: key,
size: 0,
sizeRaw: 0,
sizeEnc: 0,
mtimeSvr: mtime,
mtimeCli: mtime,
synthesizedFolder: true,
};
this.synthFoldersCache[key] = synth;
return synth;
}
return await this.writeFile(
key,
new ArrayBuffer(0),
mtime ?? Date.now(),
ctime ?? Date.now()
);
}
async writeFile(
key: string,
content: ArrayBuffer,
mtime: number,
ctime: number
): Promise<Entity> {
const blobPath = getBlobPath(key, this.remotePrefix);
const blobClient = this.containerClient.getBlockBlobClient(blobPath);
const metadata: Record<string, string> = {
mtime: new Date(mtime).toISOString(),
ctime: new Date(ctime).toISOString(),
};
if (key.endsWith("/")) {
console.debug(`yeah we have folder upload`);
const generateFolderObject = this.config.generateFolderObject ?? false;
if (!generateFolderObject) {
throw Error(
`if not generate folder object, the func should not go here`
);
}
metadata["hdi_isfolder"] = "true";
}
const uploadResult = await blobClient.uploadData(content, {
metadata: metadata,
concurrency: this.config.partsConcurrency ?? 5,
});
if (key.endsWith("/")) {
console.debug(`yeah we have folder upload`);
console.debug(uploadResult);
}
if (uploadResult._response.status >= 300) {
throw Error(`upload ${key} failed with ${JSON.stringify(uploadResult)}`);
}
return await this.stat(key);
}
async readFile(key: string): Promise<ArrayBuffer> {
const blobPath = getBlobPath(key, this.remotePrefix);
const blobClient = this.containerClient.getBlockBlobClient(blobPath);
const rsp = await blobClient.download();
if (rsp._response.status >= 300) {
throw Error(`download ${key} failed with ${JSON.stringify(rsp)}`);
}
return await (await rsp.blobBody)!.arrayBuffer();
}
async rename(key1: string, key2: string): Promise<void> {
throw new Error("Method not implemented.");
}
async rm(key: string): Promise<void> {
const blobPath = getBlobPath(key, this.remotePrefix);
if (key.endsWith("/")) {
if (this.synthFoldersCache.hasOwnProperty(key)) {
delete this.synthFoldersCache[key];
}
// in blob the folder may not exist, so we make our best effort.
// do NOT read this.config.generateFolderObject
// because the folder might be generated by previous setting
try {
const blobClient = this.containerClient.getBlockBlobClient(blobPath);
await blobClient.deleteIfExists();
} catch (e) {}
} else {
// the file should really exist
const blobClient = this.containerClient.getBlockBlobClient(blobPath);
const rsp = await blobClient.deleteIfExists();
if (!rsp.succeeded) {
throw Error(
`something goes wrong while deleting ${key}: ${JSON.stringify(rsp)}`
);
}
}
}
async checkConnect(callbackFunc?: any): Promise<boolean> {
// if we can walk, we can connect
try {
await this.walkPartial();
return true;
} catch (err) {
console.debug(err);
callbackFunc?.(err);
return false;
}
}
async getUserDisplayName(): Promise<string> {
throw new Error("Method not implemented.");
}
async revokeAuth(): Promise<any> {
throw new Error("Method not implemented.");
}
allowEmptyFile(): boolean {
return true;
}
}