pcloud
This commit is contained in:
@@ -0,0 +1,607 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { requestUrl } from "obsidian";
|
||||
import pcloudSdk from "pcloud-sdk-js";
|
||||
import {
|
||||
type Entity,
|
||||
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||
} from "../../src/baseTypes";
|
||||
import { FakeFs } from "../../src/fsAll";
|
||||
import { getFolderLevels } from "../../src/misc";
|
||||
import {
|
||||
COMMAND_CALLBACK_PCLOUD,
|
||||
PCLOUD_CLIENT_ID,
|
||||
PCLOUD_CLIENT_SECRET,
|
||||
type PCloudConfig,
|
||||
} from "./baseTypesPro";
|
||||
|
||||
export const DEFAULT_PCLOUD_CONFIG: PCloudConfig = {
|
||||
accessToken: "",
|
||||
hostname: "eapi.pcloud.com",
|
||||
locationid: 2,
|
||||
credentialsShouldBeDeletedAtTimeMs: 0,
|
||||
emptyFile: "skip",
|
||||
kind: "pcloud",
|
||||
};
|
||||
|
||||
export interface AuthAllowFirstRes {
|
||||
code: string;
|
||||
state?: string;
|
||||
locationid: 1 | 2;
|
||||
hostname: "api.pcloud.com" | "eapi.pcloud.com";
|
||||
}
|
||||
|
||||
/**
|
||||
* https://docs.pcloud.com/methods/oauth_2.0/authorize.html
|
||||
*/
|
||||
export const generateAuthUrl = async (hasCallback: boolean) => {
|
||||
const clientID = PCLOUD_CLIENT_ID;
|
||||
const state = nanoid();
|
||||
let authUrl = `https://my.pcloud.com/oauth2/authorize?response_type=code&client_id=${clientID}&state=${state}`;
|
||||
if (hasCallback) {
|
||||
authUrl += `&redirect_uri=obsidian://${COMMAND_CALLBACK_PCLOUD}`;
|
||||
}
|
||||
return {
|
||||
authUrl,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
interface AuthResSucc {
|
||||
result: number;
|
||||
access_token: string;
|
||||
token_type: "bearer";
|
||||
uid: number;
|
||||
locationid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://docs.pcloud.com/methods/oauth_2.0/oauth2_token.html
|
||||
*/
|
||||
export const sendAuthReq = async (
|
||||
hostname: string,
|
||||
authCode: string,
|
||||
errorCallBack: any
|
||||
) => {
|
||||
const clientID = PCLOUD_CLIENT_ID ?? "";
|
||||
const clientSecret = PCLOUD_CLIENT_SECRET ?? "";
|
||||
try {
|
||||
const k = {
|
||||
code: authCode,
|
||||
client_id: clientID,
|
||||
client_secret: clientSecret,
|
||||
};
|
||||
// console.debug(k);
|
||||
const resp1 = await fetch(`https://${hostname}/oauth2_token`, {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(k),
|
||||
});
|
||||
const resp2: AuthResSucc = await resp1.json();
|
||||
// console.debug(resp2);
|
||||
if (resp2?.result !== 0) {
|
||||
throw Error(`result is not 0 (success) in the end`);
|
||||
}
|
||||
if (!("access_token" in resp2)) {
|
||||
throw Error(`no access_token in the end`);
|
||||
}
|
||||
return resp2;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (errorCallBack !== undefined) {
|
||||
await errorCallBack(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const setConfigBySuccessfullAuthInplace = async (
|
||||
config: PCloudConfig,
|
||||
authAllowFirstRes: AuthAllowFirstRes,
|
||||
authRes: AuthResSucc | undefined,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
if (authRes === undefined) {
|
||||
throw Error(`you should not save the setting for undefined result`);
|
||||
}
|
||||
|
||||
config.accessToken = authRes.access_token;
|
||||
config.hostname = authAllowFirstRes.hostname;
|
||||
config.locationid = authAllowFirstRes.locationid;
|
||||
|
||||
// manually set it expired after 80 days;
|
||||
config.credentialsShouldBeDeletedAtTimeMs =
|
||||
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
||||
|
||||
await saveUpdatedConfigFunc?.();
|
||||
|
||||
console.info("finish updating local info of pCloud token");
|
||||
};
|
||||
|
||||
interface PCloudEntity extends Entity {
|
||||
id: number;
|
||||
parentID: number | undefined;
|
||||
parentIDPath: string | undefined;
|
||||
isFolder: boolean;
|
||||
hashPCloud: number | undefined;
|
||||
}
|
||||
|
||||
interface File {
|
||||
contenttype: string;
|
||||
created: string;
|
||||
fileid: number;
|
||||
hash: number;
|
||||
id: string; // "f" + fileid
|
||||
isfolder: false;
|
||||
modified: string;
|
||||
name: string;
|
||||
parentfolderid: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Folder {
|
||||
contents: (Folder | File)[] | undefined;
|
||||
id: string; // "d"+folderid
|
||||
folderid: number;
|
||||
isfolder: true;
|
||||
created: string;
|
||||
modified: string;
|
||||
name: string;
|
||||
parentfolderid: number;
|
||||
}
|
||||
|
||||
interface StatRawResponse {
|
||||
result: number;
|
||||
fileids: number[];
|
||||
metadata: (File | Folder)[];
|
||||
checksums: { sha1: string; sha256?: string; md5?: string }[] | undefined;
|
||||
}
|
||||
|
||||
const fromRawResponseToEntity = (
|
||||
item: Folder | File,
|
||||
parentFolderPath: string | undefined /* for bfs */
|
||||
): PCloudEntity => {
|
||||
if (item.parentfolderid === undefined || item.parentfolderid === 0) {
|
||||
throw Error(
|
||||
`parentfolderid=${item.parentfolderid} should not be in fromRawResponseToEntity`
|
||||
);
|
||||
}
|
||||
|
||||
let keyRaw = item.name;
|
||||
let size = 0;
|
||||
let hashPCloud: number | undefined = undefined;
|
||||
let hash: string | undefined = undefined;
|
||||
let id: number | undefined = undefined;
|
||||
if (
|
||||
parentFolderPath !== undefined &&
|
||||
parentFolderPath !== "" &&
|
||||
parentFolderPath !== "/"
|
||||
) {
|
||||
if (!parentFolderPath.endsWith("/")) {
|
||||
throw Error(
|
||||
`parentFolderPath=${parentFolderPath} should not be in fromRawResponseToEntity`
|
||||
);
|
||||
}
|
||||
keyRaw = `${parentFolderPath}${item.name}`;
|
||||
}
|
||||
|
||||
if (item.isfolder) {
|
||||
keyRaw = `${keyRaw}/`;
|
||||
id = item.folderid;
|
||||
} else {
|
||||
size = item.size;
|
||||
hashPCloud = item.hash;
|
||||
hash = `${item.hash}`;
|
||||
id = item.fileid;
|
||||
}
|
||||
|
||||
const mtime = new Date(item.modified).valueOf();
|
||||
|
||||
return {
|
||||
key: keyRaw,
|
||||
keyRaw: keyRaw,
|
||||
mtimeCli: mtime,
|
||||
mtimeSvr: mtime,
|
||||
id: id,
|
||||
parentID: item.parentfolderid,
|
||||
isFolder: item.isfolder,
|
||||
size: size,
|
||||
sizeRaw: size,
|
||||
hash: hash,
|
||||
hashPCloud: hashPCloud,
|
||||
parentIDPath: parentFolderPath,
|
||||
};
|
||||
};
|
||||
|
||||
const fromNestedFolderToEntityListAndCache = (
|
||||
root: Folder
|
||||
): { entities: PCloudEntity[]; key2Entity: Record<string, PCloudEntity> } => {
|
||||
// console.debug("root:");
|
||||
// console.debug(root);
|
||||
|
||||
const entities: PCloudEntity[] = [];
|
||||
const key2Entity: Record<string, PCloudEntity> = {};
|
||||
|
||||
if (root.contents === undefined || root.contents.length === 0) {
|
||||
// console.debug(`early return`);
|
||||
return {
|
||||
entities,
|
||||
key2Entity,
|
||||
};
|
||||
}
|
||||
|
||||
let parents: {
|
||||
folderPath: string;
|
||||
itself: Folder | File;
|
||||
}[] = [];
|
||||
for (const f of root.contents ?? []) {
|
||||
parents.push({
|
||||
folderPath: "",
|
||||
itself: f,
|
||||
});
|
||||
}
|
||||
|
||||
while (parents.length !== 0) {
|
||||
const children: typeof parents = [];
|
||||
for (const { folderPath, itself } of parents) {
|
||||
if (itself.isfolder && itself.folderid === root.folderid) {
|
||||
// special, ignore root folder itself
|
||||
} else {
|
||||
const entity = fromRawResponseToEntity(itself, folderPath);
|
||||
entities.push(entity);
|
||||
key2Entity[entity.keyRaw] = entity;
|
||||
}
|
||||
|
||||
if (
|
||||
itself.isfolder &&
|
||||
itself.contents !== undefined &&
|
||||
itself.contents.length > 0
|
||||
) {
|
||||
for (const f of itself.contents) {
|
||||
if (folderPath === "" || folderPath === "/") {
|
||||
const child = {
|
||||
itself: f,
|
||||
folderPath: `${itself.name}/`,
|
||||
};
|
||||
children.push(child);
|
||||
} else {
|
||||
const child = {
|
||||
itself: f,
|
||||
folderPath: `${folderPath}${itself.name}/`,
|
||||
};
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parents = children;
|
||||
}
|
||||
|
||||
// console.debug("entities:");
|
||||
// console.debug(entities);
|
||||
// console.debug("key2Entity:");
|
||||
// console.debug(key2Entity);
|
||||
|
||||
return {
|
||||
entities,
|
||||
key2Entity,
|
||||
};
|
||||
};
|
||||
|
||||
export class FakeFsPCloud extends FakeFs {
|
||||
kind: string;
|
||||
|
||||
pCloudConfig: PCloudConfig;
|
||||
remoteBaseDir: string;
|
||||
vaultFolderExists: boolean;
|
||||
saveUpdatedConfigFunc: () => Promise<any>;
|
||||
|
||||
keyToPCloudEntity: Record<string, PCloudEntity>;
|
||||
baseDirID: number;
|
||||
|
||||
client: pcloudSdk.Client;
|
||||
|
||||
constructor(
|
||||
pCloudConfig: PCloudConfig,
|
||||
vaultName: string,
|
||||
saveUpdatedConfigFunc: () => Promise<any>
|
||||
) {
|
||||
super();
|
||||
this.kind = "pcloud";
|
||||
this.pCloudConfig = pCloudConfig;
|
||||
this.remoteBaseDir = this.pCloudConfig.remoteBaseDir || vaultName || "";
|
||||
this.vaultFolderExists = false;
|
||||
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||
this.keyToPCloudEntity = {};
|
||||
this.baseDirID = 0;
|
||||
|
||||
(global as any).locationid = pCloudConfig.locationid; // why?? pcloud, why??
|
||||
this.client = pcloudSdk.createClient(pCloudConfig.accessToken);
|
||||
}
|
||||
|
||||
async _init() {
|
||||
if (this.vaultFolderExists) {
|
||||
// pass
|
||||
} else {
|
||||
const root = (await this.client.listfolder(0, {
|
||||
recursive: false,
|
||||
})) as Folder;
|
||||
|
||||
// find?
|
||||
if (root.contents === undefined) {
|
||||
throw Error(`cannot listfolder of root!`);
|
||||
}
|
||||
const found = root.contents.filter(
|
||||
(x) => x.isfolder && x.name === this.remoteBaseDir
|
||||
);
|
||||
if (found.length > 0) {
|
||||
// we find it!
|
||||
const f = found[0] as Folder;
|
||||
this.baseDirID = f.folderid;
|
||||
this.vaultFolderExists = true;
|
||||
} else {
|
||||
// not found, let's create it!
|
||||
const f: Folder = await this.client.createfolder(this.remoteBaseDir, 0);
|
||||
// console.debug(f);
|
||||
this.baseDirID = f.folderid;
|
||||
this.vaultFolderExists = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _getAccessToken() {
|
||||
if (this.pCloudConfig.accessToken === "") {
|
||||
throw Error("The user has not manually auth yet.");
|
||||
}
|
||||
|
||||
return this.pCloudConfig.accessToken;
|
||||
|
||||
// TODO: no expire date?
|
||||
// https://docs.pcloud.com/methods/intro/authentication.html
|
||||
}
|
||||
|
||||
async walk(): Promise<Entity[]> {
|
||||
await this._init();
|
||||
const rsp = (await this.client.listfolder(this.baseDirID, {
|
||||
recursive: true,
|
||||
})) as Folder;
|
||||
|
||||
const { entities, key2Entity } = fromNestedFolderToEntityListAndCache(rsp);
|
||||
|
||||
this.keyToPCloudEntity = Object.assign(this.keyToPCloudEntity, key2Entity);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
async walkPartial(): Promise<Entity[]> {
|
||||
await this._init();
|
||||
const rsp = (await this.client.listfolder(this.baseDirID, {
|
||||
recursive: false,
|
||||
})) as Folder;
|
||||
const { entities, key2Entity } = fromNestedFolderToEntityListAndCache(rsp);
|
||||
|
||||
this.keyToPCloudEntity = Object.assign(this.keyToPCloudEntity, key2Entity);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
async stat(key: string): Promise<Entity> {
|
||||
await this._init();
|
||||
|
||||
// TODO: we already have a cache, should we call again?
|
||||
const cachedEntity = this.keyToPCloudEntity[key];
|
||||
const fileID = cachedEntity?.id;
|
||||
if (cachedEntity === undefined || fileID === undefined) {
|
||||
throw Error(`no fileID found for key=${key}`);
|
||||
}
|
||||
|
||||
// why? pcloud doesn't have stat api??
|
||||
return cachedEntity;
|
||||
}
|
||||
|
||||
async mkdir(
|
||||
key: string,
|
||||
mtime?: number | undefined,
|
||||
ctime?: number | undefined
|
||||
): Promise<Entity> {
|
||||
if (!key.endsWith("/")) {
|
||||
throw Error(`you should not mkdir on key=${key}`);
|
||||
}
|
||||
|
||||
await this._init();
|
||||
|
||||
const cachedEntity = this.keyToPCloudEntity[key];
|
||||
const fileID = cachedEntity?.id;
|
||||
if (cachedEntity !== undefined && fileID !== undefined) {
|
||||
return cachedEntity;
|
||||
}
|
||||
|
||||
// xxx/ => ["xxx"]
|
||||
// xxx/yyy/zzz/ => ["xxx", "xxx/yyy", "xxx/yyy/zzz"]
|
||||
const folderLevels = getFolderLevels(key);
|
||||
let parentFolderPath: string | undefined = undefined;
|
||||
let parentID: number | undefined = undefined;
|
||||
if (folderLevels.length === 0) {
|
||||
throw Error(`cannot getFolderLevels of ${key}`);
|
||||
} else if (folderLevels.length === 1) {
|
||||
parentID = this.baseDirID;
|
||||
parentFolderPath = ""; // ignore base dir
|
||||
} else {
|
||||
// length > 1
|
||||
parentFolderPath = `${folderLevels[folderLevels.length - 2]}/`;
|
||||
if (!(parentFolderPath in this.keyToPCloudEntity)) {
|
||||
throw Error(
|
||||
`parent of ${key}: ${parentFolderPath} is not created before??`
|
||||
);
|
||||
}
|
||||
parentID = this.keyToPCloudEntity[parentFolderPath].id;
|
||||
}
|
||||
|
||||
// xxx/yyy/zzz/ => ["xxx", "xxx/yyy", "xxx/yyy/zzz"] => "xxx/yyy/zzz" => "zzz"
|
||||
let folderItselfWithoutSlash = folderLevels[folderLevels.length - 1];
|
||||
folderItselfWithoutSlash = folderItselfWithoutSlash.split("/").pop()!;
|
||||
|
||||
const f = await this.client.createfolder(
|
||||
folderItselfWithoutSlash,
|
||||
parentID
|
||||
);
|
||||
|
||||
const entity = fromRawResponseToEntity(f, parentFolderPath);
|
||||
// insert into cache
|
||||
this.keyToPCloudEntity[key] = entity;
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* https://docs.pcloud.com/methods/file/uploadfile.html
|
||||
*/
|
||||
async writeFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
mtime: number,
|
||||
ctime: number
|
||||
): Promise<Entity> {
|
||||
if (key.endsWith("/")) {
|
||||
throw Error(`should not call writeFile on ${key}`);
|
||||
}
|
||||
|
||||
await this._init();
|
||||
|
||||
if (content.byteLength === 0) {
|
||||
if (this.pCloudConfig.emptyFile === "error") {
|
||||
throw Error(
|
||||
`${key}: Empty file is not allowed in pCloud, and please write something in it.`
|
||||
);
|
||||
} else {
|
||||
return {
|
||||
key: key,
|
||||
keyRaw: key,
|
||||
mtimeSvr: mtime,
|
||||
mtimeCli: mtime,
|
||||
size: 0,
|
||||
sizeRaw: 0,
|
||||
synthesizedFile: true,
|
||||
// hash: ?? // TODO
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevCachedEntity: PCloudEntity | undefined =
|
||||
this.keyToPCloudEntity[key];
|
||||
const prevFileID: number | undefined = prevCachedEntity?.id;
|
||||
|
||||
let parentID: number | undefined = undefined;
|
||||
let parentFolderPath: string | undefined = undefined;
|
||||
|
||||
// "xxx" => []
|
||||
// "xxx/yyy/zzz.md" => ["xxx", "xxx/yyy"]
|
||||
const folderLevels = getFolderLevels(key);
|
||||
if (folderLevels.length === 0) {
|
||||
// root
|
||||
parentID = this.baseDirID;
|
||||
parentFolderPath = "";
|
||||
} else {
|
||||
parentFolderPath = `${folderLevels[folderLevels.length - 1]}/`;
|
||||
if (!(parentFolderPath in this.keyToPCloudEntity)) {
|
||||
throw Error(
|
||||
`parent of ${key}: ${parentFolderPath} is not created before??`
|
||||
);
|
||||
}
|
||||
parentID = this.keyToPCloudEntity[parentFolderPath].id;
|
||||
}
|
||||
|
||||
const fileItself = key.split("/").pop()!;
|
||||
|
||||
// no idea how to use the sdk, let's use https here
|
||||
// https://docs.pcloud.com/methods/file/uploadfile.html
|
||||
const params = new URLSearchParams({
|
||||
access_token: await this._getAccessToken(),
|
||||
folderid: `${parentID}`,
|
||||
filename: fileItself,
|
||||
nopartial: `1`,
|
||||
renameifexists: `0`,
|
||||
mtime: `${Math.floor(mtime / 1000.0)}`,
|
||||
ctime: `${Math.floor(ctime / 1000.0)}`,
|
||||
});
|
||||
const apiUrl = `https://${this.pCloudConfig.hostname}/uploadfile?${params}`;
|
||||
|
||||
const rsp = await fetch(apiUrl, {
|
||||
method: "PUT",
|
||||
body: content,
|
||||
});
|
||||
const f: StatRawResponse = await rsp.json();
|
||||
const entity = fromRawResponseToEntity(f.metadata[0], parentFolderPath);
|
||||
// console.debug(entity);
|
||||
this.keyToPCloudEntity[key] = entity;
|
||||
return entity;
|
||||
}
|
||||
|
||||
async readFile(key: string): Promise<ArrayBuffer> {
|
||||
await this._init();
|
||||
const cachedEntity = this.keyToPCloudEntity[key];
|
||||
const fileID = cachedEntity?.id;
|
||||
if (cachedEntity === undefined || fileID === undefined) {
|
||||
throw Error(`no fileID found for key=${key}`);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
access_token: await this._getAccessToken(),
|
||||
forcedownload: `1`,
|
||||
fileid: `${fileID}`,
|
||||
});
|
||||
const urlMeta = `https://${this.pCloudConfig.hostname}/getfilelink?${params}`;
|
||||
|
||||
// Referrer is restricted to pcloud.com.
|
||||
// we need to bypass it
|
||||
const meta = (await requestUrl(urlMeta)).json;
|
||||
// console.debug(meta);
|
||||
const link: string = `https://${meta.hosts[0]}${meta.path}`;
|
||||
const rsp = await requestUrl(link);
|
||||
const content = rsp.arrayBuffer;
|
||||
return content;
|
||||
}
|
||||
|
||||
async rename(key1: string, key2: string): Promise<void> {
|
||||
await this._init();
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async rm(key: string): Promise<void> {
|
||||
await this._init();
|
||||
|
||||
const cachedEntity = this.keyToPCloudEntity[key];
|
||||
const fileID = cachedEntity?.id;
|
||||
if (cachedEntity === undefined || fileID === undefined) {
|
||||
throw Error(`no fileID found for key=${key}`);
|
||||
}
|
||||
|
||||
if (key.endsWith("/")) {
|
||||
await this.client.deletefolder(fileID);
|
||||
} else {
|
||||
await this.client.deletefile(fileID);
|
||||
}
|
||||
}
|
||||
|
||||
async checkConnect(callbackFunc?: any): Promise<boolean> {
|
||||
// if we can init, we can connect
|
||||
try {
|
||||
await this._init();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
callbackFunc?.(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(): Promise<string> {
|
||||
await this._init();
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async revokeAuth(): Promise<any> {
|
||||
await this._init();
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
allowEmptyFile(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user