diff --git a/src/components/FilenApiKeyField.tsx b/src/components/FilenApiKeyField.tsx new file mode 100644 index 0000000..5f5b6f0 --- /dev/null +++ b/src/components/FilenApiKeyField.tsx @@ -0,0 +1,209 @@ +import { Button, Input } from '@heroui/react' +import { message } from '@tauri-apps/plugin-dialog' +import { fetch } from '@tauri-apps/plugin-http' +import { EyeIcon, EyeOffIcon } from 'lucide-react' +import { useState } from 'react' +import type { BackendOption } from '../../types/rclone' + +const FILEN_GATEWAY_URL = 'https://gateway.filen.io' + +function toHex(buffer: ArrayBuffer): string { + let hex = '' + for (const byte of new Uint8Array(buffer)) { + hex += byte.toString(16).padStart(2, '0') + } + return hex +} + +async function sha512Hex(input: string): Promise { + return toHex(await crypto.subtle.digest('SHA-512', new TextEncoder().encode(input))) +} + +// POST to the Filen gateway the same way @filen/sdk's APIClient does: anonymous bearer auth plus a +// SHA-512 checksum of the exact body, then unwrap its { status, message, code, data } envelope. +async function filenPost(endpoint: string, data: Record): Promise { + const body = JSON.stringify(data) + + const response = await fetch(`${FILEN_GATEWAY_URL}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer anonymous', + Checksum: await sha512Hex(body), + }, + body, + }) + + if (!response.ok) { + throw new Error(`Filen request failed (${response.status})`) + } + + const json = (await response.json()) as { + status?: boolean + code?: string + message?: string + data?: T + } + + if (json.status === false) { + throw new Error(json.message || json.code || 'Filen rejected the request') + } + + return json.data as T +} + +// Reproduces `filen export-api-key` over raw HTTP: derive the login hash from the plaintext password +// (mirroring @filen/sdk's generatePasswordAndMasterKeyBasedOnAuthVersion), log in, return the apiKey. +export async function generateFilenApiKey({ + email, + password, +}: { + email: string + password: string +}): Promise { + const authInfo = await filenPost<{ authVersion: number; salt: string }>('/v3/auth/info', { + email, + }) + + if (authInfo.authVersion !== 2) { + // v1 is deprecated; v3 uses Argon2id, which WebCrypto can't derive. + throw new Error( + `Unsupported Filen auth version (${authInfo.authVersion}). Run \`filen export-api-key\` and paste the key instead.` + ) + } + + // PBKDF2-HMAC-SHA512, 200k iterations, 512-bit output. The second half of the derived key is + // hashed once more with SHA-512 to form the login password (the first half is the master key). + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: new TextEncoder().encode(authInfo.salt), + iterations: 200000, + hash: 'SHA-512', + }, + keyMaterial, + 512 + ) + const derivedKey = toHex(derivedBits) + const derivedPassword = await sha512Hex(derivedKey.slice(derivedKey.length / 2)) + + const login = await filenPost<{ apiKey: string }>('/v3/login', { + email, + password: derivedPassword, + twoFactorCode: 'XXXXXX', + authVersion: authInfo.authVersion, + }) + + if (!login.apiKey) { + throw new Error('Filen did not return an API key') + } + + return login.apiKey +} + +// Controlled variant of the standard string RemoteField, with a "Generate" button that logs in with +// the email/password already entered in the form and fills the field with the resulting API key. +export default function FilenApiKeyField({ + option, + config, + setConfig, + isDisabled = false, + helpTitle, + helpDescription, +}: { + option: BackendOption + config: Record + setConfig: (config: Record) => void + isDisabled?: boolean + helpTitle: string + helpDescription: string +}) { + const [isGenerating, setIsGenerating] = useState(false) + const [isRevealed, setIsRevealed] = useState(false) + + const email = (config?.email ?? '').trim() + const password = config?.password ?? '' + const canGenerate = Boolean(email && password) + + const handleGenerate = async () => { + setIsGenerating(true) + try { + const apiKey = await generateFilenApiKey({ email, password }) + setConfig((prev: Record) => ({ ...prev, [option.Name]: apiKey })) + } catch (e) { + console.error('[FilenApiKeyField] failed to generate API key', e) + await message(e instanceof Error ? e.message : 'Failed to generate API key', { + title: 'Could not generate API key', + kind: 'error', + }) + } finally { + setIsGenerating(false) + } + } + + return ( + { + setConfig((prev: Record) => ({ + ...prev, + [option.Name]: value, + })) + }} + endContent={ +
+ {option.IsPassword && ( + + )} + +
+ } + isRequired={option.Required} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + description={ + canGenerate + ? helpDescription + : 'Enter your Filen email and password above, then press Generate.' + } + isDisabled={isDisabled} + /> + ) +} diff --git a/src/components/PathPickerField.tsx b/src/components/PathPickerField.tsx new file mode 100644 index 0000000..cd802c6 --- /dev/null +++ b/src/components/PathPickerField.tsx @@ -0,0 +1,82 @@ +import { Button, Input } from '@heroui/react' +import { open } from '@tauri-apps/plugin-dialog' +import { FolderOpenIcon } from 'lucide-react' +import type { BackendOption } from '../../types/rclone' + +// Standard string RemoteField for options that hold a local filesystem path (rclone has no explicit +// "path" flag, so callers opt fields in by name). Adds a button that opens the native file/folder +// picker and fills the field with the chosen path; the field stays freely editable. +export default function PathPickerField({ + option, + config, + setConfig, + isDisabled = false, + helpTitle, + helpDescription, + directory = false, +}: { + option: BackendOption + config: Record + setConfig: (config: Record) => void + isDisabled?: boolean + helpTitle: string + helpDescription: string + directory?: boolean +}) { + const handleBrowse = async () => { + try { + const selected = await open({ + directory, + multiple: false, + title: directory ? 'Select folder' : 'Select file', + }) + + if (typeof selected !== 'string') { + return + } + + setConfig((prev: Record) => ({ ...prev, [option.Name]: selected })) + } catch (e) { + console.error('[PathPickerField] selection failed', e) + } + } + + return ( + { + setConfig((prev: Record) => ({ + ...prev, + [option.Name]: value, + })) + }} + endContent={ + + } + isRequired={option.Required} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + description={helpDescription} + isDisabled={isDisabled} + /> + ) +} diff --git a/src/components/RemoteField.tsx b/src/components/RemoteField.tsx index 8752177..04e5780 100644 --- a/src/components/RemoteField.tsx +++ b/src/components/RemoteField.tsx @@ -1,9 +1,29 @@ import { Autocomplete, AutocompleteItem, Button, Checkbox, Input } from '@heroui/react' import { openUrl } from '@tauri-apps/plugin-opener' -import { ExternalLinkIcon } from 'lucide-react' -import { useMemo } from 'react' +import { ExternalLinkIcon, EyeIcon, EyeOffIcon } from 'lucide-react' +import { useMemo, useState } from 'react' import { OWN_OAUTH_TYPES } from '../../lib/rclone/overrides' import type { BackendOption } from '../../types/rclone' +import FilenApiKeyField from './FilenApiKeyField' +import PathPickerField from './PathPickerField' + +// rclone has no "is a local path" flag, so opt fields in by name. Value = whether it's a directory. +const PATH_PICKER_FIELDS: Record = { + service_account_file: { directory: false }, // drive, google cloud storage + box_config_file: { directory: false }, // box + client_certificate_path: { directory: false }, // azureblob, azurefiles + service_principal_file: { directory: false }, // azureblob, azurefiles + config_file: { directory: false }, // oracleobjectstorage + sse_customer_key_file: { directory: false }, // oracleobjectstorage + shared_credentials_file: { directory: false }, // s3 + key_file: { directory: false }, // sftp + known_hosts_file: { directory: false }, // sftp + pubkey_file: { directory: false }, // sftp + kerberos_ccache: { directory: false }, // smb + chunk_path: { directory: true }, // cache + db_path: { directory: true }, // cache + tmp_upload_path: { directory: true }, // cache +} export default function RemoteField({ option, @@ -32,6 +52,9 @@ export default function RemoteField({ const helpDetails = useMemo(() => option.Help.split('\n').slice(1), [option.Help]) const helpDescription = useMemo(() => helpDetails.join('\n'), [helpDetails]) + // Password fields (rclone `IsPassword`) render obscured; this toggles a plaintext reveal. + const [isRevealed, setIsRevealed] = useState(false) + // console.log( // '[RemoteField] option', // option.Name, @@ -67,6 +90,37 @@ export default function RemoteField({ } if (option.Type === 'string') { + // Filen's api_key can be generated from the account's email + password (mirrors the + // `filen export-api-key` CLI command), so render it with an inline "Generate" button. + if (config?.type === 'filen' && option.Name === 'api_key') { + return ( + + ) + } + + // Local-path options (service account files, certs, cache dirs, …) get a native picker. + const pathPicker = PATH_PICKER_FIELDS[option.Name] + if (pathPicker) { + return ( + + ) + } + const shouldUseAutocomplete = !(config?.provider === 'Other' && option.Name === 'endpoint') && option.Examples && @@ -132,6 +186,38 @@ export default function RemoteField({ OWN_OAUTH_TYPES.includes(config?.type) && (option.Name === 'client_id' || option.Name === 'client_secret') + const inputType = option.IsPassword && !isRevealed ? 'password' : 'text' + + // GUIDE button takes priority over the reveal toggle when both could apply. + const endContent = requiresOwnCredentials ? ( + + ) : option.IsPassword ? ( + + ) : undefined + return ( } - onPress={() => { - openUrl( - `https://rclone.org/${config?.type === 'google photos' ? 'googlephotos' : 'drive'}/#making-your-own-client-id` - ) - }} - > - GUIDE - - ) - } + endContent={endContent} isRequired={option.Required || requiresOwnCredentials} defaultValue={initialFieldValue} autoComplete="off"