Merge pull request #179 from Dvorinka/proxmox-issue-sweep

feat: issue sweep batch
pull/3587/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 96a0f29652
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,10 +1,12 @@
pub mod explorer;
pub mod share;
pub mod site;
pub mod user;
pub mod workflow;
// Re-export for convenience
pub use explorer::ExplorerApi;
pub use share::ShareApi;
pub use site::SiteApi;
pub use user::UserApi;
pub use workflow::WorkflowApi;

@ -0,0 +1,18 @@
use crate::client::{Client, RequestOptions};
use crate::error::ApiResult;
use crate::models::share::*;
use async_trait::async_trait;
/// Share API methods
#[async_trait]
pub trait ShareApi {
/// Create a share link for a file or folder, returns the share URL.
async fn create_share(&self, request: &ShareCreateService) -> ApiResult<String>;
}
#[async_trait]
impl ShareApi for Client {
async fn create_share(&self, request: &ShareCreateService) -> ApiResult<String> {
self.put("/share", request, RequestOptions::new()).await
}
}

@ -46,6 +46,7 @@ pub enum ErrorCode {
IncorrectPassword = 40069,
LockConflict = 40073,
StaleVersion = 40076,
EntityNotExist = 40077,
BatchOperationNotFullyCompleted = 40081,
DomainNotLicensed = 40087,
AnonymousAccessDenied = 40088,
@ -65,6 +66,7 @@ impl ErrorCode {
40069 => Some(Self::IncorrectPassword),
40073 => Some(Self::LockConflict),
40076 => Some(Self::StaleVersion),
40077 => Some(Self::EntityNotExist),
40081 => Some(Self::BatchOperationNotFullyCompleted),
40087 => Some(Self::DomainNotLicensed),
40088 => Some(Self::AnonymousAccessDenied),
@ -198,6 +200,11 @@ impl ApiError {
matches!(self, ApiError::AccessTokenExpired)
}
/// Check if this error reports a missing entity (code 40077)
pub fn is_entity_not_exist(&self) -> bool {
matches!(self, ApiError::ApiError { code, .. } if *code == 40077)
}
/// Check if this error requires login
pub fn requires_login(&self) -> bool {
matches!(

@ -20,3 +20,10 @@ pub struct ListShareResponse {
pub shares: Vec<Share>,
pub pagination: PaginationResults,
}
/// Share create service. Only the target URI is required; the server applies
/// public-link defaults for all other fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShareCreateService {
pub uri: String,
}

@ -499,3 +499,29 @@ pub fn new_my_uri(uid: Option<&str>) -> Result<CrUri> {
None => CrUri::new("cloudreve://my"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_encodes_spaces_and_special_chars() {
let mut uri = CrUri::new("cloudreve://my").unwrap();
uri.join(&["dir with spaces", "file name.txt"]);
let s = uri.to_string();
assert!(
s.contains("dir%20with%20spaces/file%20name.txt"),
"uri: {s}"
);
assert!(!s.contains(' '));
assert_eq!(uri.elements(), vec!["dir with spaces", "file name.txt"]);
}
#[test]
fn encoded_uri_roundtrips_spaces() {
let uri = CrUri::new("cloudreve://my/a%20b/c%20d.txt").unwrap();
assert_eq!(uri.path(), "/a b/c d.txt");
assert!(uri.to_string().contains("a%20b/c%20d.txt"));
}
}

@ -85,6 +85,7 @@ features = [
"Win32_Graphics_Gdi",
"Graphics_Imaging",
"Win32_System_Com_StructuredStorage",
"Win32_System_DataExchange",
"Win32_System_Variant",
"Win32_Storage_EnhancedStorage",
"Win32_UI_Notifications",

@ -20,12 +20,13 @@ use anyhow::{Context, Result};
use bytes::Bytes;
use cloudreve_api::{
ApiError,
api::{ExplorerApi, explorer::ExplorerApiExt},
api::{ExplorerApi, ShareApi, explorer::ExplorerApiExt},
models::{
explorer::{
DeleteFileService, FileResponse, FileURLService, MoveFileService, RenameFileService,
metadata,
},
share::ShareCreateService,
uri::CrUri,
user::Token,
},
@ -151,6 +152,10 @@ pub enum ManagerCommand {
ViewOnline {
path: PathBuf,
},
/// Create a public share link for a file or folder and copy it to the clipboard
CopyShareLink {
path: PathBuf,
},
PersistConfig,
GenerateThumbnail {
path: PathBuf,
@ -234,11 +239,21 @@ impl Mount {
request.entity = Some(meta.etag.clone());
}
}
let entity_url_res = self
.cr_client
.get_file_url(&request)
.await
.context("failed to get file url")?;
let entity_url_res = match self.cr_client.get_file_url(&request).await {
Err(e) if e.is_entity_not_exist() && request.entity.is_some() => {
// The entity id cached in local inventory can go stale when the
// remote file is re-uploaded or migrated; retry letting the
// server pick the primary entity.
tracing::info!(target: "drive::commands", path = %path.display(), "Preferred entity no longer exists, retrying without it");
let mut retry = request.clone();
retry.entity = None;
self.cr_client
.get_file_url(&retry)
.await
.context("failed to get file url")?
}
res => res.context("failed to get file url")?,
};
// Get the download URL from the response
let download_url = entity_url_res
@ -432,6 +447,22 @@ impl Mount {
Ok(thumb_response.bytes().await?)
}
/// Create a public share link for the file or folder at `path`,
/// returning the share URL.
pub async fn create_share_link(&self, path: PathBuf) -> Result<String> {
let (sync_path, remote_base) = {
let config = self.config.read().await;
(config.sync_path.clone(), config.remote_path.to_string())
};
let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base)
.context("failed to convert local path to cloudreve uri")?
.to_string();
Ok(self
.cr_client
.create_share(&ShareCreateService { uri })
.await?)
}
pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> {
// If source or destination is ignored, do nothing
let matcher = self.ignore_matcher.read().await;

@ -41,6 +41,15 @@ impl DriveManager {
tracing::debug!(target: "drive::manager", path = %path.display(), result = ?result, "ViewOnline command result");
});
}
ManagerCommand::CopyShareLink { path } => {
let path = path.clone();
spawn(async move {
let result = manager.handle_copy_share_link(path.clone()).await;
if let Err(e) = result {
tracing::error!(target: "drive::manager", path = %path.display(), error = %e, "CopyShareLink command failed");
}
});
}
ManagerCommand::PersistConfig => {
let result = manager.persist().await;
if let Err(e) = result {
@ -194,6 +203,36 @@ impl DriveManager {
Ok(())
}
/// Handle CopyShareLink command - creates a share link for the file or
/// folder, copies it to the clipboard and notifies the user.
pub(super) async fn handle_copy_share_link(&self, path: PathBuf) -> Result<()> {
tracing::debug!(target: "drive::manager", path = %path.display(), "CopyShareLink command");
let mount = self
.search_drive_by_child_path(path.to_str().unwrap_or(""))
.await
.ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?;
match mount.create_share_link(path.clone()).await {
Ok(url) => {
crate::utils::clipboard::set_text(&url)?;
crate::utils::toast::send_general_text_toast(
&rust_i18n::t!("shareLinkCopied"),
&url,
);
}
Err(e) => {
crate::utils::toast::send_general_text_toast(
&rust_i18n::t!("shareLinkFailed"),
&e.to_string(),
);
return Err(e);
}
}
Ok(())
}
/// Handle ShowConflictToast command
pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> {
tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command");

@ -124,3 +124,29 @@ pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> {
pub fn notify_shell_change(_path: &PathBuf, _event: u32) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_path_to_cr_uri_encodes_spaces() {
let uri = local_path_to_cr_uri(
PathBuf::from("/sync/dir with spaces/file name.txt"),
PathBuf::from("/sync"),
"cloudreve://my".to_string(),
)
.unwrap();
// The wire URI must carry percent-encoded segments, never raw spaces.
assert!(
uri.to_string()
.contains("dir%20with%20spaces/file%20name.txt"),
"uri: {}",
uri.to_string()
);
assert!(!uri.to_string().contains(' '));
// The decoded path round-trips to the original names.
assert_eq!(uri.path(), "/dir with spaces/file name.txt");
}
}

@ -4,6 +4,7 @@
mod explorer_command;
mod factory;
mod resolve_conflict;
mod share_link;
mod sub_commands;
mod sync_now;
mod view_online;
@ -11,6 +12,7 @@ mod view_online;
pub use explorer_command::CrExplorerCommandHandler;
pub use factory::CrExplorerCommandFactory;
pub use resolve_conflict::ResolveConflictCommandHandler;
pub use share_link::ShareLinkCommandHandler;
pub use sub_commands::SubCommands;
pub use sync_now::SyncNowCommandHandler;
pub use view_online::ViewOnlineCommandHandler;

@ -0,0 +1,142 @@
use crate::drive::manager::DriveManager;
use crate::{drive::commands::ManagerCommand, utils::app::AppRoot};
use rust_i18n::t;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use windows::{
Win32::{Foundation::*, System::Com::*, System::Ole::*, UI::Shell::*},
core::*,
};
/// shlguid.h: SID_SFolderView — resolves IFolderView from the command site.
const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce);
#[implement(IExplorerCommand, IObjectWithSite)]
pub struct ShareLinkCommandHandler {
drive_manager: Arc<DriveManager>,
app_root: AppRoot,
site: Mutex<Option<IUnknown>>,
}
impl ShareLinkCommandHandler {
pub fn new(drive_manager: Arc<DriveManager>, app_root: AppRoot) -> Self {
Self {
drive_manager,
app_root,
site: Mutex::new(None),
}
}
fn send_copy_share_link(&self, path: PathBuf) {
tracing::debug!(target: "shellext::context_menu", path = %path.display(), "Copy share link requested");
let command_tx = self.drive_manager.get_command_sender();
if let Err(e) = command_tx.send(ManagerCommand::CopyShareLink { path }) {
tracing::error!(target: "shellext::context_menu", error = %e, "Failed to send CopyShareLink command");
}
}
}
impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl {
fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> {
*self.site.lock().unwrap() = punksite.cloned();
Ok(())
}
fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> {
let site = self.site.lock().unwrap();
if let Some(site) = site.as_ref() {
unsafe { site.query(riid, ppvsite) }.ok()
} else {
Err(Error::from(E_FAIL))
}
}
}
impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl {
fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
let title = t!("copyShareLink");
let hstring = HSTRING::from(title.as_ref());
unsafe { SHStrDupW(&hstring) }
}
fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
let icon_path = format!("{}\\people.ico", self.app_root.image_path());
let hstring = HSTRING::from(icon_path);
unsafe { SHStrDupW(&hstring) }
}
fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
Err(Error::from(E_NOTIMPL))
}
fn GetCanonicalName(&self) -> Result<GUID> {
Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b))
}
fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result<u32> {
let Some(items) = items else {
// Not select anthing, but still triggerd from a folder
return Ok(ECS_ENABLED.0 as u32);
};
unsafe {
let count = items.GetCount()?;
if count <= 1 {
Ok(ECS_ENABLED.0 as u32)
} else {
Ok(ECS_HIDDEN.0 as u32)
}
}
}
fn Invoke(
&self,
selection: Option<&IShellItemArray>,
_bindctx: Option<&IBindCtx>,
) -> Result<()> {
tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked");
if let Some(items) = selection {
unsafe {
let count = items.GetCount()?;
if count != 1 {
return Ok(());
}
// Get the first item
let item = items.GetItemAt(0)?;
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
let path = PathBuf::from(display_name.to_string()?);
self.send_copy_share_link(path);
}
} else {
// Folder-background invoke carries no selection; resolve the
// current folder through the site Explorer gave us in SetSite.
let site = self.site.lock().unwrap().clone();
let Some(site) = site else {
return Ok(());
};
unsafe {
let service_provider: IServiceProvider = site.cast()?;
let folder_view: IFolderView =
service_provider.QueryService(&SID_S_FOLDER_VIEW)?;
let item: IShellItem = folder_view.GetFolder()?;
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
let path = PathBuf::from(display_name.to_string()?);
self.send_copy_share_link(path);
}
}
Ok(())
}
fn GetFlags(&self) -> Result<u32> {
Ok(ECF_DEFAULT.0 as u32)
}
fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> {
Err(Error::from(E_NOTIMPL))
}
}

@ -1,4 +1,7 @@
use super::{ResolveConflictCommandHandler, SyncNowCommandHandler, ViewOnlineCommandHandler};
use super::{
ResolveConflictCommandHandler, ShareLinkCommandHandler, SyncNowCommandHandler,
ViewOnlineCommandHandler,
};
use crate::{drive::manager::DriveManager, utils::app::AppRoot};
use std::sync::{Arc, Mutex};
use windows::{
@ -109,14 +112,16 @@ macro_rules! sub_command_factory {
}
sub_command_factory!(create_view_online_command, ViewOnlineCommandHandler);
sub_command_factory!(create_share_link_command, ShareLinkCommandHandler);
sub_command_factory!(create_sync_now_command, SyncNowCommandHandler);
sub_command_factory!(
create_resolve_conflict_command,
ResolveConflictCommandHandler
);
const SUB_COMMAND_FACTORIES: [SubCommandFactory; 3] = [
const SUB_COMMAND_FACTORIES: [SubCommandFactory; 4] = [
create_view_online_command,
create_share_link_command,
create_sync_now_command,
create_resolve_conflict_command,
];

@ -338,11 +338,19 @@ impl<'a> DownloadTask<'a> {
.clone();
}
let entity_url_res = self
.cr_client
.get_file_url(&request)
.await
.context("failed to get file url")?;
let entity_url_res = match self.cr_client.get_file_url(&request).await {
Err(e) if e.is_entity_not_exist() && request.entity.is_some() => {
// Entity id cached from a previous file info response may be
// stale; retry letting the server pick the primary entity.
let mut retry = request.clone();
retry.entity = None;
self.cr_client
.get_file_url(&retry)
.await
.context("failed to get file url")?
}
res => res.context("failed to get file url")?,
};
let download_url = entity_url_res
.urls

@ -0,0 +1,39 @@
//! Minimal clipboard helpers. Only Windows sets real clipboard text; other
//! platforms no-op so callers can stay unconditional.
#[cfg(windows)]
pub fn set_text(text: &str) -> anyhow::Result<()> {
use windows::Win32::{
Foundation::HANDLE,
System::{
DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData},
Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock},
Ole::CF_UNICODETEXT,
},
};
unsafe {
OpenClipboard(None)?;
let result = (|| -> anyhow::Result<()> {
EmptyClipboard()?;
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
let hglobal = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2)?;
let dst = GlobalLock(hglobal);
if dst.is_null() {
return Err(anyhow::anyhow!("GlobalLock failed"));
}
std::ptr::copy_nonoverlapping(wide.as_ptr(), dst as *mut u16, wide.len());
let _ = GlobalUnlock(hglobal);
SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hglobal.0))?;
Ok(())
})();
let _ = CloseClipboard();
result
}
}
#[cfg(not(windows))]
pub fn set_text(_text: &str) -> anyhow::Result<()> {
tracing::warn!(target: "utils::clipboard", "Clipboard set_text is not supported on this platform");
Ok(())
}

