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();