From 9c76ce2d71919ee17a2dd048bb32a45a8cf7a196 Mon Sep 17 00:00:00 2001 From: chenjinhua Date: Wed, 16 Sep 2026 15:53:56 +0800 Subject: [PATCH] feat(accounts): support a pinned bucket without ListBuckets Add an optional account-level pinned bucket backed by SQLite. A generic provider wrapper exposes the known bucket without bucket-list discovery, and the app opens directly at the pinned bucket root on startup, account switch, save, or account removal. The pinned root is treated as the account's effective root for upward navigation. Other calls remain unchanged and continue to surface vendor permission errors directly. The bucket is editable without re-entering credentials and an empty value preserves normal account discovery. --- app/src-tauri/Cargo.lock | 1 + app/src-tauri/src/lib.rs | 13 + app/src/App.tsx | 64 ++++- app/src/api.ts | 4 + app/src/components/AccountForm.tsx | 39 ++- app/src/types.ts | 2 + crates/app-core/Cargo.toml | 2 +- crates/app-core/src/lib.rs | 304 ++++++++++++++++------- crates/app-core/src/pinned.rs | 378 +++++++++++++++++++++++++++++ crates/app-core/src/store.rs | 47 +++- 10 files changed, 739 insertions(+), 115 deletions(-) create mode 100644 crates/app-core/src/pinned.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 5ffa321..79d35b6 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -108,6 +108,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" name = "app-core" version = "1.13.0" dependencies = [ + "async-trait", "bytes", "cloud-core", "flate2", diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 51dd995..beb71d4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -375,6 +375,18 @@ fn set_account_domain(state: State<'_, App>, id: String, domain: String) -> Resu .map_err(|e| e.to_string()) } +/// 设置某账号的固定 bucket;空串清除。非空时账号根目录只显示该桶。 +#[tauri::command] +fn set_account_pinned_bucket( + state: State<'_, App>, + id: String, + bucket: String, +) -> Result<(), String> { + state + .set_account_pinned_bucket(&id, &bucket) + .map_err(|e| e.to_string()) +} + /// 读取账号非敏感信息(编辑回填用)。 #[tauri::command] fn get_account(state: State<'_, App>, id: String) -> Option { @@ -2158,6 +2170,7 @@ pub fn run() { list_accounts, list_account_infos, set_account_domain, + set_account_pinned_bucket, add_aliyun_account, add_huawei_account, add_qiniu_account, diff --git a/app/src/App.tsx b/app/src/App.tsx index d393412..5a2f96d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -119,10 +119,18 @@ function openPdfWindow(account: string, entry: Entry) { win.once("tauri://error", (e) => console.error("open pdf window failed", e)); } +/** 固定桶账号的有效根;普通账号的有效根仍是全部桶列表。 */ +function accountRootPath(bucket?: string): string { + const name = bucket?.trim(); + return name ? `${name}/` : ""; +} + export default function App() { const { t } = useI18n(); const [accounts, setAccounts] = useState([]); const [current, setCurrent] = useState(null); + const currentAccount = accounts.find((account) => account.id === current); + const accountRoot = accountRootPath(currentAccount?.pinned_bucket); // 界面偏好(侧栏宽 / 视图 / 主题)持久化在 SQLite(ui_prefs 表)。先用默认值渲染, // 挂载后从后端加载并回填;prefsHydrated 为真前不回写,避免用默认值覆盖已存的偏好。 const prefsHydrated = useRef(false); @@ -723,12 +731,26 @@ export default function App() { const list = await api.listAccountInfos(); setAccounts(list); setCurrent((cur) => cur ?? list[0]?.id ?? null); + return list; }, []); useEffect(() => { refreshAccounts(); }, [refreshAccounts]); + // 固定桶账号没有可发现的“全部桶”界面;首次选择账号时直接落在桶根。 + useEffect(() => { + if (current || accounts.length === 0) return; + const first = accounts[0]; + setCurrent(first.id); + setPath(accountRootPath(first.pinned_bucket)); + }, [accounts, current]); + + // 手工输入根路径或历史记录残留 "" 时,仍然立即回到固定桶根。 + useEffect(() => { + if (current && accountRoot && path === "") setPath(accountRoot); + }, [accountRoot, current, path]); + useEffect(() => { if (prefsHydrated.current) api.setPref("sidebar_width", String(sidebarWidth)).catch(() => {}); @@ -992,7 +1014,11 @@ export default function App() { const selectAccount = (id: string) => { setCurrent(id); - setPath(""); + setPath( + accountRootPath( + accounts.find((account) => account.id === id)?.pinned_bucket, + ), + ); }; const addAccount = async ( @@ -1016,6 +1042,7 @@ export default function App() { sk: string, endpoint: string, customDomain: string, + pinnedBucket: string, ) => { setShowForm(false); const oldId = editInfo?.id; @@ -1046,9 +1073,15 @@ export default function App() { await adders[vendor](id, ak, sk, endpoint); } await api.setAccountDomain(id, customDomain.trim()); - await refreshAccounts(); + await api.setAccountPinnedBucket(id, pinnedBucket.trim()); + const refreshed = await refreshAccounts(); setCurrent(id); - setPath(""); + setPath( + accountRootPath( + refreshed.find((account) => account.id === id)?.pinned_bucket ?? + pinnedBucket, + ), + ); } catch (e) { setError(String(e)); } @@ -1064,15 +1097,19 @@ export default function App() { }; const removeAccount = async (id: string) => { + const wasCurrent = current === id; await api.removeAccount(id); - if (current === id) { - setCurrent(null); - setPath(""); - setEntries([]); - } - await refreshAccounts(); + const nextAccounts = await refreshAccounts(); + if (!wasCurrent) return; + const next = nextAccounts.find((account) => account.id !== id); + setCurrent(next?.id ?? null); + setPath(accountRootPath(next?.pinned_bucket)); + setEntries([]); }; + const navigate = (nextPath: string) => + setPath(accountRoot && nextPath === "" ? accountRoot : nextPath); + const doBatchDelete = async () => { setPendingBatchDelete(false); if (!current || selected.size === 0) return; @@ -2003,7 +2040,7 @@ export default function App() { } else if (matchBinding(e, keys.deleteSelected) && selected.size > 0) { e.preventDefault(); setPendingBatchDelete(true); - } else if (matchBinding(e, keys.parent) && path !== "") { + } else if (matchBinding(e, keys.parent) && path !== accountRoot) { e.preventDefault(); setPath(parentPath(path)); } else if (matchBinding(e, keys.refresh)) { @@ -2025,7 +2062,7 @@ export default function App() { const paletteCommands: PaletteCommand[] = []; if (current) paletteCommands.push({ id: "refresh", label: t("刷新"), run: () => void load() }); - if (path !== "") { + if (path !== accountRoot) { paletteCommands.push({ id: "up", label: t("上一层"), @@ -2136,7 +2173,7 @@ export default function App() { {current ? ( <>
- + goHistory(-1)} onForward={() => goHistory(1)} - canGoUp={path !== ""} + canGoUp={path !== accountRoot} canUpload={path !== ""} busy={busy} filter={filter} @@ -2334,6 +2371,7 @@ export default function App() { accessKeyId: editInfo.access_key_id, endpoint: editInfo.endpoint, customDomain: editInfo.custom_domain, + pinnedBucket: editInfo.pinned_bucket, } : undefined } diff --git a/app/src/api.ts b/app/src/api.ts index 77e61c6..9cfc79c 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -116,6 +116,10 @@ export const renameAccount = (id: string, newId: string) => export const setAccountDomain = (id: string, domain: string) => invoke("set_account_domain", { id, domain }); +/** 设置固定 bucket;空串恢复普通账号根目录。 */ +export const setAccountPinnedBucket = (id: string, bucket: string) => + invoke("set_account_pinned_bucket", { id, bucket }); + export const getAccount = (id: string) => invoke("get_account", { id }); diff --git a/app/src/components/AccountForm.tsx b/app/src/components/AccountForm.tsx index 5dcd234..f9f9e25 100644 --- a/app/src/components/AccountForm.tsx +++ b/app/src/components/AccountForm.tsx @@ -12,6 +12,7 @@ interface Props { accessKeyId: string; endpoint: string; customDomain?: string; + pinnedBucket?: string; }; onSubmit: ( vendor: Vendor, @@ -20,6 +21,7 @@ interface Props { accessKeySecret: string, endpoint: string, customDomain: string, + pinnedBucket: string, ) => void; onClose: () => void; } @@ -38,6 +40,7 @@ export function AccountForm({ initial, onSubmit, onClose }: Props) { ); const [endpointTouched, setEndpointTouched] = useState(editing); const [domain, setDomain] = useState(initial?.customDomain ?? ""); + const [bucket, setBucket] = useState(initial?.pinnedBucket ?? ""); const meta = VENDORS[vendor]; @@ -47,17 +50,21 @@ export function AccountForm({ initial, onSubmit, onClose }: Props) { if (!endpointTouched) setEndpoint(VENDORS[next].endpoint); }; - // 纯改别名(其它字段都没变)不强制重新输入密钥——密钥只在真的要更新凭证时才必填, - // 不然用户光是想改个名字都要翻出密钥重新输一遍。 - const onlyIdChanged = + // 只有凭证或 endpoint 会参与重建 provider;改名、公共域名和固定桶可以不重输密钥。 + const credentialsChanged = editing && - id !== initial!.id && - ak === initial!.accessKeyId && - endpoint === initial!.endpoint && - domain === (initial!.customDomain ?? ""); + (ak !== initial!.accessKeyId || endpoint !== initial!.endpoint); const valid = editing - ? !!id && !!ak && !!endpoint && (!!sk || onlyIdChanged) - : !!id && !!ak && !!sk && !!endpoint; + ? !!id && + !!ak && + !!endpoint && + (!credentialsChanged || !!sk) && + (!bucket.trim() || !bucket.trim().includes("/")) + : !!id && + !!ak && + !!sk && + !!endpoint && + (!bucket.trim() || !bucket.trim().includes("/")); return (
@@ -101,8 +108,8 @@ export function AccountForm({ initial, onSubmit, onClose }: Props) { +
@@ -138,7 +153,7 @@ export function AccountForm({ initial, onSubmit, onClose }: Props) { diff --git a/app/src/types.ts b/app/src/types.ts index a347f2c..45d8c61 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -49,6 +49,8 @@ export interface AccountInfo { endpoint: string; /** 自定义公共域名(CDN / CNAME);空表示未配置。 */ custom_domain: string; + /** 可选固定 bucket;空表示普通多桶账号。 */ + pinned_bucket: string; } /** 一个收藏的位置(账号 + 路径),与 Rust 端 app_core::Bookmark 对应。 */ diff --git a/crates/app-core/Cargo.toml b/crates/app-core/Cargo.toml index fb28466..996d02e 100644 --- a/crates/app-core/Cargo.toml +++ b/crates/app-core/Cargo.toml @@ -30,6 +30,7 @@ provider-jdcloud = { path = "../providers/provider-jdcloud" } provider-upyun = { path = "../providers/provider-upyun" } bytes = { workspace = true } futures = { workspace = true } +async-trait = "0.1" # 断点续传上传要按偏移读本地文件分片;rt 提供 spawn_blocking(图片解码放阻塞线程池)。 tokio = { workspace = true, features = ["fs", "io-util", "rt"] } serde = { workspace = true } @@ -52,4 +53,3 @@ keyring = { version = "3", default-features = false, features = [ [dev-dependencies] # test-util 提供虚拟时钟(start_paused),用于确定性地测限速。 tokio = { workspace = true, features = ["test-util"] } -async-trait = "0.1" diff --git a/crates/app-core/src/lib.rs b/crates/app-core/src/lib.rs index 2df1437..1a336b0 100644 --- a/crates/app-core/src/lib.rs +++ b/crates/app-core/src/lib.rs @@ -21,6 +21,7 @@ mod limits; mod manifest; mod matting_plugin; mod pdf; +mod pinned; mod preview; mod secret; mod settings; @@ -61,6 +62,7 @@ pub use nebula_provider::{ ByteStream, Capabilities, CorsRule, EntryKind, Grant, IncompleteUpload, LifecycleRule, ObjectVersion, Page, Permission, ProgressFn, WebsiteConfig, }; +use pinned::PinnedBucketProvider; pub use preview::TextPreview; pub use secret::{KeyringSecrets, MemorySecrets, SecretStore}; pub use settings::Settings; @@ -139,6 +141,8 @@ pub struct AccountInfo { pub endpoint: String, /// 自定义公共域名(CDN / CNAME);空表示未配置。 pub custom_domain: String, + /// 可选固定 bucket;空表示按普通多桶账号发现。 + pub pinned_bucket: String, } /// 传输面板任务的持久化记录,用于跨重启恢复列表。字段与前端 TransferItem 对应; @@ -317,94 +321,150 @@ impl App { /// 按厂商把一条记录 + 密钥注册为 provider(未知厂商忽略)。 fn register_record(&self, rec: &AccountRecord, secret: &str) { match rec.vendor.as_str() { - VENDOR_ALIYUN => self.registry.register(Arc::new(AliyunProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_HUAWEI => self.registry.register(Arc::new(HuaweiProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_QINIU => self.registry.register(Arc::new(QiniuProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_AWS => self.registry.register(Arc::new(AwsProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_R2 => self.registry.register(Arc::new(R2Provider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_MINIO => self.registry.register(Arc::new(MinioProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_TENCENT => self.registry.register(Arc::new(TencentProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_B2 => self.registry.register(Arc::new(B2Provider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_WASABI => self.registry.register(Arc::new(WasabiProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_DO_SPACES => self.registry.register(Arc::new(DoSpacesProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_SCALEWAY => self.registry.register(Arc::new(ScalewayProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_US3 => self.registry.register(Arc::new(Us3Provider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_JDCLOUD => self.registry.register(Arc::new(JdCloudProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), - VENDOR_UPYUN => self.registry.register(Arc::new(UpyunProvider::new( - rec.id.clone(), - rec.access_key_id.clone(), - secret.to_string(), - rec.endpoint.clone(), - ))), + VENDOR_ALIYUN => self.register_pinned( + rec, + Arc::new(AliyunProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_HUAWEI => self.register_pinned( + rec, + Arc::new(HuaweiProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_QINIU => self.register_pinned( + rec, + Arc::new(QiniuProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_AWS => self.register_pinned( + rec, + Arc::new(AwsProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_R2 => self.register_pinned( + rec, + Arc::new(R2Provider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_MINIO => self.register_pinned( + rec, + Arc::new(MinioProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_TENCENT => self.register_pinned( + rec, + Arc::new(TencentProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_B2 => self.register_pinned( + rec, + Arc::new(B2Provider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_WASABI => self.register_pinned( + rec, + Arc::new(WasabiProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_DO_SPACES => self.register_pinned( + rec, + Arc::new(DoSpacesProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_SCALEWAY => self.register_pinned( + rec, + Arc::new(ScalewayProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_US3 => self.register_pinned( + rec, + Arc::new(Us3Provider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_JDCLOUD => self.register_pinned( + rec, + Arc::new(JdCloudProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), + VENDOR_UPYUN => self.register_pinned( + rec, + Arc::new(UpyunProvider::new( + rec.id.clone(), + rec.access_key_id.clone(), + secret.to_string(), + rec.endpoint.clone(), + )), + ), _ => {} } } + /// Wrap a freshly constructed vendor provider so only root discovery changes when a + /// credential is intentionally scoped to one bucket. Empty keeps the provider untouched. + fn register_pinned(&self, rec: &AccountRecord, provider: Arc) { + let provider: Arc = if rec.pinned_bucket.is_empty() { + provider + } else { + Arc::new(PinnedBucketProvider::new( + provider, + rec.pinned_bucket.clone(), + )) + }; + self.registry.register(provider); + } + /// 注册一个账号 / provider(以 `provider.id()` 为键)。不持久化。 pub fn add_account(&self, provider: Arc) { self.registry.register(provider); @@ -430,6 +490,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -458,6 +519,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -485,6 +547,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -512,6 +575,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -539,6 +603,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -566,6 +631,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -594,6 +660,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -621,6 +688,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -648,6 +716,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -675,6 +744,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -702,6 +772,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -729,6 +800,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -756,6 +828,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -783,6 +856,7 @@ impl App { access_key_secret: String::new(), endpoint: endpoint.into(), custom_domain: String::new(), + pinned_bucket: String::new(), }; if let Some(store) = &self.store { store.upsert(&rec)?; @@ -862,6 +936,7 @@ impl App { access_key_id: r.access_key_id, endpoint: r.endpoint, custom_domain: r.custom_domain, + pinned_bucket: r.pinned_bucket, }) } @@ -873,6 +948,40 @@ impl App { Ok(()) } + /// 设置账号的固定 bucket;空串恢复普通账号。修改后立即重建已注册 provider。 + pub fn set_account_pinned_bucket(&self, account: &str, bucket: &str) -> Result<()> { + let bucket = bucket.trim(); + if bucket.contains('/') { + return Err(AppError::InvalidInput(format!( + "固定 bucket 不能包含路径分隔符: {bucket}" + ))); + } + let Some(store) = &self.store else { + return Err(AppError::InvalidInput( + "当前未启用本地存储,无法设置固定 bucket".into(), + )); + }; + let Some(_) = store.get(account)? else { + return Err(AppError::NoSuchProvider(account.to_string())); + }; + + let registered = self.registry.get(account).is_some(); + let secret = if registered { + Some(self.secrets.get(account)?) + } else { + None + }; + store.set_pinned_bucket(account, bucket)?; + if registered { + self.registry.remove(account); + let Some(rec) = store.get(account)? else { + return Err(AppError::NoSuchProvider(account.to_string())); + }; + self.register_record(&rec, &secret.expect("registered accounts have a secret")); + } + Ok(()) + } + /// 浏览某账号下某路径(桶 / 前缀)的条目。 pub async fn browse(&self, account: &str, path: &str) -> Result> { Ok(self.provider(account)?.list(path).await?) @@ -2955,6 +3064,33 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[tokio::test] + async fn pinned_account_discovers_bucket_without_list_buckets() { + let path = temp_db("pinned"); + let secrets: Arc = Arc::new(MemorySecrets::default()); + let app = App::with_store_and_secrets(&path, secrets).unwrap(); + app.add_aliyun_account("scoped", "ak", "sk", "oss-cn-hangzhou.aliyuncs.com") + .unwrap(); + + app.set_account_pinned_bucket("scoped", "only-bucket") + .unwrap(); + assert_eq!( + app.account_info("scoped").unwrap().pinned_bucket, + "only-bucket" + ); + assert_eq!( + app.browse("scoped", "").await.unwrap(), + vec![Entry::directory("only-bucket")] + ); + assert!(app + .set_account_pinned_bucket("scoped", "bad/bucket") + .is_err()); + + app.set_account_pinned_bucket("scoped", "").unwrap(); + assert_eq!(app.account_info("scoped").unwrap().pinned_bucket, ""); + let _ = std::fs::remove_file(&path); + } + #[test] fn secret_is_not_written_to_sqlite() { let path = temp_db("nosecret"); diff --git a/crates/app-core/src/pinned.rs b/crates/app-core/src/pinned.rs new file mode 100644 index 0000000..af63c1b --- /dev/null +++ b/crates/app-core/src/pinned.rs @@ -0,0 +1,378 @@ +//! Root-directory adaptation for credentials scoped to one bucket. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; + +use nebula_provider::{ + path, ByteStream, CorsRule, Entry, Grant, IncompleteUpload, LifecycleRule, Page, ProgressFn, + StorageProvider, WebsiteConfig, +}; + +/// Presents a single known bucket at the provider root without calling ListBuckets. +/// +/// Only discovery at the root is redirected. Object, prefix, and bucket-level calls keep +/// the vendor's existing behavior, including its own permission errors. +pub struct PinnedBucketProvider { + inner: Arc, + bucket: String, +} + +impl PinnedBucketProvider { + pub fn new(inner: Arc, bucket: impl Into) -> Self { + Self { + inner, + bucket: bucket.into(), + } + } + + fn root_entry(&self) -> Entry { + Entry::directory(self.bucket.clone()) + } +} + +#[async_trait] +impl StorageProvider for PinnedBucketProvider { + fn id(&self) -> &str { + self.inner.id() + } + + fn capabilities(&self) -> nebula_provider::Capabilities { + self.inner.capabilities() + } + + async fn list(&self, path: &str) -> nebula_provider::Result> { + if path::split(path).0.is_none() { + return Ok(vec![self.root_entry()]); + } + self.inner.list(path).await + } + + async fn list_page(&self, path: &str, cursor: Option) -> nebula_provider::Result { + if path::split(path).0.is_none() { + return Ok(Page { + entries: vec![self.root_entry()], + cursor: None, + }); + } + self.inner.list_page(path, cursor).await + } + + async fn read_stream(&self, path: &str) -> nebula_provider::Result<(Option, ByteStream)> { + self.inner.read_stream(path).await + } + + async fn read_range( + &self, + path: &str, + offset: u64, + ) -> nebula_provider::Result<(Option, ByteStream)> { + self.inner.read_range(path, offset).await + } + + async fn stat(&self, path: &str) -> nebula_provider::Result { + self.inner.stat(path).await + } + + async fn read(&self, path: &str) -> nebula_provider::Result { + self.inner.read(path).await + } + + async fn write( + &self, + path: &str, + data: Bytes, + content_type: Option<&str>, + ) -> nebula_provider::Result<()> { + self.inner.write(path, data, content_type).await + } + + async fn write_with_progress( + &self, + path: &str, + data: Bytes, + content_type: Option<&str>, + progress: ProgressFn<'_>, + ) -> nebula_provider::Result<()> { + self.inner + .write_with_progress(path, data, content_type, progress) + .await + } + + async fn write_stream( + &self, + path: &str, + len: Option, + stream: ByteStream, + content_type: Option<&str>, + progress: ProgressFn<'_>, + ) -> nebula_provider::Result<()> { + self.inner + .write_stream(path, len, stream, content_type, progress) + .await + } + + async fn begin_multipart( + &self, + path: &str, + content_type: Option<&str>, + ) -> nebula_provider::Result { + self.inner.begin_multipart(path, content_type).await + } + + async fn upload_part( + &self, + path: &str, + upload_id: &str, + part_number: u32, + data: Bytes, + ) -> nebula_provider::Result { + self.inner + .upload_part(path, upload_id, part_number, data) + .await + } + + async fn complete_multipart( + &self, + path: &str, + upload_id: &str, + parts: &[(u32, String)], + ) -> nebula_provider::Result<()> { + self.inner.complete_multipart(path, upload_id, parts).await + } + + async fn abort_multipart(&self, path: &str, upload_id: &str) -> nebula_provider::Result<()> { + self.inner.abort_multipart(path, upload_id).await + } + + async fn list_incomplete_uploads( + &self, + bucket: &str, + ) -> nebula_provider::Result> { + self.inner.list_incomplete_uploads(bucket).await + } + + async fn set_storage_class(&self, path: &str, class: &str) -> nebula_provider::Result<()> { + self.inner.set_storage_class(path, class).await + } + + async fn restore(&self, path: &str, days: u32) -> nebula_provider::Result<()> { + self.inner.restore(path, days).await + } + + async fn set_object_acl(&self, path: &str, public: bool) -> nebula_provider::Result<()> { + self.inner.set_object_acl(path, public).await + } + + async fn object_grants(&self, path: &str) -> nebula_provider::Result> { + self.inner.object_grants(path).await + } + + async fn set_object_grants(&self, path: &str, grants: &[Grant]) -> nebula_provider::Result<()> { + self.inner.set_object_grants(path, grants).await + } + + fn public_url(&self, path: &str) -> Option { + self.inner.public_url(path) + } + + async fn set_content_type( + &self, + path: &str, + content_type: &str, + ) -> nebula_provider::Result<()> { + self.inner.set_content_type(path, content_type).await + } + + async fn object_tags(&self, path: &str) -> nebula_provider::Result> { + self.inner.object_tags(path).await + } + + async fn set_object_tags( + &self, + path: &str, + tags: &[(String, String)], + ) -> nebula_provider::Result<()> { + self.inner.set_object_tags(path, tags).await + } + + async fn create_bucket(&self, bucket: &str) -> nebula_provider::Result<()> { + self.inner.create_bucket(bucket).await + } + + async fn delete_bucket(&self, bucket: &str) -> nebula_provider::Result<()> { + self.inner.delete_bucket(bucket).await + } + + async fn bucket_lifecycle(&self, bucket: &str) -> nebula_provider::Result> { + self.inner.bucket_lifecycle(bucket).await + } + + async fn set_bucket_lifecycle( + &self, + bucket: &str, + rules: &[LifecycleRule], + ) -> nebula_provider::Result<()> { + self.inner.set_bucket_lifecycle(bucket, rules).await + } + + async fn bucket_cors(&self, bucket: &str) -> nebula_provider::Result> { + self.inner.bucket_cors(bucket).await + } + + async fn set_bucket_cors( + &self, + bucket: &str, + rules: &[CorsRule], + ) -> nebula_provider::Result<()> { + self.inner.set_bucket_cors(bucket, rules).await + } + + async fn bucket_website(&self, bucket: &str) -> nebula_provider::Result> { + self.inner.bucket_website(bucket).await + } + + async fn set_bucket_website( + &self, + bucket: &str, + config: Option<&WebsiteConfig>, + ) -> nebula_provider::Result<()> { + self.inner.set_bucket_website(bucket, config).await + } + + async fn bucket_versioning(&self, bucket: &str) -> nebula_provider::Result { + self.inner.bucket_versioning(bucket).await + } + + async fn set_bucket_versioning( + &self, + bucket: &str, + enabled: bool, + ) -> nebula_provider::Result<()> { + self.inner.set_bucket_versioning(bucket, enabled).await + } + + async fn list_object_versions( + &self, + path: &str, + ) -> nebula_provider::Result> { + self.inner.list_object_versions(path).await + } + + async fn restore_object_version( + &self, + path: &str, + version_id: &str, + ) -> nebula_provider::Result<()> { + self.inner.restore_object_version(path, version_id).await + } + + async fn delete_object_version( + &self, + path: &str, + version_id: &str, + ) -> nebula_provider::Result<()> { + self.inner.delete_object_version(path, version_id).await + } + + async fn delete(&self, path: &str) -> nebula_provider::Result<()> { + self.inner.delete(path).await + } + + async fn copy(&self, from: &str, to: &str) -> nebula_provider::Result<()> { + self.inner.copy(from, to).await + } + + async fn rename(&self, from: &str, to: &str) -> nebula_provider::Result<()> { + self.inner.rename(from, to).await + } + + async fn presign(&self, path: &str, expires_secs: u64) -> nebula_provider::Result { + self.inner.presign(path, expires_secs).await + } + + async fn presign_put(&self, path: &str, expires_secs: u64) -> nebula_provider::Result { + self.inner.presign_put(path, expires_secs).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nebula_provider::Capabilities; + use std::sync::Mutex; + + #[derive(Default)] + struct RecordedProvider { + listed_paths: Mutex>, + } + + #[async_trait] + impl StorageProvider for RecordedProvider { + fn id(&self) -> &str { + "recorded" + } + + fn capabilities(&self) -> Capabilities { + Capabilities::default() + } + + async fn list(&self, path: &str) -> nebula_provider::Result> { + self.listed_paths.lock().unwrap().push(path.to_string()); + Ok(vec![Entry::file("real-bucket/object", 1)]) + } + + async fn stat(&self, path: &str) -> nebula_provider::Result { + Ok(Entry::file(path, 1)) + } + + async fn read(&self, _path: &str) -> nebula_provider::Result { + Ok(Bytes::new()) + } + + async fn write( + &self, + _path: &str, + _data: Bytes, + _content_type: Option<&str>, + ) -> nebula_provider::Result<()> { + Ok(()) + } + + async fn delete(&self, _path: &str) -> nebula_provider::Result<()> { + Ok(()) + } + } + + fn provider() -> (PinnedBucketProvider, Arc) { + let inner = Arc::new(RecordedProvider::default()); + let recorded = inner.clone(); + (PinnedBucketProvider::new(inner, "pinned"), recorded) + } + + #[tokio::test] + async fn root_listing_is_synthetic_without_list_buckets() { + let (provider, recorded) = provider(); + + let entries = provider.list("").await.unwrap(); + assert_eq!(entries, vec![Entry::directory("pinned")]); + + let page = provider.list_page("/", None).await.unwrap(); + assert_eq!(page.entries, vec![Entry::directory("pinned")]); + assert_eq!(page.cursor, None); + assert!(recorded.listed_paths.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn bucket_paths_delegate_without_rewriting() { + let (provider, recorded) = provider(); + let entries = provider.list("other/objects/").await.unwrap(); + + assert_eq!(entries, vec![Entry::file("real-bucket/object", 1)]); + assert_eq!( + *recorded.listed_paths.lock().unwrap(), + vec!["other/objects/".to_string()] + ); + } +} diff --git a/crates/app-core/src/store.rs b/crates/app-core/src/store.rs index c516868..ac9a7d5 100644 --- a/crates/app-core/src/store.rs +++ b/crates/app-core/src/store.rs @@ -16,6 +16,8 @@ pub struct AccountRecord { pub endpoint: String, /// 可选的自定义公共域名(CDN / CNAME);设了就用它拼永久公共直链。空表示未配置。 pub custom_domain: String, + /// 可选固定 bucket;非空时账号根目录只显示它,避免无 ListBuckets 权限的凭证失败。 + pub pinned_bucket: String, } /// 基于 SQLite 的账号存储。跨命令线程共享,内部用 Mutex 串行化访问。 @@ -35,6 +37,7 @@ impl AccountStore { access_key_secret TEXT NOT NULL, endpoint TEXT NOT NULL, custom_domain TEXT NOT NULL DEFAULT '' + ,pinned_bucket TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -101,6 +104,11 @@ impl AccountStore { "ALTER TABLE accounts ADD COLUMN custom_domain TEXT NOT NULL DEFAULT ''", [], ); + // 对已有库补列;固定 bucket 是最小权限账号发现能力的向后兼容扩展。 + let _ = conn.execute( + "ALTER TABLE accounts ADD COLUMN pinned_bucket TEXT NOT NULL DEFAULT ''", + [], + ); // sync_jobs 的上次结果列(早期没有);已存在则忽略。 let _ = conn.execute( "ALTER TABLE sync_jobs ADD COLUMN last_result TEXT NOT NULL DEFAULT ''", @@ -115,7 +123,7 @@ impl AccountStore { pub fn list(&self) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, vendor, access_key_id, access_key_secret, endpoint, custom_domain + "SELECT id, vendor, access_key_id, access_key_secret, endpoint, custom_domain, pinned_bucket FROM accounts ORDER BY id", )?; let rows = stmt.query_map([], |r| { @@ -126,6 +134,7 @@ impl AccountStore { access_key_secret: r.get(3)?, endpoint: r.get(4)?, custom_domain: r.get(5)?, + pinned_bucket: r.get(6)?, }) })?; rows.collect() @@ -136,8 +145,8 @@ impl AccountStore { let conn = self.conn.lock().unwrap(); // custom_domain 不在这里覆盖(编辑账号凭证时保留已配置的域名),改用 set_custom_domain。 conn.execute( - "INSERT INTO accounts (id, vendor, access_key_id, access_key_secret, endpoint, custom_domain) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO accounts (id, vendor, access_key_id, access_key_secret, endpoint, custom_domain, pinned_bucket) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(id) DO UPDATE SET vendor = ?2, access_key_id = ?3, access_key_secret = ?4, endpoint = ?5", params![ @@ -147,6 +156,7 @@ impl AccountStore { rec.access_key_secret, rec.endpoint, rec.custom_domain + ,rec.pinned_bucket ], )?; Ok(()) @@ -162,11 +172,21 @@ impl AccountStore { Ok(()) } + /// 单独更新某账号的固定 bucket;空串恢复为普通账号。 + pub fn set_pinned_bucket(&self, id: &str, bucket: &str) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE accounts SET pinned_bucket = ?2 WHERE id = ?1", + params![id, bucket], + )?; + Ok(()) + } + /// 按 id 读取一条账号(用于取自定义域名等)。 pub fn get(&self, id: &str) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, vendor, access_key_id, access_key_secret, endpoint, custom_domain + "SELECT id, vendor, access_key_id, access_key_secret, endpoint, custom_domain, pinned_bucket FROM accounts WHERE id = ?1", params![id], |r| { @@ -176,7 +196,8 @@ impl AccountStore { access_key_id: r.get(2)?, access_key_secret: r.get(3)?, endpoint: r.get(4)?, - custom_domain: r.get(5)?, + custom_domain: r.get(5)?, + pinned_bucket: r.get(6)?, }) }, ) @@ -589,9 +610,25 @@ mod tests { access_key_secret: "sk".into(), endpoint: "oss-cn-hangzhou.aliyuncs.com".into(), custom_domain: String::new(), + pinned_bucket: String::new(), } } + #[test] + fn pinned_bucket_roundtrip_and_clearing() { + let store = AccountStore::open(":memory:").unwrap(); + let mut rec = record("scoped"); + rec.pinned_bucket = "only-bucket".into(); + store.upsert(&rec).unwrap(); + assert_eq!( + store.get("scoped").unwrap().unwrap().pinned_bucket, + "only-bucket" + ); + + store.set_pinned_bucket("scoped", "").unwrap(); + assert_eq!(store.get("scoped").unwrap().unwrap().pinned_bucket, ""); + } + #[test] fn crud_roundtrip_in_memory() { let store = AccountStore::open(":memory:").unwrap();