@ -1,2 +1,3 @@
pub mod app;
pub mod clipboard;
pub mod toast;

@ -11,6 +11,42 @@ viewOnline:
ru: "Просмотреть онлайн"
pl: "Zobacz online"
it: "Visualizza online"
copyShareLink:
en-US: "Copy share link"
zh-CN: "复制分享链接"
zh-TW: "複製分享連結"
ja: "共有リンクをコピー"
de: "Freigabelink kopieren"
fr: "Copier le lien de partage"
es: "Copiar enlace para compartir"
ko: "공유 링크 복사"
ru: "Копировать ссылку"
pl: "Kopiuj link udostępniania"
it: "Copia link di condivisione"
shareLinkCopied:
en-US: "Share link copied to clipboard"
zh-CN: "分享链接已复制到剪贴板"
zh-TW: "分享連結已複製到剪貼簿"
ja: "共有リンクをクリップボードにコピーしました"
de: "Freigabelink in die Zwischenablage kopiert"
fr: "Lien de partage copié dans le presse-papiers"
es: "Enlace para compartir copiado al portapapeles"
ko: "공유 링크가 클립보드에 복사되었습니다"
ru: "Ссылка скопирована в буфер обмена"
pl: "Link udostępniania skopiowany do schowka"
it: "Link di condivisione copiato negli appunti"
shareLinkFailed:
en-US: "Failed to create share link"
zh-CN: "创建分享链接失败"
zh-TW: "建立分享連結失敗"
ja: "共有リンクの作成に失敗しました"
de: "Freigabelink konnte nicht erstellt werden"
fr: "Échec de la création du lien de partage"
es: "Error al crear el enlace para compartir"
ko: "공유 링크 생성 실패"
ru: "Не удалось создать ссылку"
pl: "Nie udało się utworzyć linku udostępniania"
it: "Impossibile creare il link di condivisione"
shared:
en-US: "Shared"
zh-CN: "已共享"

