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 | 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 } => { // console.debug("root:"); // console.debug(root); const entities: PCloudEntity[] = []; const key2Entity: Record = {}; 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; keyToPCloudEntity: Record; baseDirID: number; client: pcloudSdk.Client; constructor( pCloudConfig: PCloudConfig, vaultName: string, saveUpdatedConfigFunc: () => Promise ) { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await this._init(); throw new Error("Method not implemented."); } async rm(key: string): Promise { 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 { // 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 { await this._init(); throw new Error("Method not implemented."); } async revokeAuth(): Promise { await this._init(); throw new Error("Method not implemented."); } allowEmptyFile(): boolean { return false; } }