diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61dee2bf..a0f29546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,4 +37,6 @@ jobs: - name: test run: make i test - name: build + env: + NODE_OPTIONS: "--max_old_space_size=4096" run: make mac diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a240bf8..3afefc18 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -107,6 +107,8 @@ jobs: - name: test run: make i test - name: build + env: + NODE_OPTIONS: "--max_old_space_size=4096" run: make win64 win32 - name: Upload Release Asset to Qiniu run: | @@ -159,6 +161,8 @@ jobs: - name: test run: make i test - name: build + env: + NODE_OPTIONS: "--max_old_space_size=4096" run: make mac - name: Upload Release Asset to Qiniu run: | diff --git a/Readme.md b/Readme.md index 86622094..1d5eedd7 100755 --- a/Readme.md +++ b/Readme.md @@ -162,6 +162,7 @@ kodo-browser/ * `maxDownloadJobConcurrency`,最大下载任务并发数 * `disable`,禁止某些功能; * `nonOwnedDomain`,非自有域名; +* `baseShareUrl`,分享链接的基本 URL; 例如以下配置将修改默认登录私有云指定服务端: diff --git a/create-desktop-file.sh b/create-desktop-file.sh new file mode 100644 index 00000000..e248fa04 --- /dev/null +++ b/create-desktop-file.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e +WORKING_DIR=$(pwd) +THIS_PATH=$(readlink -f "$0") +THIS_BASE_PATH=$(dirname "${THIS_PATH}") +cd "$THIS_BASE_PATH" +FULL_PATH=$(pwd) +cd "${WORKING_DIR}" +cat < "kodo-browser.desktop" +[Desktop Entry] +Name=Kodo Browser +Comment=Kodo Browser for Linux +Exec="${FULL_PATH}/Kodo Browser" %U +Terminal=false +Type=Application +MimeType=x-scheme-handler/kodobrowser +Icon=${FULL_PATH}/resources/app/renderer/static/brand/qiniu.png +Categories=Utility;Development; +EOS +chmod +x "Kodo Browser.desktop" diff --git a/gulpfile.js b/gulpfile.js index 21888014..a365bbad 100755 --- a/gulpfile.js +++ b/gulpfile.js @@ -16,6 +16,7 @@ const ELECTRON_VERSION = "18.3.3"; const ROOT = __dirname; // https://github.com/qiniu/kodo-browser/issues/135 const WIN_NO_SANDBOX_NAME = "no-sandbox-shortcut.cmd"; +const LINUX_DESKTOP_FILE = "create-desktop-file.sh"; const BRAND = `${ROOT}/src/renderer/static/brand`; const DIST = `${ROOT}/dist`; const TARGET = `${ROOT}/build`; @@ -51,6 +52,13 @@ gulp.task("mac", done => { options.arch = "x64"; options.icon = `${BRAND}/qiniu.icns`; + options.protocols = [{ + name: 'com.qiniu.browser', + schemes: [ + 'kodo-browser' + ] + }]; + packager(options).then((paths) => { console.log("--done"); done(); @@ -163,13 +171,18 @@ gulp.task("win32zip", done => { gulp.task("linux64", done => { console.log(`--package ${NAME}-linux-x64`); + const targetDir = `${TARGET}/${NAME}-linux-x64`; - plugins.run(`rm -rf ${TARGET}/${NAME}-linux-x64`).exec(() => { + plugins.run(`rm -rf ${targetDir}`).exec(() => { let options = Object.assign({}, packagerOptions); options.platform = "linux"; options.arch = "x64"; packager(options).then((paths) => { + fs.copyFileSync( + path.resolve(ROOT, `./${LINUX_DESKTOP_FILE}`), + path.resolve(targetDir, `./${LINUX_DESKTOP_FILE}`) + ); console.log("--done"); done(); }, (errs) => { @@ -190,13 +203,18 @@ gulp.task("linux64zip", done => { gulp.task("linux32", done => { console.log(`--package ${NAME}-linux-ia32`); + const targetDir = `${TARGET}/${NAME}-linux-ia32`; - plugins.run(`rm -rf ${TARGET}/${NAME}-linux-ia32`).exec(() => { + plugins.run(`rm -rf ${targetDir}`).exec(() => { let options = Object.assign({}, packagerOptions); options.platform = "linux"; options.arch = "ia32"; packager(options).then((paths) => { + fs.copyFileSync( + path.resolve(ROOT, `./${LINUX_DESKTOP_FILE}`), + path.resolve(targetDir, `./${LINUX_DESKTOP_FILE}`) + ); console.log("--done"); done(); }, (errs) => { diff --git a/package.json b/package.json index 6adb7182..dac779c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kodo-browser", - "version": "2.2.0", + "version": "2.3.0", "license": "Apache-2.0", "author": { "name": "Rong Zhou", @@ -111,7 +111,7 @@ "form-data": "^4.0.0", "js-base64": "^3.4.5", "js-md5": "^0.7.3", - "kodo-s3-adapter-sdk": "0.5.1", + "kodo-s3-adapter-sdk": "0.6.0", "lockfile": "^1.0.4", "lodash": "^4.17.21", "mime": "^2.3.1", diff --git a/src/common/const/str.ts b/src/common/const/str.ts new file mode 100644 index 00000000..cb2f1f69 --- /dev/null +++ b/src/common/const/str.ts @@ -0,0 +1,5 @@ +export const ALPHABET_UPPERCASE = "abcdefghijklmnopqrstuvwxyz"; +export const ALPHABET_LOWERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +export const ALPHABET = ALPHABET_UPPERCASE + ALPHABET_LOWERCASE; +export const DIGITS = "0123456789"; +export const ALPHANUMERIC = ALPHABET + DIGITS; diff --git a/src/common/ipc-actions/deep-link.ts b/src/common/ipc-actions/deep-link.ts new file mode 100644 index 00000000..e73d64c6 --- /dev/null +++ b/src/common/ipc-actions/deep-link.ts @@ -0,0 +1,95 @@ +import {Sender} from "./types"; + +export enum DeepLinkAction { + RendererReady = "RendererReady", + RendererClose = "RendererClose", + SignInWithShareLink = "SignInWithShareLink", + SignInWithShareSession = "SignInWithShareSession", + SignInDataInvalid = "SignInDataInvalid", +} + +export interface DeepLinkSignInWithShareLinkMessage { + action: DeepLinkAction.SignInWithShareLink, + data: { + portalHost?: string, + shareId: string, + shareToken: string, + extractCode?: string, + }, +} + +export interface DeepLinkSignInWithShareSessionMessage { + action: DeepLinkAction.SignInWithShareSession, + data: { + accessKey: string, + accessSecret: string, + description?: string, + + sessionToken: string, + bucketName: string, + bucketId: string, + regionS3Id: string, + endpoint: string, + prefix: string, + permission: 'READONLY' | 'READWRITE', + expires: string, + }, +} + +export interface DeepLinkSignInDataInvalidMessage { + action: DeepLinkAction.SignInDataInvalid, +} + +export interface DeepLinkRendererReadyMessage { + action: DeepLinkAction.RendererReady, +} + +export interface DeepLinkRendererCloseMessage { + action: DeepLinkAction.RendererClose, +} + +export type DeepLinkMessage = DeepLinkSignInWithShareLinkMessage + | DeepLinkSignInWithShareSessionMessage + | DeepLinkSignInDataInvalidMessage + | DeepLinkRendererReadyMessage + | DeepLinkRendererCloseMessage; + +export class DeepLinkActionFns { + constructor( + private readonly sender: Sender, + private readonly channel: string, + ) { + } + + signInWithShareLink(data: DeepLinkSignInWithShareLinkMessage["data"]) { + this.sender.send(this.channel, { + action: DeepLinkAction.SignInWithShareLink, + data, + }); + } + + signInWithShareSession(data: DeepLinkSignInWithShareSessionMessage["data"]) { + this.sender.send(this.channel, { + action: DeepLinkAction.SignInWithShareSession, + data, + }); + } + + signInDataInvalid() { + this.sender.send(this.channel, { + action: DeepLinkAction.SignInDataInvalid, + }); + } + + rendererReady() { + this.sender.send(this.channel, { + action: DeepLinkAction.RendererReady, + }); + } + + rendererClose() { + this.sender.send(this.channel, { + action: DeepLinkAction.RendererClose, + }); + } +} diff --git a/src/common/ipc-actions/download.ts b/src/common/ipc-actions/download.ts index 3d37025d..321ee589 100644 --- a/src/common/ipc-actions/download.ts +++ b/src/common/ipc-actions/download.ts @@ -1,4 +1,3 @@ -import {IpcRenderer} from "electron"; import {NatureLanguage} from "kodo-s3-adapter-sdk/dist/uplog"; import {Domain} from "kodo-s3-adapter-sdk/dist/adapter"; @@ -7,6 +6,8 @@ import {Status} from "@common/models/job/types"; import DownloadJob from "@common/models/job/download-job"; import StorageClass from "@common/models/storage-class"; +import {Sender} from "./types"; + export interface RemoteObject { name: string, region: string, @@ -70,7 +71,10 @@ export interface UpdateConfigMessage { export interface LoadPersistJobsMessage { action: DownloadAction.LoadPersistJobs, data: { - clientOptions: Pick + clientOptions: Pick< + ClientOptionsSerialized, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + > downloadOptions: Pick, } } @@ -207,104 +211,104 @@ export type DownloadReplyMessage = UpdateUiDataReplyMessage export class DownloadActionFns { constructor( - private readonly ipc: IpcRenderer, + private readonly sender: Sender, private readonly channel: string, ) { } updateConfig(data: UpdateConfigMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.UpdateConfig, data: data, }); } loadPersistJobs(data: LoadPersistJobsMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.LoadPersistJobs, data, }) } addJobs(data: AddJobsMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.AddJobs, data, }); } updateUiData(data: UpdateUiDataMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.UpdateUiData, data, }); } waitJob(data: WaitJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.WaitJob, data, }); } startJob(data: StartJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StartJob, data, }); } stopJob(data: StopJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StopJob, data, }); } removeJob(data: RemoveJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.RemoveJob, data, }); } cleanUpJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.CleanupJobs, data: {}, }); } startAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StartAllJobs, data: {}, }); } stopAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StopAllJobs, data: {}, }); } stopJobsByOffline() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StopJobsByOffline, data: {}, }); } startJobsByOnline() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.StartJobsByOnline, data: {}, }); } removeAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: DownloadAction.RemoveAllJobs, data: {}, }); diff --git a/src/common/ipc-actions/types.ts b/src/common/ipc-actions/types.ts new file mode 100644 index 00000000..2aba2bf1 --- /dev/null +++ b/src/common/ipc-actions/types.ts @@ -0,0 +1,3 @@ +export interface Sender { + send(channel: string, message: T): void, +} diff --git a/src/common/ipc-actions/upload.ts b/src/common/ipc-actions/upload.ts index ba92c626..1b6cd563 100644 --- a/src/common/ipc-actions/upload.ts +++ b/src/common/ipc-actions/upload.ts @@ -1,4 +1,3 @@ -import {IpcRenderer} from "electron"; import {NatureLanguage} from "kodo-s3-adapter-sdk/dist/uplog"; import {ClientOptionsSerialized} from "@common/qiniu"; @@ -6,6 +5,8 @@ import StorageClass from "@common/models/storage-class"; import UploadJob from "@common/models/job/upload-job"; import {Status} from "@common/models/job/types"; +import {Sender} from "./types"; + // some types maybe should in models export interface DestInfo { regionId: string, @@ -63,7 +64,10 @@ export interface UpdateConfigMessage { export interface LoadPersistJobsMessage { action: UploadAction.LoadPersistJobs, data: { - clientOptions: Pick, + clientOptions: Pick< + ClientOptionsSerialized, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + >, uploadOptions: Pick, }, } @@ -212,104 +216,104 @@ export type UploadReplyMessage = UpdateUiDataReplyMessage // send actions functions export class UploadActionFns { constructor( - private readonly ipc: IpcRenderer, + private readonly sender: Sender, private readonly channel: string, ) { } updateConfig(data: UpdateConfigMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.UpdateConfig, data, }); } loadPersistJobs(data: LoadPersistJobsMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.LoadPersistJobs, data, }); } addJobs(data: AddJobsMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.AddJobs, data, }); } updateUiData(data: UpdateUiDataMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.UpdateUiData, data, }); } waitJob(data: WaitJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.WaitJob, data, }); } startJob(data: StartJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StartJob, data, }); } stopJob(data: StopJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StopJob, data, }); } removeJob(data: RemoveJobMessage["data"]) { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.RemoveJob, data, }); } cleanUpJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.CleanupJobs, data: {}, }); } startAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StartAllJobs, data: {}, }); } stopAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StopAllJobs, data: {}, }); } stopJobsByOffline() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StopJobsByOffline, data: {}, }); } startJobsByOnline() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.StartJobsByOnline, data: {}, }); } removeAllJobs() { - this.ipc.send(this.channel, { + this.sender.send(this.channel, { action: UploadAction.RemoveAllJobs, data: {}, }); diff --git a/src/common/qiniu/create-client.ts b/src/common/qiniu/create-client.ts index 609371db..57fecfe8 100644 --- a/src/common/qiniu/create-client.ts +++ b/src/common/qiniu/create-client.ts @@ -1,5 +1,6 @@ import {Qiniu, Region} from "kodo-s3-adapter-sdk"; import {Adapter, RequestInfo, ResponseInfo} from "kodo-s3-adapter-sdk/dist/adapter"; +import {S3} from "kodo-s3-adapter-sdk/dist/s3"; import {ModeOptions} from "kodo-s3-adapter-sdk/dist/qiniu"; import {NatureLanguage} from "kodo-s3-adapter-sdk/dist/uplog"; @@ -16,6 +17,7 @@ export default function createQiniuClient( const qiniu = new Qiniu( clientOptions.accessKey, clientOptions.secretKey, + clientOptions.sessionToken, clientOptions.ucUrl, `Kodo-Browser/${AppConfig.app.version}/ioutil`, clientOptions.regions.map(r => { @@ -37,10 +39,16 @@ export default function createQiniuClient( modeOptions.requestCallback = debugRequest(clientOptions.backendMode); modeOptions.responseCallback = debugResponse(clientOptions.backendMode); } - return qiniu.mode( + const adapter = qiniu.mode( clientOptions.backendMode, modeOptions, ); + if (adapter instanceof S3 && clientOptions.bucketNameId) { + Object.entries(clientOptions.bucketNameId).forEach(([n, id]) => { + adapter.addBucketNameIdCache(n, id); + }); + } + return adapter; } diff --git a/src/common/qiniu/types.ts b/src/common/qiniu/types.ts index 28d047a2..a628c281 100644 --- a/src/common/qiniu/types.ts +++ b/src/common/qiniu/types.ts @@ -1,26 +1,28 @@ import {Region} from "kodo-s3-adapter-sdk"; export enum BackendMode { - Kodo = "kodo", - S3 = "s3", + Kodo = "kodo", + S3 = "s3", } interface ClientOptionsBase { accessKey: string, secretKey: string, + sessionToken?: string, + bucketNameId?: Record, ucUrl: string, backendMode: BackendMode, } -export interface ClientOptions extends ClientOptionsBase{ +export interface ClientOptions extends ClientOptionsBase { regions: Region[], } -export interface ClientOptionsSerialized extends ClientOptionsBase{ - regions: { - id: string, - s3Id: string, - label: string, - s3Urls: string[], - }[], +export interface ClientOptionsSerialized extends ClientOptionsBase { + regions: { + id: string, + s3Id: string, + label: string, + s3Urls: string[], + }[], } diff --git a/src/common/utility-types.ts b/src/common/utility-types.ts index 0cd03cbc..22bddbbc 100644 --- a/src/common/utility-types.ts +++ b/src/common/utility-types.ts @@ -36,3 +36,7 @@ export declare type PathValue> = T extends any : never : never; + +export declare type OptionalProps = Omit & Partial>; + +export declare type RequireProps = Omit & Required>; diff --git a/src/main/deep-links/handler.test.ts b/src/main/deep-links/handler.test.ts new file mode 100644 index 00000000..4e310077 --- /dev/null +++ b/src/main/deep-links/handler.test.ts @@ -0,0 +1,110 @@ +import {MockedObjectDeep} from "ts-jest/dist/utils/testing"; +import {mocked} from "ts-jest/utils"; + +import {Sender} from "@common/ipc-actions/types"; +import {DeepLinkMessage} from "@common/ipc-actions/deep-link"; + +import {SignInHandler} from "./handler"; + +describe("SignInHandler", () => { + let handler: SignInHandler; + let mockSender: MockedObjectDeep>; + + beforeEach(() => { + mockSender = mocked({ + send: jest.fn() + }, true); + handler = new SignInHandler(mockSender, "mockedChannel"); + }); + + describe("handleSignInWithShareLink", () => { + it("should call SignInWithShareLink with correct parameters", () => { + handler.handle("kodo-browser://signIn/shareLink?id=123&token=abc&code=def"); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInWithShareLink", + data: { + shareId: "123", + shareToken: "abc", + extractCode: "def", + portalHost: "", + }, + }); + }); + + it("should call SignInDataInvalid if id or token is lost", () => { + handler.handle("kodo-browser://signIn/shareLink?token=abc"); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInDataInvalid", + }) + }); + }); + + describe("handleSignInWithShareSession", () => { + it("should call SignInWithShareSession with correct parameters", () => { + const data = { + federated_ak: "ak", + federated_sk: "sk", + session_token: "token", + bucket_name: "bucket", + bucket_id: "id", + region: "region", + endpoint: "endpoint", + prefix: "prefix", + permission: "permission", + expires: "expires", + }; + const encodedData = Buffer.from(JSON.stringify(data)).toString("base64"); + handler.handle(`kodo-browser://signIn/shareSession?data=${encodedData}`); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInWithShareSession", + data: { + accessKey: "ak", + accessSecret: "sk", + sessionToken: "token", + bucketName: "bucket", + bucketId: "id", + regionS3Id: "region", + endpoint: "endpoint", + prefix: "prefix", + permission: "permission", + expires: "expires", + }, + }); + }); + + it("should call SignInDataInvalid if data is not valid base64", () => { + handler.handle("kodo-browser://signIn/shareSession?data=invalid"); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInDataInvalid", + }); + }); + + it("should call SignInDataInvalid if data is not valid JSON", () => { + const encodedData = Buffer.from("invalid").toString("base64"); + handler.handle(`kodo-browser://signIn/shareSession?data=${encodedData}`); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInDataInvalid", + }); + }); + + it("should call SignInDataInvalid if any required field is lost", () => { + const data = { + federated_ak: "ak", + federated_sk: "sk", + session_token: "token", + bucket_name: "bucket", + bucket_id: "id", + region: "region", + endpoint: "endpoint", + prefix: "prefix", + permission: "permission", + // lost expires + }; + const encodedData = Buffer.from(JSON.stringify(data)).toString("base64"); + handler.handle(`kodo-browser://signIn/shareSession?data=${encodedData}`); + expect(mockSender.send).toHaveBeenCalledWith("mockedChannel", { + action: "SignInDataInvalid", + }); + }); + }); +}); diff --git a/src/main/deep-links/handler.ts b/src/main/deep-links/handler.ts new file mode 100644 index 00000000..52a7c2b8 --- /dev/null +++ b/src/main/deep-links/handler.ts @@ -0,0 +1,117 @@ +import {VerifyShareResult} from "kodo-s3-adapter-sdk/dist/share-service"; + +import {Sender} from "@common/ipc-actions/types"; +import {DeepLinkActionFns, DeepLinkMessage, DeepLinkSignInWithShareSessionMessage} from "@common/ipc-actions/deep-link"; + +export interface Handler { + handle(href: string): void, +} + +export interface HandlerConstructable { + new(sender: Sender, channel: string): Handler; + Host: string; +} + +export class SignInHandler implements Handler { + static Host = "signIn"; + + private deepLinkActionFns: DeepLinkActionFns + + constructor(sender: Sender, channel: string) { + this.deepLinkActionFns = new DeepLinkActionFns(sender, channel); + } + + handle(href: string): void { + const action = this.getActionName(href); + + switch (action) { + case "shareLink": + this.handleSignInWithShareLink(href); + break; + case "shareSession": + this.handleSignInWithShareSession(href); + break; + } + } + + private getActionName(href: string): string { + const url = new URL(href); + const [, action] = url.pathname.split("/", 2); + return action; + } + + private handleSignInWithShareLink(href: string) { + // kodobrowser://signIn/shareLink?id={id}&token={token}[&portalHost={portalHost}][&code={code}] + const url = new URL(href); + + const portalHost = url.searchParams.get("portalHost") || ""; + const id = url.searchParams.get("id") || ""; + const token = url.searchParams.get("token") || ""; + const code = url.searchParams.get("code") || undefined; + + if (!id || !token) { + this.deepLinkActionFns.signInDataInvalid(); + return; + } + + this.deepLinkActionFns.signInWithShareLink({ + portalHost, + shareId: id, + shareToken: token, + extractCode: code, + }); + } + + private handleSignInWithShareSession(href: string) { + // kodobrowser://signIn/shareSession?data={data} + // data is the verify response, which is encoded by url-safe base64 + let data: ( + VerifyShareResult + & { + description?: string, + } + ) | null; + try { + const base64Data = (new URL(href).searchParams.get("data") || "") + .replaceAll('/', '_') + .replaceAll('+', '-'); + data = JSON.parse( + Buffer.from( + base64Data, + "base64" + ) + .toString() + ); + } catch { + data = null; + } + if (data === null) { + this.deepLinkActionFns.signInDataInvalid(); + return; + } + + const messageData: DeepLinkSignInWithShareSessionMessage["data"] = { + accessKey: data.federated_ak, + accessSecret:data.federated_sk, + + sessionToken: data.session_token, + bucketName: data.bucket_name, + bucketId: data.bucket_id, + regionS3Id: data.region, + endpoint: data.endpoint, + prefix: data.prefix, + permission: data.permission, + expires: data.expires, + }; + if (Object.values(messageData).some(v => !v)) { + this.deepLinkActionFns.signInDataInvalid(); + return; + } + + if (data.description) { + messageData.description = data.description; + } + + this.deepLinkActionFns.signInWithShareSession(messageData); + } +} diff --git a/src/main/deep-links/index.ts b/src/main/deep-links/index.ts new file mode 100644 index 00000000..5621a06a --- /dev/null +++ b/src/main/deep-links/index.ts @@ -0,0 +1,3 @@ +import {DeepLinkRegister} from "./register"; + +export const deepLinkRegister = new DeepLinkRegister(); diff --git a/src/main/deep-links/register.ts b/src/main/deep-links/register.ts new file mode 100644 index 00000000..4cc07198 --- /dev/null +++ b/src/main/deep-links/register.ts @@ -0,0 +1,83 @@ +import path from "path"; + +import {app} from "electron"; + +import {Handler, HandlerConstructable, SignInHandler} from "./handler"; +import {Sender} from "@common/ipc-actions/types"; + +const SCHEME_NAME = "kodobrowser"; + +export class DeepLinkRegister { + private handlers: Record = {}; + private pendingDeepLink?: string; + + constructor( + private handlerTypes: HandlerConstructable[] = [ + SignInHandler, + ], + ) { + } + + initialize() { + switch (process.platform) { + case "darwin": + app.on("open-url", (_event, url) => { + if (url && url.startsWith(SCHEME_NAME)) { + this.handleDeepLink(url); + } + }); + break; + case "win32": + case "linux": + const lastArg = process.argv.pop(); + if (lastArg && lastArg.startsWith(SCHEME_NAME)) { + this.handleDeepLink(lastArg); + } + app.on('second-instance', (_event, commandLine, _workingDirectory) => { + const url = commandLine.pop(); + if (url && url.startsWith(SCHEME_NAME)) { + this.handleDeepLink(url); + } + }); + break; + } + + app.once("ready", () => { + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(SCHEME_NAME, process.execPath, [path.resolve(process.argv[1])]); + } + } else { + app.setAsDefaultProtocolClient(SCHEME_NAME); + } + }); + } + + enable(sender: Sender, channel = "DeepLinks") { + // deep link enabling; + this.handlerTypes.forEach(t => { + this.handlers[t.Host] = new t(sender, channel); + }); + if (this.pendingDeepLink) { + // handle pending link + this.handleDeepLink(this.pendingDeepLink); + this.pendingDeepLink = undefined; + } + } + + disable() { + this.handlers = {}; + } + + handleDeepLink(href: string) { + if (!Object.keys(this.handlers).length) { + // no handlers, pending + this.pendingDeepLink = href; + return; + } + + const url = new URL(href); + // handle link by appropriate handler + this.handlers[url.host]?.handle(href); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index e2dc388c..a2ecdba2 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,8 +14,17 @@ import { UplogBuffer } from "kodo-s3-adapter-sdk/dist/uplog"; import {UploadAction, UploadReplyMessage} from "@common/ipc-actions/upload"; import {DownloadAction, DownloadReplyMessage} from "@common/ipc-actions/download"; +import {DeepLinkAction} from "@common/ipc-actions/deep-link"; +import {deepLinkRegister} from "./deep-links"; + +//singleton +const singleInstanceLock = app.requestSingleInstanceLock(); +if (!singleInstanceLock) { + app.quit(); +} electronRemote.initialize(); +deepLinkRegister.initialize(); ///***************************************** const root = path.dirname(__dirname); @@ -42,23 +51,20 @@ case "win32": break; } -//singleton -app.requestSingleInstanceLock(); // Someone tried to run a second instance, we should focus our window. app.on('second-instance', (_evt, _argv, _cwd) => { if (win) { if (win.isMinimized()) { win.restore(); } - win.focus(); - - app.quit(); } }); -app.releaseSingleInstanceLock(); let createWindow = () => { + if (!singleInstanceLock) { + return; + } let opt = { width: 1280, height: 800, @@ -334,6 +340,19 @@ ipcMain.on("asynchronous", (_event, data) => { } }); +const DEEP_LINK_IPC_CHANNEL = "DeepLink"; + +ipcMain.on(DEEP_LINK_IPC_CHANNEL, (event, data) => { + switch (data.action) { + case DeepLinkAction.RendererReady: + deepLinkRegister.enable(event.sender, DEEP_LINK_IPC_CHANNEL); + break; + case DeepLinkAction.RendererClose: + deepLinkRegister.disable(); + break; + } +}) + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. @@ -365,6 +384,10 @@ app.on("window-all-closed", () => { //} }); +process.on("exit", () => { + app.releaseSingleInstanceLock(); +}) + // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here. function setMenu() { diff --git a/src/main/kv-store/data-store.ts b/src/main/kv-store/data-store.ts index ef6d6a7e..d607858a 100644 --- a/src/main/kv-store/data-store.ts +++ b/src/main/kv-store/data-store.ts @@ -393,9 +393,25 @@ export async function getDataStoreOrCreate({ wal: frameMeta.memTableReadonlyWALPath, }) : null; - diskTable = new DiskTable({ - filePath: frameMeta.diskTableFilePath, - }); + let isFileExists: boolean = false; + if (frameMeta.diskTableFilePath) { + isFileExists = await fsPromises.access( + frameMeta.diskTableFilePath, + fsConstants.F_OK, + ) + .then(() => true) + .catch(() => false); + } + if (!isFileExists) { + diskTable = await createDiskTable({ + filePath: frameMeta.diskTableFilePath, + values: new Set<[string, T]>().values(), + }); + } else { + diskTable = new DiskTable({ + filePath: frameMeta.diskTableFilePath, + }); + } } else { const tables = await initDataStore(workingDirectory); memTable = tables.memTable; diff --git a/src/main/transfer-managers/download-manager.ts b/src/main/transfer-managers/download-manager.ts index b8fe340a..7bf67f86 100644 --- a/src/main/transfer-managers/download-manager.ts +++ b/src/main/transfer-managers/download-manager.ts @@ -98,7 +98,10 @@ export default class DownloadManager extends TransferManager, + clientOptions: Pick< + ClientOptions, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + >, downloadOptions: Pick, ): Promise { const persistStore = await this.getPersistStore(); @@ -122,7 +125,10 @@ export default class DownloadManager extends TransferManager, + clientOptions: Pick< + ClientOptions, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + >, downloadOptions: Pick, ): Promise { if (this.jobs.get(jobId)) { diff --git a/src/main/transfer-managers/upload-manager.ts b/src/main/transfer-managers/upload-manager.ts index 56d03e71..2416a29f 100644 --- a/src/main/transfer-managers/upload-manager.ts +++ b/src/main/transfer-managers/upload-manager.ts @@ -270,7 +270,10 @@ export default class UploadManager extends TransferManager { } async loadJobsFromStorage( - clientOptions: Pick, + clientOptions: Pick< + ClientOptions, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + >, uploadOptions: Pick, ): Promise { const abortSignal = this.addingAbortController.signal; @@ -299,7 +302,10 @@ export default class UploadManager extends TransferManager { private async loadJob( jobId: string, persistedJob: UploadJob["persistInfo"], - clientOptions: Pick, + clientOptions: Pick< + ClientOptions, + "accessKey" | "secretKey" | "sessionToken" | "bucketNameId" | "ucUrl" | "regions" + >, uploadOptions: Pick, ): Promise { if (!persistedJob.from) { diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 21456b12..ae2e5082 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -11,10 +11,12 @@ import RoutePath from "@renderer/pages/route-path"; import NotFound from "@renderer/pages/exceptions/not-found"; import SignIn from "@renderer/pages/sign-in"; import Browse from "@renderer/pages/browse"; +import BrowseShare from "@renderer/pages/browse-share"; import SignOut from "@renderer/pages/sign-out"; import SwitchUser from "@renderer/pages/switch-user"; import {KodoAddress} from "@renderer/modules/kodo-address"; +import DeepLinkActions from "@renderer/components/deep-link-actions"; const App: React.FC = () => { const location = useLocation(); @@ -37,8 +39,10 @@ const App: React.FC = () => { }/> + }/> }/> + { + const signInState: ISignInState = { + type: "shareLink", + data, + }; + navigate(RoutePath.SignIn, { + state: signInState, + }); + }; + + const navToSignOut = (signInData: ISignInState["data"]) => { + const signOutState: SignOutState = { + type: "signInState", + data: { + type: "shareLink", + data: signInData, + }, + }; + navigate(RoutePath.SignOut, { + state: signOutState, + }); + }; + + const navToSwitchUser = (switchUser: SwitchUserStateSession["data"]) => { + const switchUserState: SwitchUserState = { + type: "shareSession", + data: switchUser, + }; + navigate(RoutePath.SwitchUser, { + state: switchUserState, + }); + }; + + // modal state + const [ + { + show: isSignOutConfirmModal, + data: confirmData, + }, + { + showModal: handleSignOutConfirmModal, + hideModal: handleCloseSignOutConfirmModal, + }, + ] = useDisplayModal(null); + const handleSignOutConfirmOk = () => { + if (confirmData?.navType === "signIn") { + navToSignOut(confirmData.navData); + } else if (confirmData?.navType === "switchUser") { + navToSwitchUser(confirmData.navData); + } + }; + + // use IPC + useIpcDeepLink({ + onSignInDataInvalid: () => { + toast.error(translate("deepLinkActions.signIn.invalidParams")); + }, + onSignInWithShareLink: (data) => { + const navData: ISignInState["data"] = { + portalHost: data.portalHost, + shareId: data.shareId, + shareToken: data.shareToken, + extractCode: data.extractCode, + }; + + if (!currentUser) { + navToSignIn(navData); + return; + } + + handleSignOutConfirmModal({ + navType: "signIn", + navData, + }); + }, + onSignInWithShareSession: (data) => { + const navData: SwitchUserStateSession["data"] = { + akItem: { + accessKey: data.accessKey, + accessSecret: data.accessSecret, + description: data.description, + }, + session: { + sessionToken: data.sessionToken, + endpoint: data.endpoint, + bucketId: data.bucketId, + bucketName: data.bucketName, + expires: data.expires, + permission: data.permission, + prefix: data.prefix, + regionS3Id: data.regionS3Id, + }, + }; + + if (!currentUser) { + navToSwitchUser(navData); + return; + } + + handleSignOutConfirmModal({ + navType: "switchUser", + navData, + }); + }, + }); + + return ( +
+ handleCloseSignOutConfirmModal(null)} + title={translate("deepLinkActions.signIn.signOutConfirm.title")} + content={translate("deepLinkActions.signIn.signOutConfirm.description")} + okText={translate("common.ok")} + onOk={handleSignOutConfirmOk} + /> +
+ ); +} + +export default DeepLinkActions; diff --git a/src/renderer/components/kodo-address-bar/index.tsx b/src/renderer/components/kodo-address-bar/index.tsx index f061b409..d6e978eb 100644 --- a/src/renderer/components/kodo-address-bar/index.tsx +++ b/src/renderer/components/kodo-address-bar/index.tsx @@ -23,6 +23,7 @@ const KodoAddressBar: React.FC = ({ currentAddress, addressHistory, currentIndex, + lockPrefix, goBack, goForward, goTo, @@ -107,7 +108,7 @@ const KodoAddressBar: React.FC = ({ onClick={goForward} /> = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/components/modals/bucket/delete-bucket/index.tsx b/src/renderer/components/modals/bucket/delete-bucket/index.tsx index 9a3e3982..2ec4a8f2 100644 --- a/src/renderer/components/modals/bucket/delete-bucket/index.tsx +++ b/src/renderer/components/modals/bucket/delete-bucket/index.tsx @@ -5,7 +5,7 @@ import {toast} from "react-hot-toast"; import {BackendMode} from "@common/qiniu"; import {Translate, useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {deleteBucket} from "@renderer/modules/qiniu-client"; import * as AuditLog from "@renderer/modules/audit-log"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -58,7 +58,7 @@ const DeleteBucket: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/components/modals/bucket/update-bucket-remark/index.tsx b/src/renderer/components/modals/bucket/update-bucket-remark/index.tsx index 4670c7dd..8e7cb017 100644 --- a/src/renderer/components/modals/bucket/update-bucket-remark/index.tsx +++ b/src/renderer/components/modals/bucket/update-bucket-remark/index.tsx @@ -5,7 +5,7 @@ import {toast} from "react-hot-toast"; import {SubmitHandler, useForm} from "react-hook-form"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {BucketItem, updateBucketRemark} from "@renderer/modules/qiniu-client"; interface UpdateBucketRemarkProps { @@ -60,7 +60,7 @@ const UpdateBucketRemark: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: true, // S3 hasn't the remark API }; const p = updateBucketRemark( diff --git a/src/renderer/components/modals/external-path/add-external-path/index.tsx b/src/renderer/components/modals/external-path/add-external-path/index.tsx index 5ec068ac..4ff8e376 100644 --- a/src/renderer/components/modals/external-path/add-external-path/index.tsx +++ b/src/renderer/components/modals/external-path/add-external-path/index.tsx @@ -6,7 +6,7 @@ import {toast} from "react-hot-toast"; import {ADDR_KODO_PROTOCOL} from "@renderer/const/kodo-nav"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import * as AuditLog from "@renderer/modules/audit-log"; import {useExternalPath} from "@renderer/modules/user-config-store"; import {listFiles} from "@renderer/modules/qiniu-client"; @@ -92,7 +92,7 @@ const AddExternalPath: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: true, maxKeys: 1, minKeys: 0, diff --git a/src/renderer/components/modals/file/change-file-storage-class/index.tsx b/src/renderer/components/modals/file/change-file-storage-class/index.tsx index e12027da..82f3353d 100644 --- a/src/renderer/components/modals/file/change-file-storage-class/index.tsx +++ b/src/renderer/components/modals/file/change-file-storage-class/index.tsx @@ -7,7 +7,7 @@ import StorageClass from "@common/models/storage-class"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {FileItem, setStorageClass} from "@renderer/modules/qiniu-client"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -78,7 +78,7 @@ const ChangeFileStorageClass: React.FC const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/change-files-storage-class/index.tsx b/src/renderer/components/modals/file/change-files-storage-class/index.tsx index 7406ca67..e3af2109 100644 --- a/src/renderer/components/modals/file/change-files-storage-class/index.tsx +++ b/src/renderer/components/modals/file/change-files-storage-class/index.tsx @@ -7,7 +7,7 @@ import StorageClass from "@common/models/storage-class"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import { FileItem, setStorageClassOfFiles, @@ -106,7 +106,7 @@ const ChangeFilesStorageClass: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; @@ -177,7 +177,7 @@ const CopyFiles: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/create-dir-share-link/index.tsx b/src/renderer/components/modals/file/create-dir-share-link/index.tsx new file mode 100644 index 00000000..a58a8564 --- /dev/null +++ b/src/renderer/components/modals/file/create-dir-share-link/index.tsx @@ -0,0 +1,447 @@ +import {clipboard} from "electron"; + +import React, {ChangeEventHandler, Fragment, useEffect, useMemo, useState} from "react"; +import {Button, Form, InputGroup, Modal, ModalProps, Spinner} from "react-bootstrap"; +import {toast} from "react-hot-toast"; +import {SubmitHandler, useForm} from "react-hook-form"; +import moment from "moment"; +import {DEFAULT_PORTAL_URL} from "kodo-s3-adapter-sdk/dist/region"; + +import Duration from "@common/const/duration"; +import {ALPHANUMERIC} from "@common/const/str"; +import {Alphanumeric} from "@renderer/const/patterns"; + +import {Translate, useI18n} from "@renderer/modules/i18n"; +import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {createShare, FileItem, getShareApiHosts} from "@renderer/modules/qiniu-client"; +import * as DefaultDict from "@renderer/modules/default-dict"; + +const MAX_EXPIRE_AFTER_SECONDS = 7200; + +interface CreateDirShareLinkProps { + bucketName: string, + fileItem: FileItem.Folder | null, +} + +interface CreateDirShareLinkFormData { + expireAfter: number, // seconds + extractCode: string, +} + +function genAlphanumericCode(len: number): string { + return "0".repeat(len) + .split("") + .map(() => { + const i = Math.floor(Math.random() * ALPHANUMERIC.length); + return ALPHANUMERIC[i]; + }) + .join(""); +} + +const CreateDirShareLink: React.FC = (props) => { + const { + regionId, + bucketName, + fileItem, + ...modalProps + } = props; + + const {translate} = useI18n(); + const {currentUser} = useAuth(); + + // cache operation states prevent props update after modal opened. + const { + memoFileItem, + memoBucketName, + } = useMemo(() => ({ + memoFileItem: fileItem, + memoBucketName: bucketName, + }), [modalProps.show]); + + + // form state + const maxExpireAfterSeconds = DefaultDict.get("MAX_SHARE_DIRECTORY_EXPIRE_AFTER_SECONDS") ?? MAX_EXPIRE_AFTER_SECONDS; + const minExpireAfterSeconds = 15 * Duration.Minute / Duration.Second; + const { + handleSubmit, + register, + reset, + setValue, + getValues, + formState: { + errors, + isSubmitting, + }, + } = useForm({ + mode: "onChange", + }); + + let defaultExpireAfterPreset = Duration.Hour / Duration.Second; + if (defaultExpireAfterPreset >= maxExpireAfterSeconds) { + defaultExpireAfterPreset = -1; + } + const [expireAfterPreset, setExpireAfterPreset] = useState(defaultExpireAfterPreset); + const handleSelectExpireAfter: ChangeEventHandler = (event) => { + const expireAfter = parseInt(event.target.value); + setExpireAfterPreset(expireAfter); + if (expireAfter > 0) { + setValue( + "expireAfter", + expireAfter, + { + shouldValidate: true, + }, + ); + } + } + + // result state + const [shareLink, setShareLink] = useState("") + const [expiredAt, setExpiredAt] = useState(""); + + // handlers + const handleRandomExtractCode = () => { + setValue( + "extractCode", + genAlphanumericCode(6), + { + shouldValidate: true, + }, + ); + }; + + const handleSubmitCreateShareLink: SubmitHandler = async (data) => { + if (!memoFileItem || !currentUser) { + return; + } + + let apiUrls: string[] = []; + const customShareUrl = currentUser.endpointType === EndpointType.Private + ? DefaultDict.get("BASE_SHARE_URL") + : undefined; + if (customShareUrl) { + const parsedShareUrl = new URL(customShareUrl); + let customApiUrls: string[] = []; + try { + customApiUrls = await getShareApiHosts([parsedShareUrl.origin]); + } catch (err: any) { + toast.error(err.toString()); + return; + } + apiUrls = apiUrls.concat(customApiUrls); + } + + const p = createShare( + { + bucket: memoBucketName, + prefix: memoFileItem?.path.toString(), + durationSeconds: data.expireAfter, + extractCode: data.extractCode, + permission: "READONLY", + }, + { + apiUrls, + accessKey: currentUser.accessKey, + accessSecret: currentUser.accessSecret, + endpointType: currentUser.endpointType, + }, + ); + + p.then(shareInfo => { + const baseShareUrl = customShareUrl || `${DEFAULT_PORTAL_URL}/kodo-shares/verify`; + const shareURL = new URL(baseShareUrl); + shareURL.searchParams.set("id", shareInfo.id); + shareURL.searchParams.set("token", shareInfo.token); + setShareLink(shareURL.toString()); + setExpiredAt(shareInfo.expires); + }); + + return toast.promise(p, { + loading: translate("common.submitting"), + success: translate("common.submitted"), + error: err => `${translate("common.failed")}: ${err}`, + }); + }; + + const handleCopyShareLink = () => { + const text = translate( + "modals.createDirectoryShareLink.shareMessage", + { + shareLink, + extractCode: getValues("extractCode"), + expiredAt: moment(expiredAt).format("YYYY-MM-DD HH:mm:ss"), + }, + ); + clipboard.writeText(text); + toast.success(translate("modals.createDirectoryShareLink.copyShareMessageSuccess")); + } + + // hooks for modal show/close + useEffect(() => { + if (modalProps.show) { + const expireAfterDefaultValue = Duration.Hour / Duration.Second; + reset({ + expireAfter: expireAfterDefaultValue, + extractCode: genAlphanumericCode(6), + }); + setExpireAfterPreset(expireAfterDefaultValue); + } else { + setShareLink(""); + setExpiredAt(""); + } + }, [modalProps.show]); + + // render + const renderExpireAfterOption = ({ + value, + unit, + unitDesc + }: { + value: number, + unit: Duration, + unitDesc: string, + }) => { + const v = value * unit / Duration.Second; + return ( + + ); + }; + + const renderContent = () => { + if (!memoFileItem) { + return ( +
+ {translate("common.noObjectSelected")} +
+ ); + } + + if (shareLink) { + return ( +
+
+ + + {translate("modals.createDirectoryShareLink.form.shareLink.label")} + + + + + + {translate("modals.createDirectoryShareLink.form.extractCode.label")} + + + + + + {translate("modals.createDirectoryShareLink.form.expiredAt.label")} + + + +
+
+ ); + } + + return ( +
+
+ + + {translate("modals.createDirectoryShareLink.form.directoryName.label")} + +
+ +
+
+ + + {translate("modals.createDirectoryShareLink.form.expireAfter.label")} + +
+ + + + { + [ + { + value: 15, + unit: Duration.Minute, + unitDesc: translate("common.minutes"), + }, + { + value: 30, + unit: Duration.Minute, + unitDesc: translate("common.minutes"), + }, + { + value: 1, + unit: Duration.Hour, + unitDesc: translate("common.hour"), + }, + { + value: 2, + unit: Duration.Hour, + unitDesc: translate("common.hours"), + }, + ].filter(opt => (opt.value * opt.unit) / Duration.Second <= maxExpireAfterSeconds) + .map(opt => renderExpireAfterOption(opt)) + } + + { + expireAfterPreset > 0 + ? null + : <> + + + {translate("modals.createDirectoryShareLink.form.expireAfter.suffix")} + + + } + + + + +
+
+ + + {translate("modals.createDirectoryShareLink.form.extractCode.label")} + +
+ + v.length === 6 && Alphanumeric.test(v), + })} + onFocus={event => event.target.select()} + type="text" + isInvalid={Boolean(errors.extractCode)} + /> + + + + {translate("modals.createDirectoryShareLink.form.extractCode.hint")} + +
+
+
+ + ); + } + + const renderFooter = () => { + return ( + <> + { + !shareLink + ? + : + } + + + ); + }; + + return ( + + + + + {translate("modals.createDirectoryShareLink.title")} + + + + {renderContent()} + + + {renderFooter()} + + + ); +}; + +export default CreateDirShareLink; diff --git a/src/renderer/components/modals/file/create-directory-file/index.tsx b/src/renderer/components/modals/file/create-directory-file/index.tsx index e1e21268..378fc34f 100644 --- a/src/renderer/components/modals/file/create-directory-file/index.tsx +++ b/src/renderer/components/modals/file/create-directory-file/index.tsx @@ -7,7 +7,7 @@ import * as qiniuPathConvertor from "qiniu-path/dist/src/convert"; import {BackendMode} from "@common/qiniu"; import {DirectoryName} from "@renderer/const/patterns"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {useI18n} from "@renderer/modules/i18n"; import {createFolder} from "@renderer/modules/qiniu-client"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -77,7 +77,7 @@ const CreateDirectoryFile: React.FC = (pr const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, } diff --git a/src/renderer/components/modals/file/delete-files/index.tsx b/src/renderer/components/modals/file/delete-files/index.tsx index 01f386c6..4865211b 100644 --- a/src/renderer/components/modals/file/delete-files/index.tsx +++ b/src/renderer/components/modals/file/delete-files/index.tsx @@ -6,7 +6,7 @@ import StorageClass from "@common/models/storage-class"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {deleteFiles, FileItem, stopDeleteFiles} from "@renderer/modules/qiniu-client"; import {useFileOperation} from "@renderer/modules/file-operation"; import * as AuditLog from "@renderer/modules/audit-log"; @@ -91,7 +91,7 @@ const DeleteFiles: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: storageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/generate-file-link/index.tsx b/src/renderer/components/modals/file/generate-file-link/index.tsx index 481eff2b..3d16f559 100644 --- a/src/renderer/components/modals/file/generate-file-link/index.tsx +++ b/src/renderer/components/modals/file/generate-file-link/index.tsx @@ -6,19 +6,19 @@ import lodash from "lodash"; import {BackendMode} from "@common/qiniu" import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; -import {FileItem, signatureUrl} from "@renderer/modules/qiniu-client"; +import {useAuth} from "@renderer/modules/auth"; +import {FileItem, getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; import {DomainAdapter, NON_OWNED_DOMAIN, useLoadDomains} from "@renderer/modules/qiniu-client-hooks"; import {useFileOperation} from "@renderer/modules/file-operation"; import { DEFAULT_EXPIRE_AFTER, - GenerateLinkFormData, - GenerateLinkForm, DomainNameField, ExpireAfterField, FileLinkField, FileNameField, + GenerateLinkForm, + GenerateLinkFormData, } from "@renderer/components/forms/generate-link-form"; interface GenerateFileLinkProps { @@ -96,23 +96,28 @@ const GenerateFileLink: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3 || data.domain?.apiScope === BackendMode.S3, }; - const domain = data.domain?.name === NON_OWNED_DOMAIN.name - ? undefined - : data.domain; + const apiDomain = data.domain?.name === NON_OWNED_DOMAIN.name ? undefined : data.domain; + + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode, + currentEndpointType: currentUser.endpointType, + }); return signatureUrl( memoRegionId, memoBucketName, memoFileItem.path.toString(), - domain, + apiDomain, data.expireAfter, + style, opt, ) .then(fileUrl => { diff --git a/src/renderer/components/modals/file/generate-file-links/index.tsx b/src/renderer/components/modals/file/generate-file-links/index.tsx index 4302e49a..098ab109 100644 --- a/src/renderer/components/modals/file/generate-file-links/index.tsx +++ b/src/renderer/components/modals/file/generate-file-links/index.tsx @@ -15,8 +15,8 @@ import {BackendMode} from "@common/qiniu" import usePortal from "@renderer/modules/hooks/use-portal"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; -import {FileItem, signatureUrls} from "@renderer/modules/qiniu-client"; +import {useAuth} from "@renderer/modules/auth"; +import {FileItem, getStyleForSignature, signatureUrls} from "@renderer/modules/qiniu-client"; import {DomainAdapter, NON_OWNED_DOMAIN, useLoadDomains} from "@renderer/modules/qiniu-client-hooks"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -160,21 +160,31 @@ const GenerateFileLinks: React.FC = (props) const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3 || domain?.apiScope === BackendMode.S3, }; + + const apiDomain = domain?.name === NON_OWNED_DOMAIN.name + ? undefined + : domain; + + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode, + currentEndpointType: currentUser.endpointType, + }); + return signatureUrls( memoRegionId, memoBucketName, memoFileItems, - domain?.name === NON_OWNED_DOMAIN.name - ? undefined - : domain, + apiDomain, expireAfter, + style, (progress) => { setBatchProgressState({ total: progress.total, diff --git a/src/renderer/components/modals/file/move-files/index.tsx b/src/renderer/components/modals/file/move-files/index.tsx index 0b584611..34653dd6 100644 --- a/src/renderer/components/modals/file/move-files/index.tsx +++ b/src/renderer/components/modals/file/move-files/index.tsx @@ -8,7 +8,7 @@ import {BackendMode} from "@common/qiniu"; import {FileRename} from "@renderer/const/patterns"; import {Translate, useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import { moveOrCopyFiles, FileItem, @@ -157,7 +157,7 @@ const MoveFiles: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; @@ -177,7 +177,7 @@ const MoveFiles: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/rename-file/index.tsx b/src/renderer/components/modals/file/rename-file/index.tsx index a4ba66db..f9410d47 100644 --- a/src/renderer/components/modals/file/rename-file/index.tsx +++ b/src/renderer/components/modals/file/rename-file/index.tsx @@ -8,7 +8,7 @@ import {BackendMode} from "@common/qiniu"; import {FileRename} from "@renderer/const/patterns"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import { checkFileOrDirectoryExists, FileItem, @@ -115,7 +115,7 @@ const RenameFile: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; @@ -134,7 +134,7 @@ const RenameFile: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; @@ -163,7 +163,7 @@ const RenameFile: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/restore-file/index.tsx b/src/renderer/components/modals/file/restore-file/index.tsx index 7fac7cfd..e0ded064 100644 --- a/src/renderer/components/modals/file/restore-file/index.tsx +++ b/src/renderer/components/modals/file/restore-file/index.tsx @@ -6,7 +6,7 @@ import {SubmitHandler, useForm} from "react-hook-form"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {FileItem, restoreFile} from "@renderer/modules/qiniu-client"; import useFrozenInfo from "@renderer/modules/qiniu-client-hooks/use-frozen-info"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -78,7 +78,7 @@ const RestoreFile: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/components/modals/file/restore-files/index.tsx b/src/renderer/components/modals/file/restore-files/index.tsx index 63b0c461..b4e33ff5 100644 --- a/src/renderer/components/modals/file/restore-files/index.tsx +++ b/src/renderer/components/modals/file/restore-files/index.tsx @@ -7,7 +7,7 @@ import {BackendMode} from "@common/qiniu"; import StorageClass from "@common/models/storage-class"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {FileItem, restoreFiles, stopRestoreFiles} from "@renderer/modules/qiniu-client"; import {useFileOperation} from "@renderer/modules/file-operation"; import * as AuditLog from "@renderer/modules/audit-log"; @@ -114,7 +114,7 @@ const RestoreFiles: React.FC = (props) => { const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses: memoStorageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/file/upload-files-confirm/index.tsx b/src/renderer/components/modals/file/upload-files-confirm/index.tsx index a5b88897..097667d0 100644 --- a/src/renderer/components/modals/file/upload-files-confirm/index.tsx +++ b/src/renderer/components/modals/file/upload-files-confirm/index.tsx @@ -63,12 +63,12 @@ const UploadFilesConfirm: React.FC = ({ ...modalProps }) => { const {currentLanguage, translate} = useI18n(); - const {currentUser} = useAuth(); + const {currentUser, shareSession} = useAuth(); const {bucketPreferBackendMode: preferBackendMode} = useFileOperation(); const { endpointConfigData, - } = useEndpointConfig(currentUser); + } = useEndpointConfig(currentUser, shareSession); // cache operation states prevent props update after modal opened. const { @@ -158,6 +158,12 @@ const UploadFilesConfirm: React.FC = ({ clientOptions: { accessKey: currentUser.accessKey, secretKey: currentUser.accessSecret, + sessionToken: shareSession?.sessionToken, + bucketNameId: shareSession + ? { + [shareSession.bucketName]: shareSession.bucketId, + } + : undefined, ucUrl: endpointConfigData.ucUrl, regions: endpointConfigData.regions.map(r => ({ id: "", diff --git a/src/renderer/components/modals/hooks/use-display-modal.ts b/src/renderer/components/modals/hooks/use-display-modal.ts index 319a8bc8..a40f032e 100644 --- a/src/renderer/components/modals/hooks/use-display-modal.ts +++ b/src/renderer/components/modals/hooks/use-display-modal.ts @@ -8,9 +8,9 @@ interface DisplayModalState { } interface DisplayModalFns { - showModal: T extends undefined ? () => void : (data: T) => void, - hideModal: T extends undefined ? () => void : (data: T) => void, - toggleModal: T extends undefined ? () => void : (data: T) => void, + showModal: (...data: T extends undefined ? [] : [T]) => void, + hideModal: (...data: T extends undefined ? [] : [T]) => void, + toggleModal: (...data: T extends undefined ? [] : [T]) => void, } function useDisplayModal(initialData: T): [DisplayModalState, DisplayModalFns] diff --git a/src/renderer/components/modals/preview-file/file-content/audio-content.tsx b/src/renderer/components/modals/preview-file/file-content/audio-content.tsx index 63d8a502..9e99c174 100644 --- a/src/renderer/components/modals/preview-file/file-content/audio-content.tsx +++ b/src/renderer/components/modals/preview-file/file-content/audio-content.tsx @@ -5,8 +5,8 @@ import Duration, {convertDuration} from "@common/const/duration"; import {BackendMode} from "@common/qiniu" import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; -import {signatureUrl} from "@renderer/modules/qiniu-client"; +import {useAuth} from "@renderer/modules/auth"; +import {getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; import {DomainAdapter, NON_OWNED_DOMAIN} from "@renderer/modules/qiniu-client-hooks"; import LoadingHolder from "@renderer/components/loading-holder"; @@ -37,17 +37,27 @@ const AudioContent: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: domain.apiScope === BackendMode.S3, }; + + const apiDomain = domain.name === NON_OWNED_DOMAIN.name + ? undefined + : domain; + + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode: domain.apiScope === BackendMode.S3 ? BackendMode.S3 : BackendMode.Kodo, + currentEndpointType: currentUser.endpointType, + }); + signatureUrl( regionId, bucketName, filePath, - domain.name === NON_OWNED_DOMAIN.name - ? undefined - : domain, + apiDomain, convertDuration(12 * Duration.Hour, Duration.Second), + style, opt, ) .then(fileUrl => { diff --git a/src/renderer/components/modals/preview-file/file-content/code-content.tsx b/src/renderer/components/modals/preview-file/file-content/code-content.tsx index e98477f4..57c1ba74 100644 --- a/src/renderer/components/modals/preview-file/file-content/code-content.tsx +++ b/src/renderer/components/modals/preview-file/file-content/code-content.tsx @@ -6,7 +6,7 @@ import {MergeView} from "codemirror/addon/merge/merge"; import {BackendMode} from "@common/qiniu" import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {getContent, saveContent} from "@renderer/modules/qiniu-client"; import {DomainAdapter, NON_OWNED_DOMAIN} from "@renderer/modules/qiniu-client-hooks"; import {DiffView, EditorView} from "@renderer/modules/codemirror"; @@ -73,7 +73,7 @@ const CodeContent: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: domain.apiScope === BackendMode.S3, }; getContent( @@ -112,7 +112,7 @@ const CodeContent: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: domain.apiScope === BackendMode.S3, preferKodoAdapter: domain.apiScope === BackendMode.Kodo, }; diff --git a/src/renderer/components/modals/preview-file/file-content/picture-content.tsx b/src/renderer/components/modals/preview-file/file-content/picture-content.tsx index 9e80c307..1a493da3 100644 --- a/src/renderer/components/modals/preview-file/file-content/picture-content.tsx +++ b/src/renderer/components/modals/preview-file/file-content/picture-content.tsx @@ -5,9 +5,9 @@ import {toast} from "react-hot-toast"; import Duration, {convertDuration} from "@common/const/duration"; import {BackendMode} from "@common/qiniu" -import {signatureUrl} from "@renderer/modules/qiniu-client"; +import {getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {DomainAdapter, NON_OWNED_DOMAIN} from "@renderer/modules/qiniu-client-hooks"; import LoadingHolder from "@renderer/components/loading-holder"; @@ -39,17 +39,27 @@ const PictureContent: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: domain.apiScope === BackendMode.S3, }; + + const apiDomain = domain.name === NON_OWNED_DOMAIN.name + ? undefined + : domain; + + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode: domain.apiScope === BackendMode.S3 ? BackendMode.S3 : BackendMode.Kodo, + currentEndpointType: currentUser.endpointType, + }); + signatureUrl( regionId, bucketName, filePath, - domain.name === NON_OWNED_DOMAIN.name - ? undefined - : domain, + apiDomain, convertDuration(12 * Duration.Hour, Duration.Second), + style, opt, ) .then(fileUrl => { diff --git a/src/renderer/components/modals/preview-file/file-content/video-content.tsx b/src/renderer/components/modals/preview-file/file-content/video-content.tsx index 331519e9..665868a9 100644 --- a/src/renderer/components/modals/preview-file/file-content/video-content.tsx +++ b/src/renderer/components/modals/preview-file/file-content/video-content.tsx @@ -4,9 +4,9 @@ import {toast} from "react-hot-toast"; import Duration, {convertDuration} from "@common/const/duration"; import {BackendMode} from "@common/qiniu" -import {signatureUrl} from "@renderer/modules/qiniu-client"; +import {getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {DomainAdapter, NON_OWNED_DOMAIN} from "@renderer/modules/qiniu-client-hooks"; import LoadingHolder from "@renderer/components/loading-holder"; @@ -41,17 +41,27 @@ const VideoContent: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferS3Adapter: domain.apiScope === BackendMode.S3, }; + + const apiDomain = domain.name === NON_OWNED_DOMAIN.name + ? undefined + : domain; + + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode: domain.apiScope === BackendMode.S3 ? BackendMode.S3 : BackendMode.Kodo, + currentEndpointType: currentUser.endpointType, + }); + signatureUrl( regionId, bucketName, filePath, - domain.name === NON_OWNED_DOMAIN.name - ? undefined - : domain, + apiDomain, convertDuration(12 * Duration.Hour, Duration.Second), + style, opt, ) .then(fileUrl => { diff --git a/src/renderer/components/modals/preview-file/file-operation/change-storage-class.tsx b/src/renderer/components/modals/preview-file/file-operation/change-storage-class.tsx index 595b0f39..de5cbe63 100644 --- a/src/renderer/components/modals/preview-file/file-operation/change-storage-class.tsx +++ b/src/renderer/components/modals/preview-file/file-operation/change-storage-class.tsx @@ -7,7 +7,7 @@ import StorageClass from "@common/models/storage-class"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {FileItem, setStorageClass} from "@renderer/modules/qiniu-client"; import {useFileOperation} from "@renderer/modules/file-operation"; import {useHeadFile} from "@renderer/modules/qiniu-client-hooks"; @@ -82,7 +82,7 @@ const ChangeStorageClass: React.FC = ({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, storageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/components/modals/preview-file/file-operation/generate-link.tsx b/src/renderer/components/modals/preview-file/file-operation/generate-link.tsx index 234dd081..00997f16 100644 --- a/src/renderer/components/modals/preview-file/file-operation/generate-link.tsx +++ b/src/renderer/components/modals/preview-file/file-operation/generate-link.tsx @@ -4,8 +4,8 @@ import lodash from "lodash"; import {BackendMode} from "@common/qiniu" -import {EndpointType, useAuth} from "@renderer/modules/auth"; -import {FileItem, signatureUrl} from "@renderer/modules/qiniu-client"; +import {useAuth} from "@renderer/modules/auth"; +import {FileItem, getStyleForSignature, signatureUrl} from "@renderer/modules/qiniu-client"; import {DomainAdapter, NON_OWNED_DOMAIN, useLoadDomains} from "@renderer/modules/qiniu-client-hooks"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -77,23 +77,30 @@ const GenerateLink: React.FC =({ const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3 || data.domain?.apiScope === BackendMode.S3, }; - const domain = data.domain?.name === NON_OWNED_DOMAIN.name + const apiDomain = data.domain?.name === NON_OWNED_DOMAIN.name ? undefined : data.domain; + const style = getStyleForSignature({ + domain: apiDomain, + preferBackendMode, + currentEndpointType: currentUser.endpointType, + }); + return signatureUrl( regionId, bucketName, fileItem.path.toString(), - domain, + apiDomain, data.expireAfter, + style, opt, ) .then(fileUrl => { diff --git a/src/renderer/components/modals/preview-file/precheck/file-archived.tsx b/src/renderer/components/modals/preview-file/precheck/file-archived.tsx index dba89f2a..3423074e 100644 --- a/src/renderer/components/modals/preview-file/precheck/file-archived.tsx +++ b/src/renderer/components/modals/preview-file/precheck/file-archived.tsx @@ -6,7 +6,7 @@ import {SubmitHandler, useForm} from "react-hook-form"; import {BackendMode} from "@common/qiniu"; import {useI18n} from "@renderer/modules/i18n"; -import {EndpointType, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {restoreFile} from "@renderer/modules/qiniu-client"; import useFrozenInfo from "@renderer/modules/qiniu-client-hooks/use-frozen-info"; import {useFileOperation} from "@renderer/modules/file-operation"; @@ -64,7 +64,7 @@ const FileArchived: React.FC> = (props) => const opt = { id: currentUser.accessKey, secret: currentUser.accessSecret, - isPublicCloud: currentUser.endpointType === EndpointType.Public, + endpointType: currentUser.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/components/top/menu-contents.tsx b/src/renderer/components/top/menu-contents.tsx index 102fb06f..f707f6ae 100644 --- a/src/renderer/components/top/menu-contents.tsx +++ b/src/renderer/components/top/menu-contents.tsx @@ -8,7 +8,7 @@ import {AkItem, useAuth} from "@renderer/modules/auth"; import {useDisplayModal} from "@renderer/components/modals/hooks"; import AkHistory from "@renderer/components/modals/general/ak-history"; -import RoutePath from "@renderer/pages/route-path"; +import RoutePath, {SwitchUserState} from "@renderer/pages/route-path"; import {MenuItem, MenuItemType} from "./menu-item"; @@ -79,8 +79,14 @@ const MenuContents: React.FC = ({ ] = useDisplayModal(); const handleActiveAk = (akItem: AkItem) => { + const state: SwitchUserState = { + type: "ak", + data: { + akItem, + }, + }; navigate(RoutePath.SwitchUser, { - state: akItem, + state, }); handleHideAccessKeyHistory(); } diff --git a/src/renderer/components/transfer-panel/index.tsx b/src/renderer/components/transfer-panel/index.tsx index 0ec70d25..12d62b2d 100644 --- a/src/renderer/components/transfer-panel/index.tsx +++ b/src/renderer/components/transfer-panel/index.tsx @@ -40,11 +40,11 @@ const TransferPanel: React.FC = ({ const [openPanelName, setOpenPanelName] = useState(); - const {currentUser} = useAuth(); + const {currentUser, shareSession} = useAuth(); const { endpointConfigData, - } = useEndpointConfig(currentUser); + } = useEndpointConfig(currentUser, shareSession); const { data: appPreferencesData, diff --git a/src/renderer/components/transfer-panel/use-ipc-download.ts b/src/renderer/components/transfer-panel/use-ipc-download.ts index 2a844518..c5015c50 100644 --- a/src/renderer/components/transfer-panel/use-ipc-download.ts +++ b/src/renderer/components/transfer-panel/use-ipc-download.ts @@ -12,7 +12,7 @@ import { UpdateUiDataMessage, } from "@common/ipc-actions/download"; -import {AkItem} from "@renderer/modules/auth"; +import {AkItem, ShareSession} from "@renderer/modules/auth"; import {Endpoint} from "@renderer/modules/qiniu-client"; import ipcDownloadManager from "@renderer/modules/electron-ipc-manages/ipc-download-manager"; @@ -45,6 +45,7 @@ type JobsQuery = UpdateUiDataMessage['data']['query']; interface UseIpcDownloadProps { endpoint: Endpoint, user: AkItem | null, + shareSession: ShareSession | null, config: DownloadConfig, initQueryCount?: number, @@ -56,6 +57,7 @@ interface UseIpcDownloadProps { const useIpcDownload = ({ endpoint, user, + shareSession, config, initQueryCount = JOB_NUMS_PER_QUERY, @@ -125,6 +127,12 @@ const useIpcDownload = ({ clientOptions: { accessKey: user.accessKey, secretKey: user.accessSecret, + sessionToken: shareSession?.sessionToken, + bucketNameId: shareSession + ? { + [shareSession.bucketName]: shareSession.bucketId, + } + : undefined, ucUrl: endpoint.ucUrl, regions: endpoint.regions.map(r => ({ id: "", diff --git a/src/renderer/components/transfer-panel/use-ipc-upload.ts b/src/renderer/components/transfer-panel/use-ipc-upload.ts index 61d49c3b..e84679c7 100644 --- a/src/renderer/components/transfer-panel/use-ipc-upload.ts +++ b/src/renderer/components/transfer-panel/use-ipc-upload.ts @@ -13,7 +13,7 @@ import { UpdateUiDataMessage, } from "@common/ipc-actions/upload"; -import {AkItem} from "@renderer/modules/auth"; +import {AkItem, ShareSession} from "@renderer/modules/auth"; import {Endpoint} from "@renderer/modules/qiniu-client"; import ipcUploadManager from "@renderer/modules/electron-ipc-manages/ipc-upload-manager"; @@ -47,6 +47,7 @@ type JobsQuery = UpdateUiDataMessage['data']['query']; export interface UseIpcUploadProps { endpoint: Endpoint, user: AkItem | null, + shareSession: ShareSession | null, config: UploadConfig, initQueryCount?: number, @@ -59,6 +60,7 @@ export interface UseIpcUploadProps { const useIpcUpload = ({ endpoint, user, + shareSession, config, initQueryCount = JOB_NUMS_PER_QUERY, @@ -140,6 +142,12 @@ const useIpcUpload = ({ clientOptions: { accessKey: user.accessKey, secretKey: user.accessSecret, + sessionToken: shareSession?.sessionToken, + bucketNameId: shareSession + ? { + [shareSession.bucketName]: shareSession.bucketId, + } + : undefined, ucUrl: endpoint.ucUrl, regions: endpoint.regions.map(r => ({ id: "", diff --git a/src/renderer/const/patterns.ts b/src/renderer/const/patterns.ts index 55d7b215..3d59b69d 100644 --- a/src/renderer/const/patterns.ts +++ b/src/renderer/const/patterns.ts @@ -1,3 +1,5 @@ +export const Alphanumeric = /[A-Za-z0-9]/; + export const Email = /^\w+([-.]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/; /* diff --git a/src/renderer/modules/auth/functions.ts b/src/renderer/modules/auth/functions.ts index f6d66026..d2ee2e99 100644 --- a/src/renderer/modules/auth/functions.ts +++ b/src/renderer/modules/auth/functions.ts @@ -3,10 +3,11 @@ import lodash from "lodash"; import * as LocalLogger from "@renderer/modules/local-logger"; import * as QiniuClient from "@renderer/modules/qiniu-client"; -import {AkItem, EndpointType} from "./types"; +import {AkItem, EndpointType, ShareSession} from "./types"; import {authPersistence} from "./persistence"; let currentUser: AkItem | null = null; +let shareSession: ShareSession | null = null; let history: AkItem[] = []; export async function loadPersistence() { @@ -25,7 +26,7 @@ export async function signIn(akItem: AkItem, remember: boolean) { await QiniuClient.listAllBuckets({ id: akItem.accessKey, secret: akItem.accessSecret, - isPublicCloud: akItem.endpointType === EndpointType.Public, + endpointType: akItem.endpointType, }); } catch (err) { QiniuClient.clearAllCache(); @@ -48,8 +49,74 @@ export async function signIn(akItem: AkItem, remember: boolean) { } } +export interface SignInWithShareLinkOptions { + apiHosts?: string[], + shareId: string, + shareToken: string, + extractCode: string, +} + +export async function signInWithShareLink({ + apiHosts, + shareId, + shareToken, + extractCode, +}: SignInWithShareLinkOptions): Promise { + const verifyShareOpt: QiniuClient.GetShareServiceOptions = {}; + if (apiHosts?.length) { + verifyShareOpt.apiUrls = apiHosts; + } + const verifyShareResult = await QiniuClient.verifyShare( + { + shareId, + shareToken, + extractCode, + }, + verifyShareOpt, + ); + currentUser = { + endpointType: EndpointType.ShareSession, + accessKey: verifyShareResult.federated_ak, + accessSecret: verifyShareResult.federated_sk, + }; + shareSession = { + sessionToken: verifyShareResult.session_token, + endpoint: verifyShareResult.endpoint, + bucketId: verifyShareResult.bucket_id, + bucketName: verifyShareResult.bucket_name, + expires: verifyShareResult.expires, + permission: verifyShareResult.permission, + prefix: verifyShareResult.prefix, + regionS3Id: verifyShareResult.region, + }; + // do not remember always; + // await authPersistence.save(currentUser); +} + +export interface SignInWithShareSessionOptions { + akItem: Omit, + session: ShareSession, +} + +export async function signInWithShareSession({ + akItem, + session, +}: SignInWithShareSessionOptions): Promise { + if (new Date(session.expires).getTime() < Date.now()) { + throw new Error("expired session"); + } + currentUser = { + ...akItem, + endpointType: EndpointType.ShareSession, + }; + shareSession = session; + // do not remember always; + // await authPersistence.save(currentUser); +} + export async function signOut() { currentUser = null; + shareSession = null; await authPersistence.clear(); } @@ -57,6 +124,10 @@ export function getCurrentUser(): AkItem | null { return currentUser; } +export function getShareSession(): ShareSession | null { + return shareSession; +} + export function getHistory(): AkItem[] { return history; } diff --git a/src/renderer/modules/auth/persistence.ts b/src/renderer/modules/auth/persistence.ts index f7ba3391..884b4cd0 100644 --- a/src/renderer/modules/auth/persistence.ts +++ b/src/renderer/modules/auth/persistence.ts @@ -16,27 +16,32 @@ interface AuthHistory { class AuthInfoSerializer extends serializer.JSONSerializer { serialize(value: AuthInfo): string { - const oldFormat = { - isPublicCloud: value.endpointType === EndpointType.Public, - id: value.accessKey, - secret: value.accessSecret, + const jsonStr = super.serialize({ + endpointType: value.endpointType, + accessKey: value.accessKey, + accessSecret: value.accessSecret, description: value.description, - } - const jsonStr = super.serialize(oldFormat as any); + }); return cipher(jsonStr); } deserialize(value: string): AuthInfo { const jsonStr = decipher(value); - const oldFormat: any = super.deserialize(jsonStr); - return { - endpointType: oldFormat.isPublicCloud - ? EndpointType.Public - : EndpointType.Private, - accessKey: oldFormat.id, - accessSecret: oldFormat.secret, - description: oldFormat.description, + const data: any = super.deserialize(jsonStr); + + // compatible with old format + if (data.hasOwnProperty("isPublicCloud")) { + return { + endpointType: data.isPublicCloud + ? EndpointType.Public + : EndpointType.Private, + accessKey: data.id, + accessSecret: data.secret, + description: data.description, + }; } + + return data; } } diff --git a/src/renderer/modules/auth/react-context.tsx b/src/renderer/modules/auth/react-context.tsx index 363cca2e..681cdf1c 100644 --- a/src/renderer/modules/auth/react-context.tsx +++ b/src/renderer/modules/auth/react-context.tsx @@ -1,19 +1,25 @@ import React, {createContext, useContext, useState} from "react"; -import {AkItem} from "./types"; +import {AkItem, ShareSession} from "./types"; import * as AuthFunc from "./functions"; const AuthContext = createContext<{ currentUser: AkItem | null, + shareSession: ShareSession | null, akHistory: AkItem[], - signIn: (akItem: AkItem, remember: boolean) => Promise, - signOut: () => Promise, - deleteHistory: (akItem: AkItem) => Promise, - clearHistory: () => Promise, + signIn: typeof AuthFunc.signIn, + signInWithShareLink: typeof AuthFunc.signInWithShareLink, + signInWithShareSession: typeof AuthFunc.signInWithShareSession, + signOut: typeof AuthFunc.signOut, + deleteHistory: typeof AuthFunc.deleteHistory, + clearHistory: typeof AuthFunc.clearHistory, }>({ currentUser: null, + shareSession: null, akHistory: [], signIn: AuthFunc.signIn, + signInWithShareLink: AuthFunc.signInWithShareLink, + signInWithShareSession: AuthFunc.signInWithShareSession, signOut: AuthFunc.signOut, deleteHistory: AuthFunc.deleteHistory, clearHistory: AuthFunc.clearHistory, @@ -24,6 +30,7 @@ export const Provider: React.FC<{ }> = ({children}) => { const [user, setUser] = useState(AuthFunc.getCurrentUser()); const [history, setHistory] = useState(AuthFunc.getHistory()); + const [shareSession, setShareSession] = useState(AuthFunc.getShareSession()); const signIn = async (akItem: AkItem, remember: boolean) => { await AuthFunc.signIn(akItem, remember); @@ -31,9 +38,22 @@ export const Provider: React.FC<{ setHistory([...AuthFunc.getHistory()]); }; + const signInWithShareLink = async (opt: AuthFunc.SignInWithShareLinkOptions) => { + await AuthFunc.signInWithShareLink(opt); + setUser(AuthFunc.getCurrentUser()); + setShareSession(AuthFunc.getShareSession()); + } + + const signInWithShareSession = async (opt: AuthFunc.SignInWithShareSessionOptions) => { + await AuthFunc.signInWithShareSession(opt); + setUser(AuthFunc.getCurrentUser()); + setShareSession(AuthFunc.getShareSession()); + } + const signOut = async () => { await AuthFunc.signOut(); setUser(null); + setShareSession(null); }; const deleteHistory = async (akItem: AkItem) => { @@ -49,8 +69,11 @@ export const Provider: React.FC<{ return ( void, + onSignInWithShareLink: (data: DeepLinkSignInWithShareLinkMessage["data"]) => void, + onSignInWithShareSession: (data: DeepLinkSignInWithShareSessionMessage["data"]) => void, +} + +let DEEP_LINK_CHANNEL = "DeepLink"; +let rendererReady = 0; + +function useIpcDeepLink({ + onSignInDataInvalid, + onSignInWithShareLink, + onSignInWithShareSession, +}: useIpcDeepLinkProps) { + const uploadReplyHandler = (_event: IpcRendererEvent, message: DeepLinkMessage) => { + switch (message.action) { + case DeepLinkAction.SignInDataInvalid: + onSignInDataInvalid(); + break; + case DeepLinkAction.SignInWithShareLink: + onSignInWithShareLink(message.data); + break; + case DeepLinkAction.SignInWithShareSession: + onSignInWithShareSession(message.data); + break; + } + }; + + const uploadReplyHandlerRef = useRef(uploadReplyHandler); + uploadReplyHandlerRef.current = uploadReplyHandler; + + useEffect(() => { + const handler = (event: Electron.IpcRendererEvent, msg: any) => { + uploadReplyHandlerRef.current(event, msg); + } + ipcRenderer.on(DEEP_LINK_CHANNEL, handler); + if (!rendererReady) { + const deepLinkActionFns = new DeepLinkActionFns(ipcRenderer, DEEP_LINK_CHANNEL); + deepLinkActionFns.rendererReady(); + } + rendererReady += 1; + + return () => { + rendererReady -= 1; + if (!rendererReady) { + const deepLinkActionFns = new DeepLinkActionFns(ipcRenderer, DEEP_LINK_CHANNEL); + deepLinkActionFns.rendererClose(); + } + ipcRenderer.off(DEEP_LINK_CHANNEL, handler); + } + }, []); +} + +export default useIpcDeepLink; diff --git a/src/renderer/modules/i18n/core.ts b/src/renderer/modules/i18n/core.ts index 575220f8..be04b5bd 100644 --- a/src/renderer/modules/i18n/core.ts +++ b/src/renderer/modules/i18n/core.ts @@ -30,8 +30,9 @@ export function getLang() { return currentLang; } -export function translate(key: PropsPath): string { - return renderWithObject(lodash.get(dictionary, key, key), dictionary); +export function translate(key: PropsPath, dict?: Record): string { + const obj = dict ?? dictionary; + return renderWithObject(lodash.get(dictionary, key, key), obj); } export async function setLang(lang: LangName): Promise { @@ -66,7 +67,7 @@ function triggerLangeChange(lang: LangName, dictionary: Dictionary) { }); } -export function renderWithObject(str: string, obj: any) { +function renderWithObject(str: string, obj: any) { return str.replace(/\$\{(\w+|\w+\.\w+)}/g, match => { const k = match.slice(2, -1); // remove ${} return lodash.get(obj, k, match); diff --git a/src/renderer/modules/i18n/lang/dict.ts b/src/renderer/modules/i18n/lang/dict.ts index 12246c2a..1b9a3f4d 100644 --- a/src/renderer/modules/i18n/lang/dict.ts +++ b/src/renderer/modules/i18n/lang/dict.ts @@ -29,6 +29,14 @@ export default interface Dictionary { downloading: string, downloaded: string, paused: string, + custom: string, + + second: string, + seconds: string, + minute: string, + minutes: string, + hour: string, + hours: string, directory: string, upload: string, @@ -41,12 +49,24 @@ export default interface Dictionary { more: string, exportLink: string, exportLinks: string, + share: string, restore: string, changeStorageClass: string, unknownStorageClass: string, clickToRetry: string, }, + // deep link actions + deepLinkActions: { + signIn: { + invalidParams: string, + signOutConfirm: { + title: string, + description: string, + }, + }, + }, + // top top: { files: string, @@ -77,7 +97,10 @@ export default interface Dictionary { // signIn signIn: { title: string, + titleShareLink: string, accessKeyHistory: string, + gotoShareLinkForm: string, + gotoAkForm: string, form: { endpoint: { label: string, @@ -114,6 +137,23 @@ export default interface Dictionary { submit: string, submitting: string, }, + formShareLink: { + shareLink: { + label: string, + holder: string, + feedback: { + invalidFormat: string, + invalidPrivateFormat: string, + }, + }, + extractCode: { + label: string, + holder: string, + feedback: { + invalidFormat: string, + }, + }, + }, }, signOut: { @@ -705,6 +745,34 @@ export default interface Dictionary { title: string, }, + createDirectoryShareLink: { + title: string, + form: { + directoryName: { + label: string, + }, + expireAfter: { + label: string, + suffix: string, + hint: string, + }, + extractCode: { + label: string, + suffix: string, + hint: string, + }, + shareLink: { + label: string, + }, + expiredAt: { + label: string, + }, + }, + copyShareMessageButton: string, + copyShareMessageSuccess: string, + shareMessage: string, + } + uploadConfirm: { title: string, previewList: { diff --git a/src/renderer/modules/i18n/lang/en-us.ts b/src/renderer/modules/i18n/lang/en-us.ts index c67a85aa..28f1e0e4 100644 --- a/src/renderer/modules/i18n/lang/en-us.ts +++ b/src/renderer/modules/i18n/lang/en-us.ts @@ -28,6 +28,14 @@ const dict: Dictionary = { noDomainToGet: "No domain to get object", errored: "An error has occurred", paused: "Paused", + custom: "custom", + + second: "second", + seconds: "seconds", + minute: "minute", + minutes: "minutes", + hour: "hour", + hours: "hours", directory: "Directory", upload: "Upload", @@ -42,12 +50,23 @@ const dict: Dictionary = { more: "More", exportLink: "Export Download Link", exportLinks: "Export Download Links", + share: "Share", restore: "Restore", changeStorageClass: "Set Storage Class", unknownStorageClass: "Unknown Storage Class", clickToRetry: "Click to retry", }, + deepLinkActions: { + signIn: { + invalidParams: "Invalid URL. Lost required parameters", + signOutConfirm: { + title: "Sign Out", + description: "Sign Out for Signing In with Share URL", + }, + }, + }, + top: { files: "Files", externalPath: "External Link", @@ -74,8 +93,11 @@ const dict: Dictionary = { }, signIn: { - title: "access key sign in", + title: "Access Key Sign In", + titleShareLink: "Share Link Sign In", accessKeyHistory: "AK History", + gotoShareLinkForm: "Use Share Link Sign In", + gotoAkForm: "Use AK Sign In", form: { accessKeyId: { holder: "AccessKeyId", @@ -112,6 +134,23 @@ const dict: Dictionary = { submit: "Sign in", submitting: "Signing in", }, + formShareLink: { + shareLink: { + label: "Share Link:", + holder: "Please input the directory share link", + feedback: { + invalidFormat: "Invalid share link format", + invalidPrivateFormat: "Invalid share link format. Please configure share base link if it's a share link for private cloud.", + }, + }, + extractCode: { + label: "Extract Code:", + holder: "Alphabet and digits only", + feedback: { + invalidFormat: "Must contain only alphabet or digits and length must be 6" + }, + }, + }, }, signOut: { @@ -704,6 +743,41 @@ const dict: Dictionary = { title: "Export Download Link", }, + createDirectoryShareLink: { + title: "Share Directory", + form: { + directoryName: { + label: "Directory:", + }, + expireAfter: { + label: "Validity Period:", + suffix: "seconds", + hint: "Range: ${minSeconds} - ${maxSeconds} seconds" + }, + extractCode: { + label: "Extraction Code:", + suffix: "Random", + hint: "Must be 6 characters long and can only consist of alphanumeric characters", + }, + shareLink: { + label: "Share Link:", + }, + expiredAt: { + label: "Valid Until:", + }, + }, + copyShareMessageButton: "Copy Share Message", + copyShareMessageSuccess: "Copied", + shareMessage: [ + "I've shared some files with you, take a look!", + "", + "${shareLink}", + "", + "Extraction Code: ${extractCode}", + "Valid until: ${expiredAt}" + ].join("\n"), + }, + uploadConfirm: { title: "Upload Files", previewList: { diff --git a/src/renderer/modules/i18n/lang/ja-jp.ts b/src/renderer/modules/i18n/lang/ja-jp.ts index 14236696..ab183a44 100644 --- a/src/renderer/modules/i18n/lang/ja-jp.ts +++ b/src/renderer/modules/i18n/lang/ja-jp.ts @@ -28,6 +28,14 @@ const dict: Dictionary = { noDomainToGet: "オブジェクトを取得するためのドメインがない", errored: "エラーが発生しました", paused: "停止しました", + custom: "自定义", + + second: "秒", + seconds: "秒", + minute: "分", + minutes: "分", + hour: "時間", + hours: "時間", directory: "フォルダ", upload: "アップロード", @@ -42,12 +50,23 @@ const dict: Dictionary = { more: "もっと", exportLink: "ダウンロードアドレスを取得する", exportLinks: "ダウンロードリンクのエクスポート", + share: "共有", restore: "リストア", changeStorageClass: "ストレージクラスを設定する", unknownStorageClass: "不明なストレージクラス", clickToRetry: "再試行", }, + deepLinkActions: { + signIn: { + invalidParams: "URLが無効です。必要なパラメータが失われました。", + signOutConfirm: { + title: "ログアウト", + description: "共有URLでサインインしているため、ログアウトします。", + }, + }, + }, + top: { files: "ファイル", externalPath: "外部リンク", @@ -75,7 +94,10 @@ const dict: Dictionary = { signIn: { title: "Access Key ログイン", + titleShareLink: "共有リンクログイン", accessKeyHistory: "AK の歴史", + gotoShareLinkForm: "共有リンクを使用してログイン", + gotoAkForm: "AKを使用してログイン", form: { accessKeyId: { holder: "AccessKeyId", @@ -112,6 +134,23 @@ const dict: Dictionary = { submit: "ログイン", submitting: "ログイン中", }, + formShareLink: { + shareLink: { + label: "共有リンク:", + holder: "フォルダの共有リンクを入力してください", + feedback: { + invalidFormat: "リンクの形式が正しくありません", + invalidPrivateFormat: "リンクの形式が正しくありません。非公開クラウドユーザーの場合は、プライベートクラウド共有アドレスを設定してください", + }, + }, + extractCode: { + label: "抽出コード:", + holder: "英数字のみ", + feedback: { + invalidFormat: "英数字のみで、6文字でなければなりません", + }, + }, + }, }, signOut: { @@ -703,6 +742,41 @@ const dict: Dictionary = { title: "ダウンロードリンクのエクスポート", }, + createDirectoryShareLink: { + title: "フォルダを共有する", + form: { + directoryName: { + label: "フォルダ:", + }, + expireAfter: { + label: "有効期限:", + suffix: "秒", + hint: "範囲:${minSeconds} - ${maxSeconds} 秒", + }, + extractCode: { + label: "抽出コード:", + suffix: "ランダム生成", + hint: "英数字のみで、6文字でなければなりません", + }, + shareLink: { + label: "共有リンク:", + }, + expiredAt: { + label: "失効時間:", + }, + }, + copyShareMessageButton: "リンクと抽出コードをコピー", + copyShareMessageSuccess: "コピーが成功しました", + shareMessage: [ + "いくつかのファイルを共有しました、ご覧ください!", + "", + "${shareLink}", + "", + "抽出コード:${extractCode}", + "有効期限:${expiredAt}" + ].join("\n"), + }, + uploadConfirm: { title: "ファイルのアップロード", previewList: { diff --git a/src/renderer/modules/i18n/lang/zh-cn.ts b/src/renderer/modules/i18n/lang/zh-cn.ts index 17601c7e..e87dc2f1 100644 --- a/src/renderer/modules/i18n/lang/zh-cn.ts +++ b/src/renderer/modules/i18n/lang/zh-cn.ts @@ -28,6 +28,14 @@ const dict: Dictionary = { noDomainToGet: "没有可用的域名获取对象", errored: "发生错误", paused: "已暂停", + custom: "自定义", + + second: "秒", + seconds: "秒", + minute: "分钟", + minutes: "分钟", + hour: "小时", + hours: "小时", directory: "目录", upload: "上传", @@ -42,12 +50,23 @@ const dict: Dictionary = { more: "更多", exportLink: "导出外链", exportLinks: "导出外链", + share: "分享", restore: "解冻", changeStorageClass: "更改存储类型", unknownStorageClass: "未知存储类型", clickToRetry: "点击重试", }, + deepLinkActions: { + signIn: { + invalidParams: "链接错误,缺少必要参数", + signOutConfirm: { + title: "退出登录", + description: "退出登录以使用共享链接", + }, + }, + }, + top: { files: "文件", externalPath: "外部路径", @@ -74,8 +93,11 @@ const dict: Dictionary = { }, signIn: { - title: "access key 登录", + title: "Access Key 登录", + titleShareLink: "分享链接登录", accessKeyHistory: "AK 历史", + gotoShareLinkForm: "使用分享链接登录", + gotoAkForm: "使用 AK 登录", form: { accessKeyId: { holder: "请输入 AccessKeyId", @@ -112,6 +134,23 @@ const dict: Dictionary = { submit: "登录", submitting: "登录中", }, + formShareLink: { + shareLink: { + label: "分享链接:", + holder: "请输入目录分享链接", + feedback: { + invalidFormat: "链接格式不正确", + invalidPrivateFormat: "链接格式不正确,非公有云用户请配置私有云分享地址", + }, + }, + extractCode: { + label: "提取码:", + holder: "必须是字母或数字", + feedback: { + invalidFormat: "只能是字母数字,必须为 6 位", + }, + }, + }, }, signOut: { @@ -703,6 +742,41 @@ const dict: Dictionary = { title: "导出外链", }, + createDirectoryShareLink: { + title: "分享目录", + form: { + directoryName: { + label: "目录:", + }, + expireAfter: { + label: "有效期:", + suffix: "秒", + hint: "范围:${minSeconds} - ${maxSeconds} 秒" + }, + extractCode: { + label: "提取码:", + suffix: "随机生成", + hint: "必须为 6 位,只能由字母数字组成", + }, + shareLink: { + label: "分享链接:", + }, + expiredAt: { + label: "失效时间:", + }, + }, + copyShareMessageButton: "复制链接与提取码", + copyShareMessageSuccess: "复制成功", + shareMessage: [ + "我分享了一些文件给您,快来看看吧!", + "", + "${shareLink}", + "", + "提取码:${extractCode}", + "有效期至:${expiredAt}" + ].join("\n"), + }, + uploadConfirm: { title: "上传文件", previewList: { diff --git a/src/renderer/modules/kodo-address/navigator.ts b/src/renderer/modules/kodo-address/navigator.ts index 21866480..83905494 100644 --- a/src/renderer/modules/kodo-address/navigator.ts +++ b/src/renderer/modules/kodo-address/navigator.ts @@ -6,6 +6,7 @@ export interface KodoNavigatorOptions { defaultProtocol: string, maxHistory?: number, initAddress?: KodoAddress, + lockPrefix?: string, } export class KodoNavigator { @@ -25,11 +26,13 @@ export class KodoNavigator { history: KodoAddress[] = [] currentIndex: number maxHistory: number + lockPrefix: string constructor({ defaultProtocol, maxHistory = 100, initAddress, + lockPrefix, }: KodoNavigatorOptions) { const defaultItem = { protocol: defaultProtocol, @@ -39,6 +42,7 @@ export class KodoNavigator { this.currentIndex = 0; this.history.push(initAddress ?? defaultItem); this.maxHistory = maxHistory; + this.lockPrefix = lockPrefix ?? ""; } onChange(callback: OnChangeListener) { @@ -83,6 +87,10 @@ export class KodoNavigator { ...kodoAddress, }; + if (!next.path.startsWith(this.lockPrefix)) { + return; + } + // address may not equal by external path // it will better if using protocol to tell which is which const nProps = Object.keys(next); diff --git a/src/renderer/modules/kodo-address/react-context.tsx b/src/renderer/modules/kodo-address/react-context.tsx index 0ed8ca55..3887714b 100644 --- a/src/renderer/modules/kodo-address/react-context.tsx +++ b/src/renderer/modules/kodo-address/react-context.tsx @@ -9,6 +9,7 @@ const KodoNavigatorContext = createContext<{ addressHistory: KodoAddress[], bucketName: string | undefined, basePath: string | undefined, + lockPrefix: string, goBack: () => void, goForward: () => void, goUp: KodoNavigator["goUp"], @@ -22,6 +23,7 @@ const KodoNavigatorContext = createContext<{ addressHistory: [], bucketName: undefined, basePath: undefined, + lockPrefix: "", goBack: () => {}, goForward: () => {}, goUp: (_p?: KodoAddress) => {}, @@ -40,6 +42,7 @@ export const Provider: React.FC<{ const [addressHistory, setAddressHistory] = useState(kodoNavigator.history); const [bucketName, setBucketName] = useState(kodoNavigator.bucketName); const [basePath, setBasePath] = useState(kodoNavigator.basePath); + const [lockPrefix, setLockPrefix] = useState(kodoNavigator.lockPrefix); useEffect(() => { const handleChange = () => { @@ -48,6 +51,7 @@ export const Provider: React.FC<{ setAddressHistory(kodoNavigator.history); setBucketName(kodoNavigator.bucketName); setBasePath(kodoNavigator.basePath); + setLockPrefix(kodoNavigator.lockPrefix); }; kodoNavigator.onChange(handleChange); return () => kodoNavigator.offChange(handleChange); @@ -60,6 +64,7 @@ export const Provider: React.FC<{ addressHistory: addressHistory, bucketName: bucketName, basePath: basePath, + lockPrefix: lockPrefix, // `bind` are required because of `this` goBack: kodoNavigator.goBack.bind(kodoNavigator), goForward: kodoNavigator.goForward.bind(kodoNavigator), diff --git a/src/renderer/modules/launch-config/chore-configs.ts b/src/renderer/modules/launch-config/chore-configs.ts new file mode 100644 index 00000000..8f441b9f --- /dev/null +++ b/src/renderer/modules/launch-config/chore-configs.ts @@ -0,0 +1,19 @@ +import * as DefaultDict from "@renderer/modules/default-dict"; + +import {LaunchConfigPlugin, LaunchConfigSetupOptions} from "./types"; + +class ChoreConfigs implements LaunchConfigPlugin { + setup(options: LaunchConfigSetupOptions) { + if (options.launchConfig.preferredEndpointType) { + DefaultDict.set("LOGIN_ENDPOINT_TYPE", options.launchConfig.preferredEndpointType); + } + if (options.launchConfig.baseShareUrl) { + DefaultDict.set("BASE_SHARE_URL", options.launchConfig.baseShareUrl); + } + if (options.launchConfig.maxShareDirectoryExpireAfterSeconds) { + DefaultDict.set("MAX_SHARE_DIRECTORY_EXPIRE_AFTER_SECONDS", options.launchConfig.maxShareDirectoryExpireAfterSeconds); + } + } +} + +export default ChoreConfigs; diff --git a/src/renderer/modules/launch-config/index.ts b/src/renderer/modules/launch-config/index.ts index 8229da7c..c906d08a 100644 --- a/src/renderer/modules/launch-config/index.ts +++ b/src/renderer/modules/launch-config/index.ts @@ -5,7 +5,7 @@ import {LocalFile, serializer} from "@renderer/modules/persistence"; import {LaunchConfigPlugin} from "./types"; import DefaultPrivateEndpoint from "./default-private-endpoint"; -import PreferredEndpointType from "./preferred-endpoint-type"; +import ChoreConfigs from "./chore-configs"; import DisableFunctions from "./disable-functions"; import PreferenceValidator from "./preference-validator"; @@ -25,7 +25,7 @@ class LaunchConfig { return; } const plugins: LaunchConfigPlugin[] = [ - new PreferredEndpointType(), + new ChoreConfigs(), new DefaultPrivateEndpoint(), new DisableFunctions(), new PreferenceValidator(), diff --git a/src/renderer/modules/launch-config/preferred-endpoint-type.ts b/src/renderer/modules/launch-config/preferred-endpoint-type.ts deleted file mode 100644 index 40857acf..00000000 --- a/src/renderer/modules/launch-config/preferred-endpoint-type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as DefaultDict from "@renderer/modules/default-dict"; - -import {LaunchConfigPlugin, LaunchConfigSetupOptions} from "./types"; - -class PreferredEndpointType implements LaunchConfigPlugin { - setup(options: LaunchConfigSetupOptions) { - if (options.launchConfig.preferredEndpointType) { - DefaultDict.set("LOGIN_ENDPOINT_TYPE", options.launchConfig.preferredEndpointType); - } - } -} - -export default PreferredEndpointType; diff --git a/src/renderer/modules/launch-config/types.ts b/src/renderer/modules/launch-config/types.ts index fcf4006c..bada7f3c 100644 --- a/src/renderer/modules/launch-config/types.ts +++ b/src/renderer/modules/launch-config/types.ts @@ -6,6 +6,8 @@ export interface LaunchConfig { preferredEndpointType?: string, defaultPrivateEndpointConfig?: DefaultPrivateEndpointConfig, preferenceValidators?: PreferenceValidators, + baseShareUrl?: string, + maxShareDirectoryExpireAfterSeconds?: number, disable?: DisableFunctions, } diff --git a/src/renderer/modules/qiniu-client-hooks/use-frozen-info.ts b/src/renderer/modules/qiniu-client-hooks/use-frozen-info.ts index cc160c80..18b5fd3a 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-frozen-info.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-frozen-info.ts @@ -4,7 +4,7 @@ import {toast} from "react-hot-toast"; import {BackendMode} from "@common/qiniu"; import {getFrozenInfo} from "@renderer/modules/qiniu-client"; -import {AkItem, EndpointType} from "@renderer/modules/auth"; +import {AkItem} from "@renderer/modules/auth"; export interface FrozenInfo { isLoading: boolean, @@ -43,7 +43,7 @@ const useFrozenInfo = ({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/modules/qiniu-client-hooks/use-head-file.ts b/src/renderer/modules/qiniu-client-hooks/use-head-file.ts index 0ed26177..77d92c5e 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-head-file.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-head-file.ts @@ -5,7 +5,7 @@ import {ObjectInfo} from "kodo-s3-adapter-sdk/dist/adapter"; import {BackendMode} from "@common/qiniu"; import StorageClass from "@common/models/storage-class"; -import {AkItem, EndpointType} from "@renderer/modules/auth"; +import {AkItem} from "@renderer/modules/auth"; import {headFile} from "@renderer/modules/qiniu-client"; interface HeadFileState { @@ -46,7 +46,7 @@ const useHeadFile = ({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, storageClasses: storageClasses, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/modules/qiniu-client-hooks/use-load-buckets.ts b/src/renderer/modules/qiniu-client-hooks/use-load-buckets.ts index e3b43c47..ae013b77 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-load-buckets.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-load-buckets.ts @@ -1,5 +1,5 @@ import {useState} from "react"; -import {AkItem, EndpointType} from "@renderer/modules/auth"; +import {AkItem} from "@renderer/modules/auth"; import {BucketItem, listAllBuckets} from "@renderer/modules/qiniu-client"; interface LoadBucketsState { @@ -27,7 +27,7 @@ export default function useLoadBuckets({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, }; setLoadBucketsState(s => ({ diff --git a/src/renderer/modules/qiniu-client-hooks/use-load-domains.ts b/src/renderer/modules/qiniu-client-hooks/use-load-domains.ts index 84a774af..558d1b8c 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-load-domains.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-load-domains.ts @@ -74,6 +74,16 @@ export default function useLoadDomains({ return; } + if (user.endpointType === EndpointType.ShareSession) { + setLoadDomainsState({ + loading: false, + domains: [{ + ...NON_OWNED_DOMAIN, + }], + }); + return; + } + if (!regionId || !bucketName) { toast.error("hooks loadDomains lost required arguments."); LocalLogger.error( @@ -102,7 +112,7 @@ export default function useLoadDomains({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, }; diff --git a/src/renderer/modules/qiniu-client-hooks/use-load-files.ts b/src/renderer/modules/qiniu-client-hooks/use-load-files.ts index 701f9521..7820fd7f 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-load-files.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-load-files.ts @@ -4,7 +4,7 @@ import {toast} from "react-hot-toast"; import StorageClass from "@common/models/storage-class"; import {BackendMode} from "@common/qiniu"; -import {AkItem, EndpointType} from "@renderer/modules/auth"; +import {AkItem} from "@renderer/modules/auth"; import {FileItem, listFiles, ListFilesOption, ListFilesResult} from "@renderer/modules/qiniu-client"; import * as LocalLogger from "@renderer/modules/local-logger"; @@ -121,7 +121,7 @@ export default function useLoadFiles({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, preferKodoAdapter: preferBackendMode === BackendMode.Kodo, preferS3Adapter: preferBackendMode === BackendMode.S3, diff --git a/src/renderer/modules/qiniu-client-hooks/use-load-regions.ts b/src/renderer/modules/qiniu-client-hooks/use-load-regions.ts index 3a7ac7e6..c9cb8fe6 100644 --- a/src/renderer/modules/qiniu-client-hooks/use-load-regions.ts +++ b/src/renderer/modules/qiniu-client-hooks/use-load-regions.ts @@ -43,7 +43,7 @@ export default function useLoadRegions({ const opt = { id: user.accessKey, secret: user.accessSecret, - isPublicCloud: user.endpointType === EndpointType.Public, + endpointType: user.endpointType, }; let regions: Region[] | undefined = undefined; diff --git a/src/renderer/modules/qiniu-client/buckets.test.ts b/src/renderer/modules/qiniu-client/buckets.test.ts index 228593bd..a6839647 100644 --- a/src/renderer/modules/qiniu-client/buckets.test.ts +++ b/src/renderer/modules/qiniu-client/buckets.test.ts @@ -14,6 +14,8 @@ import { mocked } from "ts-jest/utils"; import { Kodo as KodoAdapter } from "kodo-s3-adapter-sdk/dist/kodo"; import { S3 as S3Adapter } from "kodo-s3-adapter-sdk/dist/s3"; +import {EndpointType} from "@renderer/modules/auth"; + import * as QiniuClientCommon from "./common"; import * as QiniuClientBuckets from "./buckets"; @@ -28,7 +30,7 @@ describe("test qiniu-client/buckets.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; describe("Kodo", () => { diff --git a/src/renderer/modules/qiniu-client/common.test.ts b/src/renderer/modules/qiniu-client/common.test.ts index 54b98c56..73701682 100644 --- a/src/renderer/modules/qiniu-client/common.test.ts +++ b/src/renderer/modules/qiniu-client/common.test.ts @@ -27,7 +27,7 @@ describe("test qiniu-client/common.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; expect(QiniuClientCommon.clientBackendMode(opt)).toBe(KODO_MODE); opt.preferKodoAdapter = true; @@ -37,13 +37,13 @@ describe("test qiniu-client/common.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, preferS3Adapter: true, }; // preferS3Adapter || !isPublicCloud opt.preferS3Adapter = true; expect(QiniuClientCommon.clientBackendMode(opt)).toBe(S3_MODE); - opt.isPublicCloud = false; + opt.endpointType = EndpointType.Private; const endpointConfig = getEndpointConfig({ accessKey: ENV.QINIU_ACCESS_KEY, accessSecret: ENV.QINIU_SECRET_KEY, @@ -65,7 +65,7 @@ describe("test qiniu-client/common.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; expect(QiniuClientCommon.getDefaultClient(opt).constructor).toBe(KodoAdapter); opt.preferKodoAdapter = true; @@ -76,13 +76,13 @@ describe("test qiniu-client/common.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, preferS3Adapter: true, }; // preferS3Adapter || !isPublicCloud opt.preferS3Adapter = true; expect(QiniuClientCommon.getDefaultClient(opt).constructor).toBe(S3Adapter); - opt.isPublicCloud = false; + opt.endpointType = EndpointType.Private; const endpointConfig = getEndpointConfig({ accessKey: ENV.QINIU_ACCESS_KEY, accessSecret: ENV.QINIU_SECRET_KEY, @@ -104,7 +104,7 @@ describe("test qiniu-client/common.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; QiniuClientCommon.getRegionService(opt); expect(RegionService).toBeCalled(); diff --git a/src/renderer/modules/qiniu-client/common.ts b/src/renderer/modules/qiniu-client/common.ts index a92de9cf..cddf0989 100644 --- a/src/renderer/modules/qiniu-client/common.ts +++ b/src/renderer/modules/qiniu-client/common.ts @@ -3,11 +3,11 @@ import {NatureLanguage} from "kodo-s3-adapter-sdk/dist/uplog"; import {Kodo as KodoAdapter} from "kodo-s3-adapter-sdk/dist/kodo" import {S3 as S3Adapter} from "kodo-s3-adapter-sdk/dist/s3" import {RegionService} from "kodo-s3-adapter-sdk/dist/region_service"; - +import {ShareService} from "kodo-s3-adapter-sdk/dist/share-service"; import * as AppConfig from "@common/const/app-config"; import * as LocalLogger from "@renderer/modules/local-logger"; -import {EndpointType} from "@renderer/modules/auth"; +import {EndpointType, getShareSession} from "@renderer/modules/auth"; import {appPreferences, getEndpointConfig} from "@renderer/modules/user-config-store"; export function debugRequest(mode: string) { @@ -78,12 +78,14 @@ function makeAdapterCacheKey(accessKey: string, secretKey: string, ucUrl?: strin function getQiniuAdapter( accessKey: string, secretKey: string, - ucUrl: string | undefined = undefined, + sessionToken?: string, + ucUrl?: string, regions: Region[] = [], ) { return new Qiniu( accessKey, secretKey, + sessionToken, ucUrl, `Kodo-Browser/${AppConfig.app.version}`, regions, @@ -95,6 +97,7 @@ interface AdapterOption { // for kodo-s3-adapter-sdk/Qiniu accessKey: string, secretKey: string, + sessionToken?: string, regions: Region[], ucUrl?: string, @@ -114,7 +117,7 @@ interface AdapterOption { export interface GetAdapterOptionParam { id: string, secret: string, - isPublicCloud: boolean, + endpointType: EndpointType, preferKodoAdapter?: boolean, preferS3Adapter?: boolean, } @@ -132,25 +135,25 @@ function getAdapterOption(opt: GetAdapterOptionParam): AdapterOption { regions: [], }; let result: AdapterOption; - if (opt.isPublicCloud) { - result = { - ...baseResult, - ucUrl: undefined, - }; - } else { - // change to async to ensure await get the private endpoint config. - // the private endpoint is working exactly correct for now, - // because it's loaded before app start - const endpointConfig = getEndpointConfig({ - accessKey: opt.id, - accessSecret: opt.secret, - endpointType: EndpointType.Private, - }); - const privateEndpoint = endpointConfig.getAll(); - result = { - ...baseResult, - ucUrl: privateEndpoint.ucUrl, - regions: privateEndpoint.regions.map(rSetting => { + switch (opt.endpointType) { + case EndpointType.Public: { + result = { + ...baseResult, + ucUrl: undefined, + }; + break; + } + case EndpointType.Private: { + // change to async to ensure await get the private endpoint config. + // the private endpoint is working exactly correct for now, + // because it's loaded before app start + const endpointConfig = getEndpointConfig({ + accessKey: opt.id, + accessSecret: opt.secret, + endpointType: EndpointType.Private, + }); + const privateEndpoint = endpointConfig.getAll(); + const regions = privateEndpoint.regions.map(rSetting => { const r = new Region( "", rSetting.identifier, @@ -159,12 +162,36 @@ function getAdapterOption(opt: GetAdapterOptionParam): AdapterOption { r.s3Urls = [rSetting.endpoint]; r.ucUrls = [privateEndpoint.ucUrl]; return r; - }), - // disable uplog when use customize cloud - // because there isn't a valid access key of uplog - uplogBufferSize: -1, + }); + result = { + ...baseResult, + ucUrl: privateEndpoint.ucUrl, + regions, + // disable uplog when use customize cloud + // because there isn't a valid access key of uplog + uplogBufferSize: -1, + } + break; + } + case EndpointType.ShareSession: { + const shareSession = getShareSession(); + if (!shareSession) { + throw new Error("Endpoint type is ShareSession, but lost ShareSession info") + } + const region = new Region( + "", + shareSession.regionS3Id, + ); + region.s3Urls = [shareSession.endpoint]; + result = { + ...baseResult, + sessionToken: shareSession.sessionToken, + regions: [region], + }; + break; } } + if (opt.preferS3Adapter) { result.preferS3Adapter = opt.preferS3Adapter; } @@ -178,7 +205,7 @@ export function clientBackendMode(opt: GetAdapterOptionParam): string { adapterOption.regions.length > 0 && !adapterOption.preferKodoAdapter || adapterOption.preferS3Adapter || - !opt.isPublicCloud + opt.endpointType !== EndpointType.Public ) { return S3_MODE; } else { @@ -196,6 +223,7 @@ function getS3Client(opt: GetAdapterOptionParam): S3Adapter { const qiniuAdapter = getQiniuAdapter( adapterOption.accessKey, adapterOption.secretKey, + adapterOption.sessionToken, adapterOption.ucUrl, adapterOption.regions, ); @@ -207,6 +235,10 @@ function getS3Client(opt: GetAdapterOptionParam): S3Adapter { responseCallback: debugResponse(S3_MODE), uplogBufferSize: adapterOption.uplogBufferSize, }) as S3Adapter; + const shareSession = getShareSession(); + if (shareSession) { + s3Client.addBucketNameIdCache(shareSession.bucketName, shareSession.bucketId); + } s3AdaptersCache[cacheKey] = s3Client; return s3Client; } @@ -222,6 +254,7 @@ function getKodoClient(opt: GetAdapterOptionParam): KodoAdapter { const qiniuAdapter = getQiniuAdapter( adapterOption.accessKey, adapterOption.secretKey, + adapterOption.sessionToken, adapterOption.ucUrl, adapterOption.regions, ); @@ -265,6 +298,38 @@ export function getRegionService(opt: GetAdapterOptionParam): RegionService { return regionService; } +export interface GetShareServiceOptions { + apiUrls?: string[], + accessKey?: string, + accessSecret?: string, + endpointType?: EndpointType, +} + +export async function getShareService(opt: GetShareServiceOptions): Promise { + let ucUrl: string | undefined; + if (opt.accessKey && opt.accessSecret) { + const endpointConfig = getEndpointConfig({ + accessKey: opt.accessKey, + accessSecret: opt.accessSecret, + endpointType: opt.endpointType ?? EndpointType.Public, + }); + if (!endpointConfig.state.initialized) { + await endpointConfig.loadFromPersistence(); + } + ucUrl = endpointConfig.get("ucUrl"); + } + return new ShareService({ + ucUrl: ucUrl, + apiUrls: opt.apiUrls, + ak: opt.accessKey, + sk: opt.accessSecret, + appName: AppConfig.app.id, + appVersion: AppConfig.app.version, + requestCallback: debugRequest(KODO_MODE), + responseCallback: debugResponse(KODO_MODE), + }); +} + export function clearAllCache() { Object.keys(s3AdaptersCache).forEach((key) => { s3AdaptersCache[key].clearCache(); diff --git a/src/renderer/modules/qiniu-client/files.test.ts b/src/renderer/modules/qiniu-client/files.test.ts index fc66dbaf..29373922 100644 --- a/src/renderer/modules/qiniu-client/files.test.ts +++ b/src/renderer/modules/qiniu-client/files.test.ts @@ -29,6 +29,7 @@ import { } from "kodo-s3-adapter-sdk/dist/adapter"; import Duration from "@common/const/duration"; +import {EndpointType} from "@renderer/modules/auth"; import * as QiniuClientCommon from "./common"; import * as QiniuClientFile from "./files"; @@ -78,7 +79,7 @@ describe("test qiniu-client/files.ts", () => { const mockOpt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; @@ -606,6 +607,7 @@ describe("test qiniu-client/files.ts", () => { mockDataKey, undefined, 10, + name === "S3" ? "path" : "bucketEndpoint", mockOpt, ); expect(QiniuClientCommon.getDefaultClient).toBeCalledTimes(1); @@ -621,6 +623,7 @@ describe("test qiniu-client/files.ts", () => { }, undefined, expectDeadline, + name === "S3" ? "path" : "bucketEndpoint", ); } finally { jest.useRealTimers(); diff --git a/src/renderer/modules/qiniu-client/files.ts b/src/renderer/modules/qiniu-client/files.ts index 3a5b7d3d..c70ac10b 100644 --- a/src/renderer/modules/qiniu-client/files.ts +++ b/src/renderer/modules/qiniu-client/files.ts @@ -1,8 +1,12 @@ import * as qiniuPathConvertor from "qiniu-path/dist/src/convert"; import { Path as QiniuPath } from "qiniu-path/dist/src/path"; import { Adapter, Domain, FrozenInfo, ObjectInfo, PartialObjectError, StorageClass, TransferObject } from 'kodo-s3-adapter-sdk/dist/adapter' + +import {BackendMode} from "@common/qiniu"; import Duration from "@common/const/duration"; +import {EndpointType} from "@renderer/modules/auth"; + import * as FileItem from "./file-item"; import { GetAdapterOptionParam, getDefaultClient } from "./common" @@ -321,12 +325,32 @@ export async function restoreFile( }); } +export function getStyleForSignature({ + domain, + preferBackendMode, + currentEndpointType, +}: { + domain: Domain | undefined, + preferBackendMode: BackendMode | undefined, + currentEndpointType: EndpointType, +}): "path" | "virtualHost" | "bucketEndpoint" { + if (domain?.apiScope === BackendMode.S3) { + return "bucketEndpoint"; + } else if (!domain || preferBackendMode === BackendMode.S3) { + // some private cloud not support wildcard domain name resolving + return currentEndpointType === EndpointType.Public ? "virtualHost" : "path"; + } else { + return "bucketEndpoint"; + } +} + export async function signatureUrl( region: string, bucket: string, key: QiniuPath | string, domain: Domain | undefined, expires: number, // seconds + style: "path" | "virtualHost" | "bucketEndpoint", opt: GetAdapterOptionParam, ): Promise { const deadline = new Date(); @@ -341,6 +365,7 @@ export async function signatureUrl( }, domain, deadline, + style, ); }, { targetBucket: bucket, @@ -884,6 +909,7 @@ export async function signatureUrls( items: FileItem.Item[], domain: Domain | undefined, expires: number, + style: "path" | "virtualHost" | "bucketEndpoint", progressFn: (progress: Progress) => void, onEachSuccess: (file: FileItem.File, url: URL) => void, onError: (err: any) => void, @@ -927,6 +953,7 @@ export async function signatureUrls( }, domain, deadline, + style, ); onEachSuccess(file, url); progressCallback(i, null); diff --git a/src/renderer/modules/qiniu-client/index.ts b/src/renderer/modules/qiniu-client/index.ts index a74ba8cc..252fc427 100644 --- a/src/renderer/modules/qiniu-client/index.ts +++ b/src/renderer/modules/qiniu-client/index.ts @@ -6,3 +6,4 @@ export * from "./bucket-item"; export * from "./files"; export * as FileItem from "./file-item"; export * from "./regions"; +export * from "./share"; diff --git a/src/renderer/modules/qiniu-client/region.test.ts b/src/renderer/modules/qiniu-client/region.test.ts index 77c7d38d..ec44df16 100644 --- a/src/renderer/modules/qiniu-client/region.test.ts +++ b/src/renderer/modules/qiniu-client/region.test.ts @@ -27,6 +27,8 @@ import { S3 as S3Adapter } from "kodo-s3-adapter-sdk/dist/s3"; import { GetAllRegionsOptions, RegionService } from "kodo-s3-adapter-sdk/dist/region_service"; import { mocked } from "ts-jest/utils"; +import {EndpointType} from "@renderer/modules/auth"; + import * as QiniuClientCommon from "./common"; import * as QiniuClientRegions from "./regions"; @@ -38,7 +40,7 @@ describe("test qiniu-client/region.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; const MockedRegionService = mocked(RegionService, true); const MockedKodoAdapter = mocked(KodoAdapter, true); diff --git a/src/renderer/modules/qiniu-client/share.ts b/src/renderer/modules/qiniu-client/share.ts new file mode 100644 index 00000000..f3325038 --- /dev/null +++ b/src/renderer/modules/qiniu-client/share.ts @@ -0,0 +1,40 @@ +import { + CheckShareOptions, + CreateShareOptions, + CreateShareResult, + VerifyShareOptions, + VerifyShareResult, +} from "kodo-s3-adapter-sdk/dist/share-service"; + +import {getShareService, GetShareServiceOptions} from "@renderer/modules/qiniu-client/common"; + +export async function getShareApiHosts( + portalHosts: string[], +): Promise { + const shareService = await getShareService({}); + return await shareService.getApiHosts(portalHosts); +} + +export async function createShare( + param: CreateShareOptions, + opt: Required, +): Promise { + const shareService = await getShareService(opt); + return await shareService.createShare(param); +} + +export async function checkShare( + param: CheckShareOptions, + opt: GetShareServiceOptions, +): Promise { + const shareService = await getShareService(opt); + await shareService.checkShare(param); +} + +export async function verifyShare( + param: VerifyShareOptions, + opt: GetShareServiceOptions, +): Promise { + const shareService = await getShareService(opt); + return await shareService.verifyShare(param); +} diff --git a/src/renderer/modules/qiniu-client/utils.test.ts b/src/renderer/modules/qiniu-client/utils.test.ts index c206d246..1fbaf5e6 100644 --- a/src/renderer/modules/qiniu-client/utils.test.ts +++ b/src/renderer/modules/qiniu-client/utils.test.ts @@ -35,6 +35,8 @@ import { Kodo as KodoAdapter } from "kodo-s3-adapter-sdk/dist/kodo"; import { S3 as S3Adapter } from "kodo-s3-adapter-sdk/dist/s3"; import * as KodoNav from "@renderer/const/kodo-nav"; +import {EndpointType} from "@renderer/modules/auth"; + import * as QiniuClientCommon from "./common"; import * as QiniuClientFiles from "./files"; import * as QiniuClientUtils from "./utils"; @@ -105,7 +107,7 @@ describe("test qiniu-client/utils.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; beforeEach(() => { spiedGetDefaultClient.mockClear(); @@ -158,7 +160,7 @@ describe("test qiniu-client/utils.ts", () => { const opt: QiniuClientCommon.GetAdapterOptionParam = { id: ENV.QINIU_ACCESS_KEY, secret: ENV.QINIU_SECRET_KEY, - isPublicCloud: true, + endpointType: EndpointType.Public, }; it("just check call checkFolderExists or checkFileExists", async () => { const mockedCheckFolder = jest.spyOn(QiniuClientFiles, "checkFolderExists") diff --git a/src/renderer/modules/user-config-store/endpoint-config.ts b/src/renderer/modules/user-config-store/endpoint-config.ts index 38193fb5..128989b9 100644 --- a/src/renderer/modules/user-config-store/endpoint-config.ts +++ b/src/renderer/modules/user-config-store/endpoint-config.ts @@ -2,12 +2,12 @@ import {useMemo, useSyncExternalStore} from "react"; import {HttpUrl} from "@renderer/const/patterns"; import * as DefaultDict from "@renderer/modules/default-dict"; -import {AkItem, EndpointType} from "@renderer/modules/auth"; +import {AkItem, EndpointType, ShareSession} from "@renderer/modules/auth"; import {Endpoint} from "@renderer/modules/qiniu-client"; import {LocalFile, serializer} from "@renderer/modules/persistence"; +import handleLoadError from "./error-handler"; import UserConfigStore from "./user-config-store"; -import handleLoadError from "@renderer/modules/user-config-store/error-handler"; const DEFAULT_ENDPOINT: Endpoint = { ucUrl: "", @@ -38,7 +38,7 @@ export function getEndpointConfig(akItem: AkItem | null) { return privateEndpointConfig; } -export function useEndpointConfig(akItem: AkItem | null) { +export function useEndpointConfig(akItem: AkItem | null, shareSession?: ShareSession | null) { const endpointConfig = useMemo( () => getEndpointConfig(akItem), [akItem] @@ -53,14 +53,44 @@ export function useEndpointConfig(akItem: AkItem | null) { ); const setEndpoint = (endpoint: Endpoint) => { + if (shareSession) { + throw new Error("Can't set ShareSession Endpoint") + } return endpointConfig.setAll(endpoint, false); }; const endpointValid = useMemo( - () => endpointConfigData.ucUrl && endpointConfigData.ucUrl.match(HttpUrl), + () => { + if (shareSession) { + return true; + } + return endpointConfigData.ucUrl && endpointConfigData.ucUrl.match(HttpUrl); + }, [endpointConfigData.ucUrl], ); + if (shareSession) { + return { + endpointConfigState: { + initialized: true, + loadingPersistence: false, + loadError: null, + changedPersistenceValue: false, + valid: endpointValid, + }, + endpointConfigData: { + ucUrl: "", + regions: [{ + identifier: shareSession.regionS3Id, + label: shareSession.regionS3Id, + endpoint: shareSession.endpoint, + }], + }, + endpointConfigLoadPersistencePromise: Promise.resolve(), + setEndpoint, + } + } + return { endpointConfigState: { ...endpointConfigState, diff --git a/src/renderer/pages/browse-share/contents.tsx b/src/renderer/pages/browse-share/contents.tsx new file mode 100644 index 00000000..014640d5 --- /dev/null +++ b/src/renderer/pages/browse-share/contents.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import {Region} from "kodo-s3-adapter-sdk"; + +import {BackendMode} from "@common/qiniu"; + +import {useAuth} from "@renderer/modules/auth"; +import {Provider as FileOperationProvider} from "@renderer/modules/file-operation"; +import {BucketItem} from "@renderer/modules/qiniu-client"; + +import Files from "../browse/files"; + +interface ContentsProps { + toggleRefresh?: boolean, +} + +const Contents: React.FC = ({ + toggleRefresh +}) => { + const {shareSession} = useAuth(); + + if (!shareSession) { + return ( + <> + no share session + + ); + } + + const bucket: BucketItem = { + id: shareSession.bucketId, + name: shareSession.bucketName, + // can't get create data of a share path. + createDate: new Date(NaN), + regionId: shareSession.regionS3Id, + preferBackendMode: BackendMode.S3, + grantedPermission: shareSession.permission === "READWRITE" ? "readwrite" : "readonly", + } + const region: Region = new Region( + "", + shareSession.regionS3Id, + ); + region.s3Urls = [shareSession.endpoint] + + return ( + +
+ +
+
+ ); +}; + +export default Contents; diff --git a/src/renderer/pages/browse-share/index.tsx b/src/renderer/pages/browse-share/index.tsx new file mode 100644 index 00000000..b67e6d39 --- /dev/null +++ b/src/renderer/pages/browse-share/index.tsx @@ -0,0 +1,84 @@ +import React, {useCallback, useEffect, useState} from "react"; +import lodash from "lodash"; +import {toast} from "react-hot-toast"; + +import {useAuth} from "@renderer/modules/auth"; +import {ADDR_KODO_PROTOCOL} from "@renderer/const/kodo-nav"; +import { + KodoAddress, + KodoNavigator, + Provider as KodoNavigatorProvider, +} from "@renderer/modules/kodo-address"; +import {useBookmarkPath} from "@renderer/modules/user-config-store"; + +import LoadingHolder from "@renderer/components/loading-holder"; +import KodoAddressBar from "@renderer/components/kodo-address-bar"; + +import Transfer from "../browse/transfer"; +import Contents from "./contents"; + +interface BrowseShareProps {} + +const BrowseShare: React.FC = () => { + const {currentUser, shareSession} = useAuth(); + + const [kodoNavigator, setKodoNavigator] = useState(); + const [toggleRefresh, setToggleRefresh] = useState(true); + + const toggleRefreshThrottled = useCallback(lodash.throttle(() => { + setToggleRefresh(v => !v); + }, 300), []); + + const {setHome} = useBookmarkPath(currentUser); + + // initial kodo navigator + useEffect(() => { + if (!currentUser || !shareSession) { + return; + } + const homeAddress: KodoAddress = { + protocol: ADDR_KODO_PROTOCOL, + path: `${shareSession.bucketName}/${shareSession.prefix}`, + } + const kodoNav = new KodoNavigator({ + defaultProtocol: ADDR_KODO_PROTOCOL, + maxHistory: 100, + initAddress: homeAddress, + lockPrefix: homeAddress.path, + }); + setKodoNavigator(kodoNav); + setHome(homeAddress) + .catch(e=> { + toast.error(e.toString()); + }); + }, [currentUser]); + + // render + if (!currentUser) { + return ( + <>not sign in + ); + } + + if (!kodoNavigator) { + return ( + + ); + } + + return ( + + + + + + ); +}; + +export default BrowseShare; diff --git a/src/renderer/pages/browse/files/file-content.tsx b/src/renderer/pages/browse/files/file-content.tsx index 4689be6b..1358062c 100644 --- a/src/renderer/pages/browse/files/file-content.tsx +++ b/src/renderer/pages/browse/files/file-content.tsx @@ -8,9 +8,11 @@ import {useKodoNavigator} from "@renderer/modules/kodo-address"; import {ContentViewStyle} from "@renderer/modules/user-config-store"; import {FileItem} from "@renderer/modules/qiniu-client"; import {DomainAdapter} from "@renderer/modules/qiniu-client-hooks"; +import {useFileOperation} from "@renderer/modules/file-operation"; import {useDisplayModal} from "@renderer/components/modals/hooks"; import {OperationDoneRecallFn} from "@renderer/components/modals/file/types"; +import CreateDirShareLink from "@renderer/components/modals/file/create-dir-share-link"; import GenerateFileLink from "@renderer/components/modals/file/generate-file-link"; import RestoreFile from "@renderer/components/modals/file/restore-file"; import ChangeFileStorageClass from "@renderer/components/modals/file/change-file-storage-class"; @@ -20,7 +22,6 @@ import PreviewFile from "@renderer/components/modals/preview-file"; import {OperationName} from "./types"; import FileTable from "./file-table"; import FileGrid from "./file-grid"; -import {useFileOperation} from "@renderer/modules/file-operation"; interface FileContentProps { viewStyle: ContentViewStyle, @@ -64,6 +65,21 @@ const FileContent: React.FC = ({ const {bucketGrantedPermission} = useFileOperation(); // modal states + const [ + { + show: isShowShareDir, + data: { + fileItem: fileItemForShareDir, + }, + }, + { + showModal: handleClickShareDir, + hideModal: handleHideShareDir, + }, + ] = useDisplayModal<{ fileItem: FileItem.Folder | null }>({ + fileItem: null + }); + const [ { show: isShowGenerateFileLink, @@ -154,6 +170,10 @@ const FileContent: React.FC = ({ case OperationName.Download: onDownloadFile(file); break; + case OperationName.ShareDir: + // TODO: should we toast error when no s3 domain available? + FileItem.isItemFolder(file) && handleClickShareDir({fileItem: file}); + break; case OperationName.GenerateLink: if (!selectDomain) { toast.error(translate("common.noDomainToGet")); @@ -243,6 +263,12 @@ const FileContent: React.FC = ({ bucketName={bucketName} fileItem={fileItemForRestore} /> + handleHideShareDir({fileItem: null})} + bucketName={bucketName} + fileItem={fileItemForShareDir} + /> handleHideGeneratingLink({fileItem: null})} diff --git a/src/renderer/pages/browse/files/file-table/columns/file-operations.tsx b/src/renderer/pages/browse/files/file-table/columns/file-operations.tsx index 86e5f484..b5286833 100644 --- a/src/renderer/pages/browse/files/file-table/columns/file-operations.tsx +++ b/src/renderer/pages/browse/files/file-table/columns/file-operations.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, {useMemo} from "react"; import {useI18n} from "@renderer/modules/i18n"; import {FileItem} from "@renderer/modules/qiniu-client"; @@ -6,6 +6,9 @@ import {useFileOperation} from "@renderer/modules/file-operation"; import TooltipButton from "@renderer/components/tooltip-button"; +import {EndpointType, useAuth} from "@renderer/modules/auth"; +import * as DefaultDict from "@renderer/modules/default-dict"; + import {OperationName, RowCellDataProps} from "../../types"; export interface FileOperationsCellCallbackProps { @@ -21,11 +24,31 @@ const FileOperations: React.FC { const {translate} = useI18n(); + const {currentUser} = useAuth(); const {bucketGrantedPermission} = useFileOperation(); const isFile = FileItem.isItemFile(file); const canRestore = isFile && ["Archive", "DeepArchive"].includes(file.storageClass); + const shouldShowShareDirButton = useMemo(() => { + if (isFile || !currentUser) { + return false; + } + + if (currentUser.endpointType === EndpointType.Public) { + return true; + } + + if ( + currentUser.endpointType === EndpointType.Private && + DefaultDict.get("BASE_SHARE_URL") + ) { + return true; + } + + return false; + }, [isFile, currentUser]); + return ( <> { @@ -56,6 +79,21 @@ const FileOperations: React.FC + { + !shouldShowShareDirButton + ? null + : { + e.stopPropagation(); + onAction(OperationName.ShareDir, file); + }} + /> + } { !isFile ? null diff --git a/src/renderer/pages/browse/files/file-tool-bar.tsx b/src/renderer/pages/browse/files/file-tool-bar.tsx index 89a48f1a..f2a4892e 100644 --- a/src/renderer/pages/browse/files/file-tool-bar.tsx +++ b/src/renderer/pages/browse/files/file-tool-bar.tsx @@ -39,7 +39,6 @@ interface FileToolBarProps { availableStorageClasses?: Record, regionId?: string, bucketName?: string, - bucketPermission?: "readonly" | "readwrite", directoriesNumber: number, listedFileNumber: number, hasMoreFiles: boolean, diff --git a/src/renderer/pages/browse/files/index.tsx b/src/renderer/pages/browse/files/index.tsx index 49b7a4ea..d49c7e4d 100644 --- a/src/renderer/pages/browse/files/index.tsx +++ b/src/renderer/pages/browse/files/index.tsx @@ -18,6 +18,7 @@ import {BucketItem, FileItem} from "@renderer/modules/qiniu-client"; import {DomainAdapter, useLoadDomains, useLoadFiles} from "@renderer/modules/qiniu-client-hooks"; import ipcDownloadManager from "@renderer/modules/electron-ipc-manages/ipc-download-manager"; import * as AuditLog from "@renderer/modules/audit-log"; +import {useFileOperation} from "@renderer/modules/file-operation"; import DropZone from "@renderer/components/drop-zone"; import {useDisplayModal, useIsShowAnyModal} from "@renderer/components/modals/hooks"; @@ -35,7 +36,8 @@ interface FilesProps { const Files: React.FC = (props) => { const {currentLanguage, translate} = useI18n(); - const {currentUser} = useAuth(); + const {currentUser, shareSession} = useAuth(); + const {bucketGrantedPermission} = useFileOperation(); const { state: appPreferencesState, @@ -47,7 +49,7 @@ const Files: React.FC = (props) => { const { endpointConfigData, - } = useEndpointConfig(currentUser); + } = useEndpointConfig(currentUser, shareSession); const {currentAddress, basePath, goTo} = useKodoNavigator(); @@ -195,7 +197,7 @@ const Files: React.FC = (props) => { } return true; }, - canDefaultS3Domain: !props.bucket?.grantedPermission, + canDefaultS3Domain: !bucketGrantedPermission, preferBackendMode: props.bucket?.preferBackendMode, }); const [selectedDomain, setSelectedDomain] = useState(); @@ -310,6 +312,12 @@ const Files: React.FC = (props) => { clientOptions: { accessKey: currentUser.accessKey, secretKey: currentUser.accessSecret, + sessionToken: shareSession?.sessionToken, + bucketNameId: shareSession + ? { + [shareSession.bucketName]: shareSession.bucketId, + } + : undefined, ucUrl: endpointConfigData.ucUrl, regions: endpointConfigData.regions.map(r => ({ id: "", @@ -329,7 +337,6 @@ const Files: React.FC = (props) => { availableStorageClasses={availableStorageClasses} regionId={props.region?.s3Id} bucketName={props.bucket?.name} - bucketPermission={props.bucket?.grantedPermission} directoriesNumber={files.filter(f => FileItem.isItemFolder(f)).length} listedFileNumber={files.length} hasMoreFiles={hasMoreFiles} @@ -385,7 +392,7 @@ const Files: React.FC = (props) => { onReloadFiles={handleReloadFiles} /> { - props.bucket?.grantedPermission === "readonly" + bucketGrantedPermission === "readonly" ? null : { // context states const {translate} = useI18n(); + const {state: routeState} = useLocation() as { + state: SignInState + }; + const navigate = useNavigate(); + const signInType: "ak" | "shareLink" = routeState?.type ?? "ak"; const { endpointConfigState, @@ -37,7 +47,7 @@ const SignIn: React.FC = () => { ] = useDisplayModal(); const [ { - show:isShowAccessKeyHistory, + show: isShowAccessKeyHistory, }, { showModal: handleClickAccessKeyHistory, @@ -46,6 +56,7 @@ const SignIn: React.FC = () => { ] = useDisplayModal(); // local states + // AK form const [formDefaultValues, setFormDefaultValues] = useState({ endpointType: EndpointType.Public, accessKey: "", @@ -61,6 +72,52 @@ const SignIn: React.FC = () => { })) }, []); + // Share Link form + const [shareLinkFormDefaultValues, setShareLinkFormDefaultValues] = useState({ + shareLink: "", + extractCode: "", + }); + useEffect(() => { + if ( + routeState?.type !== "shareLink" || + !routeState.data.shareId || + !routeState.data.shareToken + ) { + setShareLinkFormDefaultValues({ + shareLink: "", + extractCode: "", + }); + return; + } + const shareURL = new URL(`${routeState.data.portalHost || DEFAULT_PORTAL_URL}/kodo-shares/verify`); + shareURL.searchParams.set("id", routeState.data.shareId); + shareURL.searchParams.set("token", routeState.data.shareToken); + setShareLinkFormDefaultValues({ + shareLink: shareURL.toString(), + extractCode: routeState.data.extractCode || "", + }); + }, [routeState?.type, routeState?.data]); + + // handle events + const handleSignInType = () => { + if (signInType !== "ak") { + navigate(RoutePath.SignIn); + return; + } + + // share link state + const defaultShareLinkState: SignInState = { + type: "shareLink", + data: { + shareId: "", + shareToken: "", + }, + }; + navigate(RoutePath.SignIn, { + state: defaultShareLinkState, + }); + }; + // render return ( @@ -68,14 +125,40 @@ const SignIn: React.FC = () => { - {translate("signIn.title")} + + { + signInType === "ak" + ? translate("signIn.title") + : translate("signIn.titleShareLink") + } + - + { + signInType === "ak" + ? + : + } + + + + diff --git a/src/renderer/pages/sign-in/sign-in-form.tsx b/src/renderer/pages/sign-in/sign-in-form.tsx index 38f57bbf..b234106c 100644 --- a/src/renderer/pages/sign-in/sign-in-form.tsx +++ b/src/renderer/pages/sign-in/sign-in-form.tsx @@ -57,7 +57,7 @@ const SignInForm: React.FC = ({ isSubmitting, }, } = useForm({ - mode: "onBlur", + mode: "onSubmit", defaultValues: defaultValues, }); diff --git a/src/renderer/pages/sign-in/sign-in-share-form.tsx b/src/renderer/pages/sign-in/sign-in-share-form.tsx new file mode 100644 index 00000000..a8fede41 --- /dev/null +++ b/src/renderer/pages/sign-in/sign-in-share-form.tsx @@ -0,0 +1,203 @@ +import React, {useEffect} from "react"; +import {useNavigate} from "react-router-dom"; +import {toast} from "react-hot-toast"; +import {SubmitHandler, useForm} from "react-hook-form"; +import {Button, Col, Form, Row, Spinner} from "react-bootstrap"; +import {DEFAULT_PORTAL_URL} from "kodo-s3-adapter-sdk/dist/region"; + +import {translate} from "@renderer/modules/i18n"; +import {getShareApiHosts} from "@renderer/modules/qiniu-client"; +import {useAuth} from "@renderer/modules/auth"; +import * as AuditLog from "@renderer/modules/audit-log"; + +import RoutePath from "@renderer/pages/route-path"; + +interface SignInShareFormData { + shareLink: string, + extractCode: string, +} + +interface ParseShareURLResult { + portalHost: string, + shareId: string, + shareToken: string, +} + +function isPublicShareURL(url: string): boolean { + const shareURL = new URL(url.trim()); + const defaultPortalURL = new URL(DEFAULT_PORTAL_URL); + return shareURL.host === defaultPortalURL.host +} + +function parseShareURL(url: string): ParseShareURLResult | null { + const shareURL = new URL(url.trim()); + + const result = { + portalHost: shareURL.origin, + apiHost: shareURL.searchParams.get("apiHost") || undefined, + shareId: shareURL.searchParams.get("id") || "", + shareToken: shareURL.searchParams.get("token") || "", + }; + + if (!result.shareId || !result.shareToken) { + return null; + } + + return result; +} + +interface SignInShareFormProps { + defaultValues: SignInShareFormData, +} + +const SignInShareForm: React.FC = ({ + defaultValues, +}) => { + const navigate = useNavigate(); + const {signInWithShareLink} = useAuth(); + + const { + register, + handleSubmit, + reset, + formState: { + errors, + isSubmitting, + }, + } = useForm({ + mode: "onSubmit", + defaultValues, + }); + useEffect(() => { + reset(defaultValues); + }, [defaultValues]); + + const handleSignIn: SubmitHandler = async (data) => { + const parsedShareURL = parseShareURL(data.shareLink); + if (!parsedShareURL) { + toast.error(translate("signIn.formShareLink.shareLink.feedback.invalidFormat")); + return; + } + + // try to determine the api host from private cloud share link + let apiHosts: string[] = []; + if (!isPublicShareURL(data.shareLink)) { + try { + apiHosts = await getShareApiHosts([parsedShareURL.portalHost]); + } catch (err: any) { + toast.error(err.toString()); + return; + } + } + if ( + !isPublicShareURL(data.shareLink) && + !apiHosts.length + ) { + toast.error(translate("signIn.formShareLink.shareLink.feedback.invalidPrivateFormat")); + return; + } + + // send sign in request + const p = signInWithShareLink({ + apiHosts, + shareId: parsedShareURL.shareId, + shareToken: parsedShareURL.shareToken, + extractCode: data.extractCode.trim(), + }); + p.then(() => { + navigate(RoutePath.BrowseShare); + AuditLog.log(AuditLog.Action.Login); + }); + return toast.promise(p, { + loading: translate("signIn.form.submit"), + success: translate("common.success"), + error: err => `${translate("common.failed")}: ${err}`, + }); + }; + + return ( +
+
+ + + * + {translate("signIn.formShareLink.shareLink.label")} + + + { + return value.trim().startsWith("http"); + } + } + )} + as="textarea" + rows={5} + style={{ + resize: 'none', + }} + placeholder={translate("signIn.formShareLink.shareLink.holder")} + isInvalid={Boolean(errors.shareLink)} + /> + + {translate("signIn.formShareLink.shareLink.feedback.invalidFormat")} + + + + + + * + {translate("signIn.formShareLink.extractCode.label")} + + + + + {translate("signIn.formShareLink.extractCode.feedback.invalidFormat")} + + + +
+ + + + + + +
+ ); +}; + +export default SignInShareForm; diff --git a/src/renderer/pages/sign-out/index.tsx b/src/renderer/pages/sign-out/index.tsx index 33f18bb1..1611367a 100644 --- a/src/renderer/pages/sign-out/index.tsx +++ b/src/renderer/pages/sign-out/index.tsx @@ -1,7 +1,7 @@ import {ipcRenderer} from "electron"; import React, {useEffect, useMemo} from "react"; -import {useNavigate} from "react-router-dom"; +import {useLocation, useNavigate} from "react-router-dom"; import {useAuth} from "@renderer/modules/auth"; import {clearAllCache} from "@renderer/modules/qiniu-client"; @@ -10,12 +10,15 @@ import {useI18n} from "@renderer/modules/i18n"; import LoadingHolder from "@renderer/components/loading-holder"; import * as AuditLog from "@renderer/modules/audit-log"; -import RoutePath from "@renderer/pages/route-path"; +import RoutePath, {SignInState, SignOutState} from "@renderer/pages/route-path"; const SignOut: React.FC = () => { const {translate} = useI18n(); const {currentUser, signOut} = useAuth(); const navigate = useNavigate(); + const {state: routeState} = useLocation() as { + state: SignOutState + }; const memoCurrentUser = useMemo(() => currentUser, []); @@ -31,7 +34,10 @@ const SignOut: React.FC = () => { return signOut(); }) .then(() => { - navigate(RoutePath.SignIn); + const signInState: SignInState = routeState?.data; + navigate(RoutePath.SignIn, { + state: signInState, + }); AuditLog.log(AuditLog.Action.Logout, { from: memoCurrentUser?.accessKey ?? "", }) diff --git a/src/renderer/pages/switch-user/index.tsx b/src/renderer/pages/switch-user/index.tsx index cf76ec61..c2973a37 100644 --- a/src/renderer/pages/switch-user/index.tsx +++ b/src/renderer/pages/switch-user/index.tsx @@ -4,31 +4,31 @@ import React, {useEffect, useMemo} from "react"; import {useLocation, useNavigate} from "react-router-dom"; import {toast} from "react-hot-toast"; -import {AkItem, useAuth} from "@renderer/modules/auth"; +import {useAuth} from "@renderer/modules/auth"; import {clearAllCache} from "@renderer/modules/qiniu-client"; import {useI18n} from "@renderer/modules/i18n"; import * as AuditLog from "@renderer/modules/audit-log"; import LoadingHolder from "@renderer/components/loading-holder"; -import RoutePath from "@renderer/pages/route-path"; +import RoutePath, {SwitchUserState} from "@renderer/pages/route-path"; const SwitchUser: React.FC = () => { const {translate} = useI18n(); - const {currentUser, signIn, signOut} = useAuth(); + const {currentUser, signIn, signOut, signInWithShareSession} = useAuth(); const navigate = useNavigate(); const { - state: akItem, + state: routeState, } = useLocation() as { - state: AkItem, + state: SwitchUserState, }; const memoCurrentUser = useMemo(() => currentUser, []); - useEffect(() => { clearAllCache(); ipcRenderer.send('asynchronous', {key: "signOut"}); + new Promise(resolve => { // make sure work cleared. setTimeout(resolve, 2500); @@ -37,10 +37,23 @@ const SwitchUser: React.FC = () => { return signOut(); }) .then(() => { - return signIn(akItem, true); + switch (routeState?.type) { + case "ak": + return signIn(routeState.data.akItem, true); + case "shareSession": + return signInWithShareSession(routeState.data); + } + return; }) .then(() => { - navigate(RoutePath.Browse); + switch (routeState?.type) { + case "ak": + navigate(RoutePath.Browse); + break; + case "shareSession": + navigate(RoutePath.BrowseShare); + break; + } }) .catch(err => { toast.error(translate("switchUser.error") + err.toString()); @@ -59,8 +72,8 @@ const SwitchUser: React.FC = () => {
- {akItem.accessKey}
- {akItem.description?.trim() && `(${akItem.description})`} + {routeState?.data.akItem.accessKey}
+ {routeState?.data.akItem.description?.trim() && `(${routeState.data.akItem.description})`}
); diff --git a/yarn.lock b/yarn.lock index faa8e5a6..25615f73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7364,10 +7364,10 @@ klona@^2.0.4, klona@^2.0.5: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== -kodo-s3-adapter-sdk@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/kodo-s3-adapter-sdk/-/kodo-s3-adapter-sdk-0.5.1.tgz#82a2e0e8cfdff72c0b95e0cbcdac5dcc04c5e94e" - integrity sha512-lB7RqjU52HMAD86EPOMYn0dKFyZFAabIt3sjD1HsRVSgoBYep/pe0nd8DRsLtyOKVn7UOFhDmXRCHpNv3Ixcqw== +kodo-s3-adapter-sdk@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/kodo-s3-adapter-sdk/-/kodo-s3-adapter-sdk-0.6.0.tgz#d1e5f2687683748ec3bf33602d6ad9b1bc2f396b" + integrity sha512-aq4LBU7GCErC3YZCmIcMhLgfjlYzMwF5WJ80qUT/UqAsk0/mhgrG3oj2yGmVooid6UhBuTe3to7XjddyUkdj3A== dependencies: agentkeepalive "^4.2.1" async-lock "^1.2.4"