From e5c843a3309881c40ab07f302dc22b9748dfed2a Mon Sep 17 00:00:00 2001 From: tomasmatus Date: Tue, 26 Nov 2024 13:28:46 +0100 Subject: [PATCH] Support copy&pasting files with administrator privileges Allows administrator to paste files into directories owned by a different user. --- src/app.tsx | 7 +- src/dialogs/copyPasteOwnership.tsx | 139 +++++++++++++++++++++++++++++ src/files-card-body.tsx | 13 +-- src/files-folder-view.tsx | 4 +- src/header.tsx | 5 +- src/menu.tsx | 46 ++++++---- test/check-application | 77 ++++++++++++++++ 7 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 src/dialogs/copyPasteOwnership.tsx diff --git a/src/app.tsx b/src/app.tsx index d2fb0b50..c695440a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -71,6 +71,11 @@ export const FilesContext = React.createContext({ export const useFilesContext = () => useContext(FilesContext); +export interface ClipboardInfo { + path: string, + files: FolderFileInfo[]; +} + export const usePath = () => { const { options } = usePageLocation(); let currentPath = decodeURIComponent(options.path?.toString() || "/"); @@ -98,7 +103,7 @@ export const Application = () => { const [files, setFiles] = useState([]); const [selected, setSelected] = useState([]); const [showHidden, setShowHidden] = useState(localStorage.getItem("files:showHiddenFiles") === "true"); - const [clipboard, setClipboard] = useState([]); + const [clipboard, setClipboard] = useState({ path: "/", files: [] }); const [alerts, setAlerts] = useState([]); const [cwdInfo, setCwdInfo] = useState(null); diff --git a/src/dialogs/copyPasteOwnership.tsx b/src/dialogs/copyPasteOwnership.tsx new file mode 100644 index 00000000..5d7bc484 --- /dev/null +++ b/src/dialogs/copyPasteOwnership.tsx @@ -0,0 +1,139 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import React, { useEffect, useState } from 'react'; + +import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert"; +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form"; +import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect"; +import { Modal, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal"; + +import cockpit from 'cockpit'; +import type { Dialogs, DialogResult } from 'dialogs'; +import { superuser } from 'superuser'; + +import { useFilesContext } from '../app.tsx'; +import type { ClipboardInfo } from '../app.tsx'; +import { get_owner_candidates } from '../ownership.tsx'; + +const _ = cockpit.gettext; + +async function pasteAsOwner(clipboard: ClipboardInfo, dstPath: string, owner: string, + addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void) { + try { + await cockpit.spawn([ + "cp", + "--recursive", + ...clipboard.files.map(file => clipboard.path + file.name), + dstPath + ], { superuser: "try" }); + + await cockpit.spawn([ + "chown", + "--recursive", + owner, + ...clipboard.files.map(file => dstPath + file.name), + ], { superuser: "try" }); + } catch (err) { + const e = err as cockpit.BasicError; + addAlert(e.message, AlertVariant.danger, `${new Date().getTime()}`); + } +} + +const CopyPasteAsOwnerModal = ({ + clipboard, + dialogResult, + path, +} : { + clipboard: ClipboardInfo, + dialogResult: DialogResult, + path: string, +}) => { + const [currentUser, setCurrentUser] = useState(); + const [selectedOwner, setSelectedOwner] = useState(); + const { cwdInfo, addAlert } = useFilesContext(); + + useEffect(() => { + cockpit.user().then(user => setCurrentUser(user)); + }, []); + + const candidates = []; + if (superuser.allowed && currentUser && cwdInfo) { + candidates.push(...get_owner_candidates(currentUser, cwdInfo)); + if (selectedOwner === undefined) { + setSelectedOwner(candidates[0]); + } + } + + if (selectedOwner === undefined) { + return; + } + + const modalFooter = ( + <> + + + + ); + + return ( + dialogResult.resolve()} + variant={ModalVariant.small} + footer={modalFooter} + > +
+ + setSelectedOwner(val)} + > + {candidates.map(user => + )} + + + +
+ +
+ ); +}; + +export function show_copy_paste_as_owner(dialogs: Dialogs, clipboard: ClipboardInfo, path: string) { + dialogs.run(CopyPasteAsOwnerModal, { clipboard, path }); +} diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx index 9def7b3d..54619769 100644 --- a/src/files-card-body.tsx +++ b/src/files-card-body.tsx @@ -35,7 +35,7 @@ import { dirname } from "cockpit-path.ts"; import { useDialogs } from "dialogs"; import * as timeformat from "timeformat"; -import { FolderFileInfo, useFilesContext } from "./app.tsx"; +import { ClipboardInfo, FolderFileInfo, useFilesContext } from "./app.tsx"; import { get_permissions, permissionShortStr } from "./common.ts"; import { confirm_delete } from "./dialogs/delete.tsx"; import { show_create_directory_dialog } from "./dialogs/mkdir.tsx"; @@ -101,7 +101,7 @@ function compare(sortBy: Sort): (a: FolderFileInfo, b: FolderFileInfo) => number const ContextMenuItems = ({ path, selected, setSelected, clipboard, setClipboard } : { path: string, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, }) => { const dialogs = useDialogs(); const { addAlert, cwdInfo } = useFilesContext(); @@ -152,7 +152,7 @@ export const FilesCardBody = ({ selected: FolderFileInfo[], setSelected: React.Dispatch>, sortBy: Sort, setSortBy: React.Dispatch>, loadingFiles: boolean, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, showHidden: boolean, setShowHidden: React.Dispatch>, }) => { @@ -365,15 +365,16 @@ export const FilesCardBody = ({ // Keep standard text editing behavior by excluding input fields if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { e.preventDefault(); - setClipboard(selected.map(s => path + s.name)); + setClipboard({ path, files: selected }); } break; case "v": // Keep standard text editing behavior by excluding input fields - if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + if (e.ctrlKey && !e.shiftKey && !e.altKey && clipboard.files.length > 0 && + !(e.target instanceof HTMLInputElement)) { e.preventDefault(); - pasteFromClipboard(clipboard, cwdInfo, path, addAlert); + pasteFromClipboard(clipboard, cwdInfo, path, dialogs, addAlert); } break; diff --git a/src/files-folder-view.tsx b/src/files-folder-view.tsx index 66023ca5..db0156bf 100644 --- a/src/files-folder-view.tsx +++ b/src/files-folder-view.tsx @@ -26,7 +26,7 @@ import { debounce } from "throttle-debounce"; import cockpit from "cockpit"; import { EmptyStatePanel } from "cockpit-components-empty-state"; -import type { FolderFileInfo } from "./app.tsx"; +import type { FolderFileInfo, ClipboardInfo } from "./app.tsx"; import { FilesCardBody } from "./files-card-body.tsx"; import { as_sort, FilesCardHeader } from "./header.tsx"; @@ -63,7 +63,7 @@ export const FilesFolderView = ({ showHidden: boolean, setShowHidden: React.Dispatch>, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, }) => { const dropzoneRef = useRef(null); const [currentFilter, setCurrentFilter] = useState(""); diff --git a/src/header.tsx b/src/header.tsx index bc6128db..384c9b16 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -32,7 +32,8 @@ import cockpit from "cockpit"; import { KebabDropdown } from "cockpit-components-dropdown"; import { useDialogs } from "dialogs"; -import { FolderFileInfo, useFilesContext } from "./app.tsx"; +import { useFilesContext } from "./app.tsx"; +import type { FolderFileInfo, ClipboardInfo } from "./app.tsx"; import { showKeyboardShortcuts } from "./dialogs/keyboardShortcutsHelp.tsx"; import { get_menu_items } from "./menu.tsx"; import { UploadButton } from "./upload-button.tsx"; @@ -146,7 +147,7 @@ export const FilesCardHeader = ({ sortBy: Sort, setSortBy: React.Dispatch> showHidden: boolean, setShowHidden: React.Dispatch>, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch> + clipboard: ClipboardInfo, setClipboard: React.Dispatch> path: string, }) => { const { addAlert, cwdInfo } = useFilesContext(); diff --git a/src/menu.tsx b/src/menu.tsx index fe5aaebe..451cdfec 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -25,8 +25,10 @@ import cockpit from "cockpit"; import type { FileInfo } from "cockpit/fsinfo"; import { basename, dirname } from "cockpit-path"; import type { Dialogs } from 'dialogs'; +import { superuser } from 'superuser'; -import type { FolderFileInfo } from "./app"; +import type { ClipboardInfo, FolderFileInfo } from "./app"; +import { show_copy_paste_as_owner } from "./dialogs/copyPasteOwnership.tsx"; import { show_create_file_dialog } from './dialogs/create-file.tsx'; import { confirm_delete } from './dialogs/delete.tsx'; import { edit_file, MAX_EDITOR_FILE_SIZE } from './dialogs/editor.tsx'; @@ -46,31 +48,43 @@ type MenuItem = { type: "divider" } | { className?: string; }; -export function pasteFromClipboard( - clipboard: string[], +function isForeignDestination(cwdInfo: FileInfo, clipboard: ClipboardInfo) { + return !clipboard.files.every(file => file.user === cwdInfo.user && file.group === cwdInfo.group); +} + +export async function pasteFromClipboard( + clipboard: ClipboardInfo, cwdInfo: FileInfo | null, path: string, + dialogs: Dialogs, addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, ) { - const existingFiles = clipboard.filter(sourcePath => cwdInfo?.entries?.[basename(sourcePath)]); + const existingFiles = clipboard.files.filter(sourcePath => cwdInfo?.entries?.[sourcePath.name]); + if (existingFiles.length > 0) { addAlert(_("Pasting failed"), AlertVariant.danger, "paste-error", cockpit.format(_("\"$0\" exists, not overwriting with paste."), - existingFiles.map(basename).join(", "))); + existingFiles.map(file => file.name).join(", "))); return; } - cockpit.spawn([ - "cp", - "-R", - ...clipboard, - path - ]).catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`)); + + if (superuser.allowed && cwdInfo !== null && isForeignDestination(cwdInfo, clipboard)) { + show_copy_paste_as_owner(dialogs, clipboard, path); + } else { + cockpit.spawn([ + "cp", + "-R", + ...clipboard.files.map(file => clipboard.path + file.name), + path + ]) + .catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`)); + } } export function get_menu_items( path: string, selected: FolderFileInfo[], setSelected: React.Dispatch>, - clipboard: string[], setClipboard: React.Dispatch>, + clipboard: ClipboardInfo, setClipboard: React.Dispatch>, cwdInfo: FileInfo | null, addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, dialogs: Dialogs, @@ -86,8 +100,8 @@ export function get_menu_items( { id: "paste-item", title: _("Paste"), - isDisabled: clipboard.length === 0, - onClick: () => pasteFromClipboard(clipboard, cwdInfo, path, addAlert), + isDisabled: clipboard.files?.length === 0, + onClick: () => pasteFromClipboard(clipboard, cwdInfo, path, dialogs, addAlert), }, { type: "divider" }, { @@ -138,7 +152,7 @@ export function get_menu_items( { id: "copy-item", title: _("Copy"), - onClick: () => setClipboard([path + item.name]) + onClick: () => setClipboard({ path, files: [item] }) }, { type: "divider" }, { @@ -183,7 +197,7 @@ export function get_menu_items( { id: "copy-item", title: _("Copy"), - onClick: () => setClipboard(selected.map(s => path + s.name)), + onClick: () => setClipboard({ path, files: selected }), }, { id: "delete-item", diff --git a/test/check-application b/test/check-application index 37b53d39..01b31306 100755 --- a/test/check-application +++ b/test/check-application @@ -1952,10 +1952,14 @@ class TestFiles(testlib.MachineCase): m = self.machine self.enter_files() + b.click("button[aria-label='Display as a list']") # Copy/paste file m.execute("runuser -u admin mkdir /home/admin/newdir") m.write('/home/admin/newfile', 'test_text\n', owner='admin:admin') + # "hack" test is too quick and UI doesnt reflect the chown done in m.write so it still + # thinks that file owner is root + b.wait_in_text("[data-item='newfile'] .item-owner", "admin") b.click("[data-item='newfile']") b.click("#dropdown-menu") b.click("#copy-item") @@ -1971,6 +1975,8 @@ class TestFiles(testlib.MachineCase): # original file still exists b.wait_visible("[data-item='newfile']") + b.click("button[aria-label='Display as a grid']") + # Copy/paste directory m.execute("runuser -u admin mkdir /home/admin/copyDir") m.execute("runuser -u admin mkdir /home/admin/newdir/loaded") @@ -2047,6 +2053,77 @@ class TestFiles(testlib.MachineCase): b.wait_in_text("h4.pf-v5-c-alert__title", "Pasting failed") b.wait_in_text(".pf-v5-c-alert__description", "\"newfile\" exists") self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n") + b.click(".pf-v5-c-alert__action button") + + # Copy & paste as superuser + # Switch to list so owner is visible + b.click("button[aria-label='Display as a list']") + b.go("/files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + m.execute("useradd -m foouser") + + b.click("[data-item='newfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + # Paste file as destination directory owner + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.click("#paste-owner-modal button.pf-m-warning") + b.wait_in_text("[data-item='newfile'] .item-owner", "foouser") + + # Copy foouser file into admin directory as admin + m.execute("runuser -u foouser touch /home/foouser/foouserfile") + b.wait_in_text("[data-item='foouserfile'] .item-owner", "foouser") + b.click("[data-item='foouserfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.click("#paste-owner-modal button.pf-m-warning") + b.wait_in_text("[data-item='foouserfile'] .item-owner", "admin") + + # Paste as admin into foouser directory + m.execute("runuser -u admin touch /home/admin/adminfile") + b.wait_in_text("[data-item='adminfile'] .item-owner", "admin") + b.click("[data-item='adminfile']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "admin:admin") + b.click("#paste-owner-modal button.pf-m-warning") + b.wait_in_text("[data-item='adminfile'] .item-owner", "admin") + + # Copying a directory should change the owner recursively + b.go("files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + b.click("[data-item='kbdCopy']") + b.click("#dropdown-menu") + b.click("#copy-item") + b.go("files#/?path=/home/foouser") + self.assert_last_breadcrumb("foouser") + b.click("#dropdown-menu") + b.click("#paste-item") + b.wait_visible("#paste-owner-modal") + b.select_from_dropdown("#paste-owner-select", "root:root") + b.click("#paste-owner-modal button.pf-m-warning") + b.wait_in_text("[data-item='kbdCopy'] .item-owner", "root") + b.go("files#/?path=/home/foouser/kbdCopy") + self.assert_last_breadcrumb("kbdCopy") + b.wait_in_text("[data-item='loaded'] .item-owner", "root") + b.wait_in_text("[data-item='newfile'] .item-owner", "root") @testlib.skipBrowser(".upload_files() doesn't work on Firefox", "firefox") def testUpload(self) -> None: