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 Feb 3, 2025
1 parent 564357a commit dc5296d
Show file tree
Hide file tree
Showing 10 changed files with 490 additions and 31 deletions.
4 changes: 2 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { useInit, usePageLocation } from "hooks";
import { superuser } from "superuser";

import { FilesContext, usePath } from "./common.ts";
import type { FolderFileInfo } from "./common.ts";
import type { ClipboardInfo, FolderFileInfo } from "./common.ts";
import { FilesBreadcrumbs } from "./files-breadcrumbs.tsx";
import { FilesFolderView } from "./files-folder-view.tsx";
import { FilesFooterDetail } from "./files-footer-detail.tsx";
Expand All @@ -61,7 +61,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
12 changes: 11 additions & 1 deletion src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import React, { useContext } from "react";

import type { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
Expand All @@ -33,6 +32,11 @@ export interface FolderFileInfo extends FileInfo {
category: { class: string } | null,
}

export interface ClipboardInfo {
path: string,
files: FolderFileInfo[];
}

interface FilesContextType {
addAlert: (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode,
actionLinks?: React.ReactNode) => void,
Expand Down Expand Up @@ -139,3 +143,9 @@ export function checkFilename(candidate: string, entries: Record<string, FileInf
return null;
}
}

export function debug(...args: unknown[]) {
if (window.debugging === "all" || window.debugging?.includes("files")) {
console.debug("files:", ...args);
}
}
221 changes: 221 additions & 0 deletions src/dialogs/copyPasteOwnership.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* 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, { 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 { Text, TextContent } from "@patternfly/react-core/dist/esm/components/Text";

import cockpit from 'cockpit';
import { FileInfo } from 'cockpit/fsinfo.ts';
import type { Dialogs, DialogResult } from 'dialogs';
import { useEvent, useInit } from 'hooks.ts';
import { superuser } from 'superuser';

import { useFilesContext } from '../common.ts';
import type { ClipboardInfo, FolderFileInfo } from '../common.ts';
import { get_owner_candidates } from '../ownership.tsx';

const _ = cockpit.gettext;

async function pasteAsOwner(clipboard: ClipboardInfo,
dstPath: string,
ownerStr: string,
addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void) {
try {
await cockpit.spawn([
"cp",
"-a",
"--recursive",
...clipboard.files.map(file => clipboard.path + "/" + file.name),
dstPath
], { superuser: "require" });

// "original" is a special value, valid values are formatted as 'user:group'
if (ownerStr !== "original") {
await cockpit.spawn([
"chown",
"--recursive",
ownerStr,
...clipboard.files.map(file => dstPath + "/" + file.name),
], { superuser: "require" });
}
} catch (err) {
const e = err as cockpit.BasicError;
addAlert(_("Pasting failed"), AlertVariant.danger, `${new Date().getTime()}`, e.message);

// cleanup potentially copied files in case of "chown" fail
try {
await cockpit.spawn([
"rm",
"-rf",
...clipboard.files.map(file => dstPath + "/" + file.name)
], { superuser: "try" });
} catch (ex) {
console.warn(`Failed to clean up copied files in ${dstPath}`, ex);
}
}
}

function ownershipTypes(files: FolderFileInfo[]) {
const owners = new Set();

for (const file of files) {
const user = file.user;
const group = file.group;

if (user === group) {
owners.add(String(user));
} else {
owners.add(`${user}:${group}`);
}
}

return [...owners];
}

function makeCandidatesMap(currentUser: cockpit.UserInfo, cwdInfo: FileInfo, clipboard: ClipboardInfo) {
// keys in Map() are ordered in the same order as they were inserted
// and has no duplicates. Only the first insertion.
const map: Map<string, string> = new Map();
const candidates = get_owner_candidates(currentUser, cwdInfo);

// also add current ownership if it is same for all files (shallow check)
const firstFile = clipboard.files[0];
const firstOwnerStr = `${firstFile.user}:${firstFile.group}`;
const uniqueOwners = ownershipTypes(clipboard.files);
if (uniqueOwners.length === 1) {
candidates.add(firstOwnerStr);
}

candidates.forEach(owner => {
const split = owner.split(':');
let key = owner;
if (split.length === 2 && split[0] === split[1]) {
key = split[0];
}
if (uniqueOwners.length === 1 && owner === firstOwnerStr) {
key = `${key} ${_("(original owner)")}`;
}

map.set(key, owner);
});

// add option to keep the same permissions on mixed permissions files
if (uniqueOwners.length > 1) {
const dots = uniqueOwners.length > 2 ? ", ..." : "";
map.set(`${_("keep original owners")} (${uniqueOwners.slice(0, 2).join(", ")}${dots})`, "original");
}

return map;
}

const CopyPasteAsOwnerModal = ({
clipboard,
dialogResult,
path,
} : {
clipboard: ClipboardInfo,
dialogResult: DialogResult<void>,
path: string,
}) => {
// @ts-expect-error superuser.js is not typed
useEvent(superuser, "changed");
const [selectedOwner, setSelectedOwner] = useState<string | undefined>();
const { cwdInfo, addAlert } = useFilesContext();
const [candidatesMap, setCandidatesMap] = useState<Map<string, string> | undefined>();

useInit(async () => {
const userInfo = await cockpit.user();
let map: Map<string, string> = new Map();

if (superuser.allowed && userInfo && cwdInfo) {
map = makeCandidatesMap(userInfo, cwdInfo, clipboard);
if (selectedOwner === undefined) {
setSelectedOwner(map.keys().next().value);
}
}

setCandidatesMap(map);
});

if (selectedOwner === undefined || candidatesMap === undefined) {
return;
}

const selectedVal = candidatesMap.get(selectedOwner);
cockpit.assert(selectedVal !== undefined, "New file ownership undefined");

const modalFooter = (
<>
<Button
variant="primary"
onClick={() => {
pasteAsOwner(clipboard, path, selectedVal, addAlert);
dialogResult.resolve();
}}
>
{_("Paste")}
</Button>
<Button variant="link" onClick={() => dialogResult.resolve()}>{_("Cancel")}</Button>
</>
);

return (
<Modal
id="paste-owner-modal"
position="top"
title={_("Paste as owner")}
isOpen
onClose={() => dialogResult.resolve()}
variant={ModalVariant.small}
footer={modalFooter}
>
<Form isHorizontal>
<TextContent>
<Text>
{/*eslint-disable-line*/ _("Files being pasted have a different owner. By default, ownership will be changed to match the destination directory.")}
</Text>
</TextContent>
<FormGroup fieldId="paste-as-owner" label={_("New owner")}>
<FormSelect
id='paste-owner-select'
value={selectedOwner}
onChange={(_ev, val) => setSelectedOwner(val)}
>
{[...candidatesMap.keys()].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 @@ -36,7 +36,7 @@ import { useDialogs } from "dialogs";
import * as timeformat from "timeformat";

import { get_permissions, permissionShortStr, useFilesContext } from "./common.ts";
import type { FolderFileInfo } from "./common.ts";
import type { ClipboardInfo, FolderFileInfo } from "./common.ts";
import { confirm_delete } from "./dialogs/delete.tsx";
import { show_create_directory_dialog } from "./dialogs/mkdir.tsx";
import { show_rename_dialog } from "./dialogs/rename.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 "./common.ts";
import type { FolderFileInfo, ClipboardInfo } from "./common.ts";
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
4 changes: 2 additions & 2 deletions src/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { KebabDropdown } from "cockpit-components-dropdown";
import { useDialogs } from "dialogs";

import { useFilesContext } from "./common.ts";
import type { FolderFileInfo } from "./common.ts";
import type { FolderFileInfo, ClipboardInfo } from "./common.ts";
import { showKeyboardShortcuts } from "./dialogs/keyboardShortcutsHelp.tsx";
import { get_menu_items } from "./menu.tsx";
import { UploadButton } from "./upload-button.tsx";
Expand Down Expand Up @@ -147,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
Loading

0 comments on commit dc5296d

Please sign in to comment.