Skip to content

Commit

Permalink
Support copy&pasting files with administrator privileges
Browse files Browse the repository at this point in the history
Allows administrator to paste files into directories owned
by a different user.
  • Loading branch information
tomasmatus committed Jan 3, 2025
1 parent 97da281 commit e5c843a
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 27 deletions.
7 changes: 6 additions & 1 deletion src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() || "/");
Expand Down Expand Up @@ -98,7 +103,7 @@ export const Application = () => {
const [files, setFiles] = useState<FolderFileInfo[]>([]);
const [selected, setSelected] = useState<FolderFileInfo[]>([]);
const [showHidden, setShowHidden] = useState(localStorage.getItem("files:showHiddenFiles") === "true");
const [clipboard, setClipboard] = useState<string[]>([]);
const [clipboard, setClipboard] = useState<ClipboardInfo>({ path: "/", files: [] });
const [alerts, setAlerts] = useState<Alert[]>([]);
const [cwdInfo, setCwdInfo] = useState<FileInfo | null>(null);

Expand Down
139 changes: 139 additions & 0 deletions src/dialogs/copyPasteOwnership.tsx
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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<void>,
path: string,
}) => {
const [currentUser, setCurrentUser] = useState<cockpit.UserInfo| undefined>();
const [selectedOwner, setSelectedOwner] = useState<string | undefined>();
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 = (
<>
<Button
variant="warning"
onClick={() => {
pasteAsOwner(clipboard, path, selectedOwner, addAlert);
dialogResult.resolve();
}}
>
{_("Paste")}
</Button>
<Button variant="link" onClick={() => dialogResult.resolve()}>{_("Cancel")}</Button>
</>
);

return (
<Modal
id="paste-owner-modal"
position="top"
title={_("Select owner of pasted files")}
titleIconVariant="warning"
isOpen
onClose={() => dialogResult.resolve()}
variant={ModalVariant.small}
footer={modalFooter}
>
<Form isHorizontal>
<FormGroup fieldId="paste-as-owner" label={_("Paste files as")}>
<FormSelect
id='paste-owner-select'
value={selectedOwner}
onChange={(_ev, val) => setSelectedOwner(val)}
>
{candidates.map(user =>
<FormSelectOption
key={user}
value={user}
label={user}
/>)}
</FormSelect>
</FormGroup>

</Form>

</Modal>
);
};

export function show_copy_paste_as_owner(dialogs: Dialogs, clipboard: ClipboardInfo, path: string) {
dialogs.run(CopyPasteAsOwnerModal, { clipboard, path });
}
13 changes: 7 additions & 6 deletions src/files-card-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
clipboard: ClipboardInfo, setClipboard: React.Dispatch<React.SetStateAction<ClipboardInfo>>,
}) => {
const dialogs = useDialogs();
const { addAlert, cwdInfo } = useFilesContext();
Expand Down Expand Up @@ -152,7 +152,7 @@ export const FilesCardBody = ({
selected: FolderFileInfo[], setSelected: React.Dispatch<React.SetStateAction<FolderFileInfo[]>>,
sortBy: Sort, setSortBy: React.Dispatch<React.SetStateAction<Sort>>,
loadingFiles: boolean,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
clipboard: ClipboardInfo, setClipboard: React.Dispatch<React.SetStateAction<ClipboardInfo>>,
showHidden: boolean,
setShowHidden: React.Dispatch<React.SetStateAction<boolean>>,
}) => {
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/files-folder-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -63,7 +63,7 @@ export const FilesFolderView = ({
showHidden: boolean,
setShowHidden: React.Dispatch<React.SetStateAction<boolean>>,
selected: FolderFileInfo[], setSelected: React.Dispatch<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
clipboard: ClipboardInfo, setClipboard: React.Dispatch<React.SetStateAction<ClipboardInfo>>,
}) => {
const dropzoneRef = useRef<HTMLDivElement>(null);
const [currentFilter, setCurrentFilter] = useState("");
Expand Down
5 changes: 3 additions & 2 deletions src/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -146,7 +147,7 @@ export const FilesCardHeader = ({
sortBy: Sort, setSortBy: React.Dispatch<React.SetStateAction<Sort>>
showHidden: boolean, setShowHidden: React.Dispatch<React.SetStateAction<boolean>>,
selected: FolderFileInfo[], setSelected: React.Dispatch<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>
clipboard: ClipboardInfo, setClipboard: React.Dispatch<React.SetStateAction<ClipboardInfo>>
path: string,
}) => {
const { addAlert, cwdInfo } = useFilesContext();
Expand Down
46 changes: 30 additions & 16 deletions src/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<React.SetStateAction<FolderFileInfo[]>>,
clipboard: string[], setClipboard: React.Dispatch<React.SetStateAction<string[]>>,
clipboard: ClipboardInfo, setClipboard: React.Dispatch<React.SetStateAction<ClipboardInfo>>,
cwdInfo: FileInfo | null,
addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void,
dialogs: Dialogs,
Expand All @@ -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" },
{
Expand Down Expand Up @@ -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" },
{
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit e5c843a

Please sign in to comment.