@ -228,7 +228,7 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => {
</SquareMenuItem>
)}
{displayOpt.showExtractArchive && (
<SquareMenuItem onClick={() => dispatch(extractArchive(fmIndex, targets[0]))}>
<SquareMenuItem onClick={() => dispatch(extractArchive(fmIndex, targets))}>
<ListItemIcon>
<ArchiveArrow fontSize="small" />
</ListItemIcon>

@ -13,6 +13,28 @@ import { FileManagerIndex } from "../FileManager.tsx";
const supportedArchiveTypes = ["zip", "gz", "xz", "tar", "rar", "7z", "bz2"];
// archiveVolumeSetKey returns the volume-set key of a file name following the
// same conventions as the backend (partN.rar, .rNN), or null when the name is
// not a recognizable multi-volume member.
const archiveVolumeSetKey = (name: string): string | null => {
const l = name.toLowerCase();
let m = /^(.+)\.part\d+\.rar$/.exec(l);
if (m) return `rar:${m[1]}`;
m = /^(.+)\.r\d+$/.exec(l);
if (m) return `rarold:${m[1]}`;
if (l.endsWith(".rar")) return `rarold:${l.slice(0, -4)}`;
return null;
};
// isSingleVolumeSet is true when all files are volumes of the same archive set.
export const isSingleVolumeSet = (files: FileResponse[]): boolean => {
if (files.length < 2 || files.some((f) => f.type != FileType.file)) {
return false;
}
const key = archiveVolumeSetKey(files[0].name);
return key != null && files.every((f) => archiveVolumeSetKey(f.name) === key);
};
export const canManageVersion = (file: FileResponse, bs: Boolset) => {
return (
file.type == FileType.file &&
@ -267,12 +289,11 @@ export const getActionOpt = (
display.orCapability?.enabled(NavigatorCapability.enter_folder) &&
display.allReadable;
display.showExtractArchive =
targets.length == 1 &&
display.hasFile &&
display.showDownload &&
!!currentUser &&
groupBs.enabled(GroupPermission.archive_task) &&
supportedArchiveTypes.includes(firstFileSuffix ?? "");
((targets.length == 1 && supportedArchiveTypes.includes(firstFileSuffix ?? "")) || isSingleVolumeSet(targets));
display.showTorrentRemoteDownload =
targets.length == 1 &&
display.hasFile &&

@ -29,6 +29,7 @@ const ExtractArchive = () => {
const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen);
const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile);
const targets = useAppSelector((state) => state.globalState.extractArchiveDialogFiles);
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask);
const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding);
@ -44,7 +45,7 @@ const ExtractArchive = () => {
const showPasswordOption = useMemo(() => {
const ext = fileExtension(target?.name ?? "");
return ext === "zip" || ext === "7z";
return ext === "zip" || ext === "7z" || ext === "rar";
}, [target?.name]);
useEffect(() => {
@ -65,7 +66,7 @@ const ExtractArchive = () => {
setLoading(true);
dispatch(
sendExtractArchive({
src: [getFileLinkedUri(target)],
src: (targets ?? [target]).map((f) => getFileLinkedUri(f)),
dst: path,
encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined,
password: showPasswordOption && password ? password : undefined,
@ -83,7 +84,7 @@ const ExtractArchive = () => {
.finally(() => {
setLoading(false);
});
}, [target, encoding, path, showPasswordOption, showEncodingOption, password, mask]);
}, [target, targets, encoding, path, showPasswordOption, showEncodingOption, password, mask]);
return (
<DraggableDialog

@ -198,6 +198,7 @@ export interface GlobalStateSlice {
// Extract archive dialog
extractArchiveDialogOpen?: boolean;
extractArchiveDialogFile?: FileResponse;
extractArchiveDialogFiles?: FileResponse[];
extractArchiveDialogMask?: string[];
extractArchiveDialogEncoding?: string;
@ -430,15 +431,17 @@ export const globalStateSlice = createSlice({
},
setExtractArchiveDialog: (
state,
action: PayloadAction<{ open: boolean; file?: FileResponse; mask?: string[]; encoding?: string }>,
action: PayloadAction<{ open: boolean; file?: FileResponse; files?: FileResponse[]; mask?: string[]; encoding?: string }>,
) => {
state.extractArchiveDialogOpen = action.payload.open;
state.extractArchiveDialogFile = action.payload.file;
state.extractArchiveDialogFiles = action.payload.files;
state.extractArchiveDialogMask = action.payload.mask;
state.extractArchiveDialogEncoding = action.payload.encoding;
},
closeExtractArchiveDialog: (state) => {
state.extractArchiveDialogOpen = false;
state.extractArchiveDialogFiles = undefined;
state.extractArchiveDialogMask = undefined;
state.extractArchiveDialogEncoding = undefined;
},

@ -1246,10 +1246,10 @@ export function walkAll(files: FileResponse[]): AppThunk<Promise<FileResponseWal
};
}
export function extractArchive(index: number, file: FileResponse): AppThunk {
export function extractArchive(index: number, files: FileResponse[]): AppThunk {
return async (dispatch, _getState) => {
dispatch(closeContextMenu({ index, value: undefined }));
dispatch(setExtractArchiveDialog({ open: true, file }));
dispatch(setExtractArchiveDialog({ open: true, file: files[0], files }));
};
}

@ -21,6 +21,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
@ -42,6 +43,7 @@ type (
ExtractArchiveTaskState struct {
Uri string `json:"uri,omitempty"`
Encoding string `json:"encoding,omitempty"`
VolumeUris []string `json:"volume_uris,omitempty"`
Dst string `json:"dst,omitempty"`
TempPath string `json:"temp_path,omitempty"`
TempZipFilePath string `json:"temp_zip_file_path,omitempty"`
@ -72,15 +74,17 @@ func init() {
queue.RegisterResumableTaskFactory(queue.ExtractArchiveTaskType, NewExtractArchiveTaskFromModel)
}
// NewExtractArchiveTask creates a new ExtractArchiveTask
func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string) (queue.Task, error) {
// NewExtractArchiveTask creates a new ExtractArchiveTask. volumes optionally
// lists the URIs of all volumes of a multi-volume archive selected together.
func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string, volumes []string) (queue.Task, error) {
state := &ExtractArchiveTaskState{
Uri: src,
Dst: dst,
Encoding: encoding,
NodeState: NodeState{},
Password: password,
FileMask: mask,
Uri: src,
Dst: dst,
Encoding: encoding,
NodeState: NodeState{},
Password: password,
FileMask: mask,
VolumeUris: volumes,
}
stateBytes, err := json.Marshal(state)
if err != nil {
@ -178,6 +182,10 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep
user := inventory.UserFromContext(ctx)
fm := manager.NewFileManager(dep, user)
// A later volume may have been selected directly; extract from the first
// volume of the set when it exists.
uri = m.resolveFirstVolumeURI(ctx, fm, uri)
// Get entity source to extract
archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
if err != nil {
@ -212,6 +220,7 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep
UserID: user.ID,
Password: m.state.Password,
FileMask: m.state.FileMask,
Volumes: m.resolveVolumeEntities(ctx, fm, uri.DirUri(), archiveFile.DisplayName(), entityModel),
}
payloadStr, err := json.Marshal(payload)
@ -271,6 +280,10 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen
user := inventory.UserFromContext(ctx)
fm := manager.NewFileManager(dep, user)
// A later volume may have been selected directly; extract from the first
// volume of the set when it exists.
uri = m.resolveFirstVolumeURI(ctx, fm, uri)
// Get entity source to extract
archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
if err != nil {
@ -344,8 +357,22 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen
extractor = zipExtractor
}
}
} else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" {
} else if rarExtractor, ok := extractor.(archives.Rar); ok {
rarExtractor.Password = m.state.Password
// Route volume reads through a resolver on the archive's folder so
// multi-volume archives (.partN.rar / .rNN) are followed across files.
rarExtractor.Name = archiveFile.DisplayName()
dir := uri.DirUri()
rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) {
sibling, err := fm.Get(ctx, dir.Join(name), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
if err != nil {
return nil, err
}
if sibling.PrimaryEntity() == nil {
return nil, fmt.Errorf("volume %q has no entity", name)
}
return fm.GetEntitySource(ctx, 0, fs.WithEntity(sibling.PrimaryEntity()))
}}
extractor = rarExtractor
} else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" {
sevenZipExtractor.Password = m.state.Password
@ -555,6 +582,67 @@ func (m *ExtractArchiveTask) Cleanup(ctx context.Context) error {
return nil
}
// resolveFirstVolumeURI returns the URI of the first volume of the archive
// set the given URI belongs to. When the selected file already is the first
// volume, or the first volume is not found in the same folder, uri is
// returned unchanged.
func (m *ExtractArchiveTask) resolveFirstVolumeURI(ctx context.Context, fm manager.FileManager, uri *fs.URI) *fs.URI {
first := firstVolumeName(uri.Name())
if first == "" {
return uri
}
candidate := uri.DirUri().Join(first)
if _, err := fm.Get(ctx, candidate, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()); err != nil {
m.l.Info("Archive %q is a later volume but first volume %q is unavailable: %s", uri.Name(), first, err)
return uri
}
m.l.Info("Selected file %q is a later volume, extracting from first volume %q", uri.Name(), first)
return candidate
}
// resolveVolumeEntities collects the entities of all sibling files in the
// same archive volume set, so a slave node can resolve later volumes without
// accessing the filesystem. The first volume entity is always included.
func (m *ExtractArchiveTask) resolveVolumeEntities(ctx context.Context, fm manager.FileManager, dir *fs.URI, archiveName string, archiveEntity *ent.Entity) map[string]*ent.Entity {
volumes := map[string]*ent.Entity{archiveName: archiveEntity}
key, _, isVolume := volumeInfo(archiveName)
// Volumes explicitly selected with the archive.
for _, v := range m.state.VolumeUris {
vuri, err := fs.NewUriFromString(v)
if err != nil || volumes[vuri.Name()] != nil {
continue
}
volumeFile, err := fm.Get(ctx, vuri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
if err != nil || volumeFile.PrimaryEntity() == nil {
continue
}
volumes[volumeFile.DisplayName()] = volumeFile.PrimaryEntity().Model()
}
// Sibling volumes present in the folder but not selected.
if isVolume {
if _, listed, err := fm.List(ctx, dir, &manager.ListArgs{PageSize: 1000}); err == nil {
for _, child := range listed.Files {
if volumes[child.DisplayName()] != nil {
continue
}
childKey, _, ok := volumeInfo(child.DisplayName())
if !ok || childKey != key {
continue
}
volumeFile, err := fm.Get(ctx, dir.Join(child.DisplayName()), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
if err != nil || volumeFile.PrimaryEntity() == nil {
continue
}
volumes[child.DisplayName()] = volumeFile.PrimaryEntity().Model()
}
}
}
return volumes
}
type (
SlaveExtractArchiveTask struct {
*queue.InMemoryTask
@ -566,17 +654,18 @@ type (
}
SlaveExtractArchiveTaskState struct {
FileName string `json:"file_name"`
Entity *ent.Entity `json:"entity"`
Policy *ent.StoragePolicy `json:"policy"`
Encoding string `json:"encoding,omitempty"`
Dst string `json:"dst,omitempty"`
UserID int `json:"user_id"`
TempPath string `json:"temp_path,omitempty"`
TempZipFilePath string `json:"temp_zip_file_path,omitempty"`
ProcessedCursor string `json:"processed_cursor,omitempty"`
Password string `json:"password,omitempty"`
FileMask []string `json:"file_mask,omitempty"`
FileName string `json:"file_name"`
Entity *ent.Entity `json:"entity"`
Policy *ent.StoragePolicy `json:"policy"`
Encoding string `json:"encoding,omitempty"`
Dst string `json:"dst,omitempty"`
UserID int `json:"user_id"`
TempPath string `json:"temp_path,omitempty"`
TempZipFilePath string `json:"temp_zip_file_path,omitempty"`
ProcessedCursor string `json:"processed_cursor,omitempty"`
Password string `json:"password,omitempty"`
FileMask []string `json:"file_mask,omitempty"`
Volumes map[string]*ent.Entity `json:"volumes,omitempty"`
}
)
@ -724,8 +813,18 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) {
extractor = zipExtractor
}
}
} else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" {
} else if rarExtractor, ok := extractor.(archives.Rar); ok {
rarExtractor.Password = m.state.Password
// Resolve sibling volumes from the entities shipped with the task
// payload so multi-volume archives are followed across files.
rarExtractor.Name = m.state.FileName
rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) {
model, ok := m.state.Volumes[name]
if !ok {
return nil, fmt.Errorf("volume %q not provided", name)
}
return fm.GetEntitySource(ctx, 0, fs.WithEntity(fs.NewEntity(model)), fs.WithPolicy(fm.CastStoragePolicyOnSlave(ctx, m.state.Policy)))
}}
extractor = rarExtractor
} else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" {
sevenZipExtractor.Password = m.state.Password

@ -0,0 +1,141 @@
package workflows
import (
"fmt"
"io"
iofs "io/fs"
"regexp"
"strconv"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
)
// Multi-volume archive naming conventions. rardecode derives the next volume
// name from the current one and opens it via a custom fs.FS, so siblings only
// need to be resolvable by bare file name within the archive's folder.
// Only RAR conventions are recognized: it is the format rardecode can follow
// across volumes via fs.FS. Split zip/7z sets are intentionally excluded as
// their extractors cannot span files.
var (
newRarVolumeRe = regexp.MustCompile(`^(.+)\.part(\d+)\.rar$`)
oldRarVolumeRe = regexp.MustCompile(`^(.+)\.r(\d+)$`)
)
// volumeInfo returns the volume-set key and the ordering number of a file
// name. ok is false when the name is not a recognizable multi-volume member.
// The key is only comparable within one folder.
func volumeInfo(name string) (key string, order int64, ok bool) {
lower := strings.ToLower(name)
if m := newRarVolumeRe.FindStringSubmatch(lower); m != nil {
n, _ := strconv.ParseInt(m[2], 10, 64)
return "rar:" + m[1], n, true
}
if m := oldRarVolumeRe.FindStringSubmatch(lower); m != nil {
n, _ := strconv.ParseInt(m[2], 10, 64)
return "rarold:" + m[1], n + 1, true
}
if stem, found := strings.CutSuffix(lower, ".rar"); found {
return "rarold:" + stem, 0, true
}
return "", 0, false
}
// firstVolumeName returns the expected file name of the first volume of the
// set the given member belongs to, or "" when the name already is the first
// volume or is not a recognizable later volume.
func firstVolumeName(name string) string {
lower := strings.ToLower(name)
if m := newRarVolumeRe.FindStringSubmatchIndex(lower); m != nil {
if n, _ := strconv.Atoi(lower[m[4]:m[5]]); n > 1 {
return fmt.Sprintf("%s.part%0*d.rar", name[m[2]:m[3]], len(lower[m[4]:m[5]]), 1)
}
return ""
}
if m := oldRarVolumeRe.FindStringSubmatchIndex(lower); m != nil {
return name[m[2]:m[3]] + ".rar"
}
return ""
}
// FirstVolumeURI inspects a multi-file selection of cloudreve URIs and, when
// all files are volumes of the same archive set in the same folder, returns
// the URI of the first volume to extract from. ok is false when the
// selection is not a single volume set.
func FirstVolumeURI(srcs []string) (src string, ok bool) {
if len(srcs) < 2 {
return "", false
}
var key, dir string
best, bestOrder := -1, int64(0)
for i, s := range srcs {
u, err := fs.NewUriFromString(s)
if err != nil {
return "", false
}
k, order, isVolume := volumeInfo(u.Name())
if !isVolume {
return "", false
}
if i == 0 {
key, dir = k, u.Dir()
} else if k != key || u.Dir() != dir {
return "", false
}
if best < 0 || order < bestOrder {
best, bestOrder = i, order
}
}
return srcs[best], true
}
// archiveVolumeFS is an fs.FS resolving bare archive volume file names to
// entity sources. rardecode calls Open with volume names derived from the
// first volume's name; a missing sibling must surface fs.ErrNotExist so the
// decoder treats it as the end of the archive.
type archiveVolumeFS struct {
open func(name string) (entitysource.EntitySource, error)
}
func (f *archiveVolumeFS) Open(name string) (iofs.File, error) {
if name == "" || strings.ContainsRune(name, '/') || strings.ContainsRune(name, '\\') {
return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist}
}
es, err := f.open(name)
if err != nil || es == nil {
return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist}
}
return &archiveVolumeFile{es: es, name: name}, nil
}
// archiveVolumeFile adapts an entity source to fs.File while keeping the
// io.Seeker passthrough rardecode uses for in-volume seeks.
type archiveVolumeFile struct {
es entitysource.EntitySource
name string
}
func (f *archiveVolumeFile) Read(p []byte) (int, error) { return f.es.Read(p) }
func (f *archiveVolumeFile) Seek(o int64, w int) (int64, error) { return f.es.Seek(o, w) }
func (f *archiveVolumeFile) Close() error { return f.es.Close() }
func (f *archiveVolumeFile) Stat() (iofs.FileInfo, error) {
return archiveVolumeFileInfo{name: f.name, size: f.es.Entity().Size()}, nil
}
var _ io.Seeker = (*archiveVolumeFile)(nil)
type archiveVolumeFileInfo struct {
name string
size int64
}
func (i archiveVolumeFileInfo) Name() string { return i.name }
func (i archiveVolumeFileInfo) Size() int64 { return i.size }
func (i archiveVolumeFileInfo) Mode() iofs.FileMode { return 0 }
func (i archiveVolumeFileInfo) ModTime() time.Time { return time.Time{} }
func (i archiveVolumeFileInfo) IsDir() bool { return false }
func (i archiveVolumeFileInfo) Sys() any { return nil }

@ -0,0 +1,83 @@
package workflows
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestVolumeInfo(t *testing.T) {
cases := []struct {
name string
key string
order int64
ok bool
}{
{"movie.part1.rar", "rar:movie", 1, true},
{"movie.part02.rar", "rar:movie", 2, true},
{"MOVIE.PART10.RAR", "rar:movie", 10, true},
{"movie.rar", "rarold:movie", 0, true},
{"movie.r00", "rarold:movie", 1, true},
{"movie.r15", "rarold:movie", 16, true},
{"movie.zip", "", 0, false},
{"movie.z01", "", 0, false},
{"movie.001", "", 0, false},
{"movie.7z", "", 0, false},
{"movie.tar.gz", "", 0, false},
{"readme.txt", "", 0, false},
}
for _, c := range cases {
key, order, ok := volumeInfo(c.name)
require.Equal(t, c.ok, ok, c.name)
if c.ok {
require.Equal(t, c.key, key, c.name)
require.Equal(t, c.order, order, c.name)
}
}
}
func TestFirstVolumeName(t *testing.T) {
require.Equal(t, "movie.part01.rar", firstVolumeName("movie.part07.rar"))
require.Equal(t, "movie.part1.rar", firstVolumeName("movie.part3.rar"))
require.Equal(t, "movie.rar", firstVolumeName("movie.r02"))
require.Equal(t, "", firstVolumeName("movie.part1.rar"))
require.Equal(t, "", firstVolumeName("movie.rar"))
require.Equal(t, "", firstVolumeName("movie.zip"))
require.Equal(t, "", firstVolumeName("movie.001"))
require.Equal(t, "", firstVolumeName("plain.txt"))
}
func TestFirstVolumeURI(t *testing.T) {
base := "cloudreve://my/archives"
src, ok := FirstVolumeURI([]string{
base + "/movie.part2.rar",
base + "/movie.part1.rar",
base + "/movie.part3.rar",
})
require.True(t, ok)
require.Equal(t, base+"/movie.part1.rar", src)
// Old-style set: .rar is first.
src, ok = FirstVolumeURI([]string{
base + "/movie.r00",
base + "/movie.rar",
})
require.True(t, ok)
require.Equal(t, base+"/movie.rar", src)
// Single source never matches.
_, ok = FirstVolumeURI([]string{base + "/movie.part1.rar"})
require.False(t, ok)
// Mixed sets do not match.
_, ok = FirstVolumeURI([]string{base + "/a.part1.rar", base + "/b.part2.rar"})
require.False(t, ok)
// Different folders do not match.
_, ok = FirstVolumeURI([]string{base + "/movie.part1.rar", "cloudreve://my/other/movie.part2.rar"})
require.False(t, ok)
// Non-volume names do not match.
_, ok = FirstVolumeURI([]string{base + "/a.txt", base + "/b.txt"})
require.False(t, ok)
}

@ -260,8 +260,17 @@ func (service *ArchiveWorkflowService) CreateExtractTask(c *gin.Context) (*TaskR
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
}
// When multiple sources form a single multi-volume archive set, extract
// from the first volume and pass all volumes to the task.
src := service.Src[0]
var volumes []string
if first, ok := workflows.FirstVolumeURI(service.Src); ok {
src = first
volumes = service.Src
}
// Create task
t, err := workflows.NewExtractArchiveTask(c, service.Src[0], service.Dst, service.Encoding, service.Password, service.FileMask)
t, err := workflows.NewExtractArchiveTask(c, src, service.Dst, service.Encoding, service.Password, service.FileMask, volumes)
if err != nil {
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
}

Loading…
Cancel
Save