src/file.ts
import { PassThrough } from 'stream';
import Bucket from "./bucket";
import { BackblazeLibraryError } from "./errors";
import FileUploadStream from "./file-upload-stream";
/**
* Where sensible, Backblaze recommends these values to allow different B2 clients
* and the B2 web user interface to interoperate correctly
*
* The file name and file info must fit, along with the other necessary headers,
* within a 7,000 byte limit. This limit applies to the fully encoded HTTP header line,
* including the carriage-return and newline.
*
* See [Files] for further details about HTTP header size limit.
*
* [Files]: https://www.backblaze.com/b2/docs/files.htmlx
*/
export interface FileInfo {
/**
* The value should be a base 10 number which represents a UTC time when the
* original source file was last modified. It is a base 10 number of milliseconds
* since midnight, January 1, 1970 UTC. This fits in a 64 bit integer.
*/
src_last_modified_millis: string;
/**
* If this is present, B2 will use it as the value of the 'Content-Disposition' header
* when the file is downloaded (unless it's overridden by a value given in the download request).
* The value must match the grammar specified in RFC 6266.
* Parameter continuations are not supported.
*
* 'Extended-value's are supported for charset 'UTF-8' (case-insensitive) when the language is empty.
*
* Note that this file info will not be included in downloads as a x-bz-info-b2-content-disposition header.
* Instead, it (or the value specified in a request) will be in the Content-Disposition.
*/
"content-disposition"?: string;
/**
* If this is present, B2 will use it as the value of the 'Content-Language' header when the file
* is downloaded (unless it's overridden by a value given in the download request).
*
* The value must match the grammar specified in RFC 2616.
*
* Note that this file info will not be included in downloads as a x-bz-info-b2-content-language header.
*
* Instead, it (or the value specified in a request) will be in the Content-Language header.
*/
"content-language"?: string;
/**
* If this is present, B2 will use it as the value of the 'Expires' header when the file is downloaded
* (unless it's overridden by a value given in the download request).
*
* The value must match the grammar specified in RFC 2616.
*
* Note that this file info will not be included in downloads as a x-bz-info-b2-expires header.
* Instead, it (or the value specified in a request) will be in the Expires header.
*/
"b2-expires"?: string;
/**
* If this is present, B2 will use it as the value of the 'Cache-Control' header when the file is
* downloaded (unless it's overridden by a value given in the download request), and overriding
* the value defined at the bucket level.
*
* The value must match the grammar specified in RFC 2616.
*
* Note that this file info will not be included in downloads as a x-bz-info-cache-control header.
* Instead, it (or the value specified in a request) will be in the Cache-Control header.
*/
"b2-cache-control"?: string;
/**
* If this is present, B2 will use it as the value of the 'Content-Encoding' header when the file
* is downloaded (unless it's overridden by a value given in the download request).
*
* The value must match the grammar specified in RFC 2616.
*
* Note that this file info will not be included in downloads as a x-bz-info-b2-content-encoding header.
* Instead, it (or the value specified in a request) will be in the Content-Encoding header.
*/
"b2-content-encoding"?: string;
/**
* If this is present, B2 will use it as the value of the 'Content-Type' header when the file is downloaded
* (unless it's overridden by a value given in the download request).
*
* The value must match the grammar specified in RFC 2616.
*
* Note that this file info will not be included in downloads as a x-bz-info-b2-content-type header.
* Instead, it (or the value specified in a request) will be in the Content-Type header.
*/
"b2-content-type"?: string;
/**
* ## Custom headers:
*
* - Must use the format `X-Bz-Info-*` for the header name.
* - Up to 10 of these headers may be present.
* - The * part of the header name is replaced with the name of a custom field in the file
* information stored with the file, and the value is an arbitrary UTF-8 string, percent-encoded.
* - The same info headers sent with the upload will be returned with the download.
* - The header name is case insensitive.
*/
[key: string]: string | undefined;
}
export enum FileAction {
/** "start" means that a large file has been started, but not finished or canceled */
start = "start",
/** "upload" means a file that was uploaded to B2 Cloud Storage. */
upload = "upload",
/** "hide" means a file version marking the file as hidden, so that it will not show up in `b2_list_file_names`. */
hide = "hide",
/** "folder" is used to indicate a virtual folder when listing files. */
folder = "folder",
}
export interface FileData {
/** The account that owns the file. */
accountId: string;
action: FileAction;
/** The bucket that the file is in. */
bucketId: string;
/** The number of bytes stored in the file. Only useful when the action is "upload". Always 0 when the action is "start", "hide", or "folder". */
contentLength: number;
/**
* The SHA1 of the bytes stored in the file as a 40-digit hex string.
*
* Large files do not have SHA1 checksums, and the value is "none".
*
* The value is null when the action is "hide" or "folder".
*/
contentSha1: string | null;
/**
* When the action is "upload" or "start", the MIME type of the file,
* as specified when the file was uploaded.
*
* For "hide" action, always "application/x-bz-hide-marker".
*
* For "folder" action, always null.
*/
contentType: string | null;
/**
* The unique identifier for this version of this file.
*
* Used with b2_get_file_info, b2_download_file_by_id, and b2_delete_file_version.
*
* The value is null when for action "folder".
*/
fileId: string | null;
/**
* The custom information that was uploaded with the file.
*
* This is a JSON object, holding the name/value pairs that were uploaded with the file.
*/
fileInfo: Record<string, any>;
/** The name of this file, which can be used with `b2_download_file_by_name`. */
fileName: string;
/** This is a UTC time when this file was uploaded.
*
* It is a base 10 number of milliseconds since midnight, January 1, 1970 UTC.
* This fits in a 64 bit integer.
*
* Always 0 when the action is "folder".
*/
uploadTimestamp: string;
}
export interface FileUploadOptions {
/**
* The length of the file in bytes.
*
* Automatically calculated for `Buffer`s.
*
* Required in order to enable single-part uploads for streams.
*/
contentLength?: number;
/** We will calculate this if not passed */
sha1?: string;
contentType?: string;
fileInfo?: FileInfo;
maxRetries?: number;
backoff?: number;
}
type MinimumFileData = Partial<FileData> & { fileName: string };
export default class File {
private _bucket: Bucket;
private _fileData: MinimumFileData;
/** @internal */
constructor(bucket: Bucket, fileData: MinimumFileData) {
this._bucket = bucket;
this._fileData = fileData;
}
async getFileName() {
let { fileName } = this._fileData;
if (typeof fileName !== "undefined") return fileName;
return (await this.stat()).fileName;
}
/**
* When getting a file's ids by its `fileName`, this is a Class C transaction
* See https://www.backblaze.com/b2/cloud-storage-pricing.html
*/
async getFileId() {
let { fileId } = this._fileData;
if (typeof fileId !== "undefined" && fileId !== null) return fileId;
return (await this.stat()).fileId;
}
getBucketId() {
return this._bucket.getBucketId();
}
getBucketName() {
return this._bucket.getBucketName();
}
/** @internal */
get b2() {
return this._bucket.b2;
}
/**
* Gets file data by fileId or fileName.
*
* When stating a file without its `fileId`, this is a Class C transaction
* See https://www.backblaze.com/b2/cloud-storage-pricing.html
*
* @throws {@linkcode BackblazeLibraryError.FileNotFound} When a file is not found by name.
*/
stat(): Promise<FileData> {
if (typeof this._fileData.fileId !== "undefined" && this._fileData.fileId != null) {
return this._statById();
} else if (typeof this._fileData.fileName !== "undefined") {
return this._statByName();
} else {
throw new BackblazeLibraryError.BadUsage("To stat a file, you must provide either its fileId or fileName.")
}
}
private async _statById(): Promise<FileData> {
const res = await this.b2.callApi("b2_get_file_info", {
method: "POST",
body: JSON.stringify({
fileId: await this.getFileId()
})
});
return this._fileData = await res.json();
}
private async _statByName(): Promise<FileData> {
const {files: [fileData]} = await this._bucket._getFileDataBatch({ batchSize: 1, startFileName: this._fileData.fileName! });
if(typeof fileData === "undefined" || fileData.fileName !== this._fileData.fileName!)
throw new BackblazeLibraryError.FileNotFound("The file was not found.");
return this._fileData = fileData;
}
/**
* Download this file from B2.
*
* ```js
* const file = bucket.file("text.txt");
* file.createReadStream();
```
*/
createReadStream(): NodeJS.ReadableStream {
const stream = new PassThrough();
const { fileId, fileName } = this._fileData;
if (typeof fileId !== "undefined" && fileId != null) {
this.b2.callDownloadApi(
"b2_download_file_by_id?fileId=" + encodeURIComponent(fileId),
{}
).then((res) => {
res.body.on("error", stream.destroy)
res.body.pipe(stream);
});
} else if (typeof fileName !== "undefined") {
Promise.all([this.getBucketName()]).then(([bucketName]) =>
this.b2.requestFromDownloadFileByName(
bucketName,
fileName,
{}
)
).then((res) => {
res.body.on("error", stream.destroy)
res.body.pipe(stream);
});
} else {
throw new BackblazeLibraryError.BadUsage("To download a file, you must provide either its fileId or fileName.")
}
return stream;
}
/**
* Upload to this file on B2.
*
* This works by loading chunks of the stream, upto {@linkcode B2.partSize},
* into memory. If the stream has less than or equal to that many bytes, a
* single-part upload will be attempted.
*
* Otherwise, a multi-part upload will be attempted by loading up-to
* `b2.partSize` bytes of the stream into memory at a time.
*
* ```js
* const file = bucket.file("example");
* const stream = file.createWriteStream();
* stream.on("error", (err) => {
* // handle the error
* // note that retries are automatically attempted before errors are
* // thrown for most potentially recoverable errors, as per the B2 docs.
* })
* stream.on("finish", (err) => {
* // upload done, the file instance has been updated to reflect this
* })
* res.body.pipe(stream);
* ```
*/
createWriteStream(): FileUploadStream {
return new FileUploadStream(this);
}
/** @internal */
async _startMultipartUpload(options: FileUploadOptions): Promise<void> {
if (this._fileData.action === FileAction.upload) return;
const [bucketId, fileName] = await Promise.all([
this.getBucketId(),
this.getFileName(),
]);
const res = await this.b2.callApi("b2_start_large_file", {
method: "POST",
body: JSON.stringify({
bucketId,
fileName,
contentType: options.contentType || "application/octet-stream",
fileInfo: options.fileInfo,
}),
});
this._fileData = await res.json();
}
/** @internal */
async uploadSinglePart(
data: Buffer | NodeJS.ReadableStream,
options: FileUploadOptions & { contentLength: number }
) {
return this._bucket.uploadSinglePart(
await this.getFileName(),
data,
options
);
}
}