Skip to content

Commit

Permalink
Support uploading files with administrator privileges
Browse files Browse the repository at this point in the history
Files could not yet support uploading to non-logged in user directories
as these files would always be uploaded as `root:root` and this might
not be wanted in directories not owned by `root`. Therefore they are now
uploaded by default as the owner of the directory with the ability to
change ownership after upload in the shown alert.
  • Loading branch information
jelly committed Dec 13, 2024
1 parent 65761bc commit 2a1b189
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 31 deletions.
24 changes: 19 additions & 5 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ interface Alert {
key: string,
title: string,
variant: AlertVariant,
detail?: string,
detail?: string | React.ReactNode,
actionLinks?: React.ReactNode
}

export interface FolderFileInfo extends FileInfo {
Expand All @@ -56,12 +57,15 @@ export interface FolderFileInfo extends FileInfo {
}

interface FilesContextType {
addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void,
addAlert: (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode,
actionLinks?: React.ReactNode) => void,
removeAlert: (key: string) => void,
cwdInfo: FileInfo | null,
}

export const FilesContext = React.createContext({
addAlert: () => console.warn("FilesContext not initialized"),
removeAlert: () => console.warn("FilesContext not initialized"),
cwdInfo: null,
} as FilesContextType);

Expand Down Expand Up @@ -163,14 +167,23 @@ export const Application = () => {
if (loading)
return <EmptyStatePanel loading />;

const addAlert = (title: string, variant: AlertVariant, key: string, detail?: string) => {
setAlerts(prevAlerts => [...prevAlerts, { title, variant, key, ...detail && { detail }, }]);
const addAlert = (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode,
actionLinks?: React.ReactNode) => {
setAlerts(prevAlerts => [
...prevAlerts, {
title,
variant,
key,
...detail && { detail },
...actionLinks && { actionLinks },
}
]);
};
const removeAlert = (key: string) => setAlerts(prevAlerts => prevAlerts.filter(alert => alert.key !== key));

return (
<Page>
<FilesContext.Provider value={{ addAlert, cwdInfo }}>
<FilesContext.Provider value={{ addAlert, removeAlert, cwdInfo }}>
<WithDialogs>
<AlertGroup isToast isLiveRegion>
{alerts.map(alert => (
Expand All @@ -184,6 +197,7 @@ export const Application = () => {
onClose={() => removeAlert(alert.key)}
/>
}
actionLinks={alert.actionLinks}
key={alert.key}
>
{alert.detail}
Expand Down
3 changes: 3 additions & 0 deletions src/upload-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,6 @@ button.cancel-button {
}
}

.ct-grey-text {
color: var(--pf-v5-global--Color--200);
}
156 changes: 147 additions & 9 deletions src/upload-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import React, { useState, useRef } from "react";

import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import { AlertVariant, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
Expand All @@ -29,14 +29,18 @@ import { Progress } from "@patternfly/react-core/dist/esm/components/Progress";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { TrashIcon } from "@patternfly/react-icons";

import cockpit from "cockpit";
import cockpit, { BasicError } from "cockpit";
import type { FileInfo } from "cockpit/fsinfo";
import { upload } from "cockpit-upload-helper";
import { DialogResult, useDialogs } from "dialogs";
import { superuser } from "superuser";
import * as timeformat from "timeformat";
import { fmt_to_fragments } from "utils";

import { useFilesContext } from "./app.tsx";
import { FolderFileInfo, useFilesContext } from "./app.tsx";
import { permissionShortStr } from "./common.ts";
import { edit_permissions } from "./dialogs/permissions.tsx";
import { get_owner_candidates } from "./ownership.tsx";

import "./upload-button.scss";

Expand All @@ -48,6 +52,26 @@ interface ConflictResult {
applyToAll: boolean;
}

const UploadedFilesList = ({
files,
modes,
owner,
}: {
files: File[],
modes: number[],
owner: string,
}) => {
cockpit.assert(modes.length !== 0, "modes cannot be empty");
const permission = permissionShortStr(modes[0]);
const title = files.length === 1 ? files[0].name : cockpit.format(_("$0 files"), files.length);
return (
<>
<p>{title}</p>
{owner && <p className="ct-grey-text">{cockpit.format(_("Uploaded as $0, $1"), owner, permission)}</p>}
</>
);
};

const FileConflictDialog = ({
path,
file,
Expand Down Expand Up @@ -131,7 +155,7 @@ export const UploadButton = ({
path: string,
}) => {
const ref = useRef<HTMLInputElement>(null);
const { addAlert, cwdInfo } = useFilesContext();
const { addAlert, removeAlert, cwdInfo } = useFilesContext();
const dialogs = useDialogs();
const [showPopover, setPopover] = React.useState(false);
const [uploadedFiles, setUploadedFiles] = useState<{[name: string]:
Expand All @@ -155,7 +179,16 @@ export const UploadButton = ({
cockpit.assert(event.target.files, "not an <input type='file'>?");
cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined");
let next_progress = 0;
const toUploadFiles = [];
let owner = null;
const toUploadFiles: File[] = [];

// When we are superuser upload as the owner of the directory and allow
// the user to later change ownership if required.
const user = await cockpit.user();
if (superuser.allowed && cwdInfo) {
const candidates = get_owner_candidates(user, cwdInfo);
owner = candidates[0];
}

const resetInput = () => {
// Reset input field in the case a download was cancelled and has to be re-uploaded
Expand Down Expand Up @@ -207,9 +240,11 @@ export const UploadButton = ({
window.addEventListener("beforeunload", beforeUnloadHandler);

const cancelledUploads = [];
const fileModes: number[] = [];
await Promise.allSettled(toUploadFiles.map(async (file: File) => {
const destination = path + file.name;
let destination = path + file.name;
const abort = new AbortController();
let options = { };

setUploadedFiles(oldFiles => {
return {
Expand All @@ -218,6 +253,49 @@ export const UploadButton = ({
};
});

if (owner !== null) {
destination = `${path}.${file.name}.tmp`;
// The cockpit.file() API does not support setting an owner/group when uploading a new
// file with fsreplace1. This requires a re-design of the Files API:
// https://issues.redhat.com/browse/COCKPIT-1215
//
// For now we create an empty file using fsreplace1 and give it the proper ownership and using that tag
// upload the to be uploaded file. This prevents uploading with the wrong ownership.
//
// To support changing permissions after upload with our change permissions dialog we obtain the
// file mode using `stat` as fsreplace1 does not report this back except via the `tag` which is not
// a stable interface.
try {
await cockpit.file(destination, { superuser: "try" }).replace("");
await cockpit.spawn(["chown", owner, destination], { superuser: "try" });
await cockpit.file(destination, { superuser: "try" }).read()
.then((((_content: string, tag: string) => {
options = { superuser: "try", tag };
}) as any /* eslint-disable-line @typescript-eslint/no-explicit-any */));
const stat = await cockpit.spawn(["stat", "--format", "%a", destination], { superuser: "try" });
fileModes.push(Number.parseInt(stat.trimEnd(), 8));
} catch (exc) {
const err = exc as BasicError;
console.warn("Cannot set initial file permissions", err.toString());
addAlert(_("Failed"), AlertVariant.warning, "upload", err.toString());

try {
await cockpit.file(destination, { superuser: "require" }).replace(null);
} catch (exc) {
console.warn(`Unable to cleanup file: ${destination}, err: ${exc}`);
}

cancelledUploads.push(file);
setUploadedFiles(oldFiles => {
const copy = { ...oldFiles };
delete copy[file.name];
return copy;
});

return;
}
}

try {
await upload(destination, file, (progress) => {
const now = performance.now();
Expand All @@ -231,10 +309,34 @@ export const UploadButton = ({
[file.name]: { ...oldFile, progress },
};
});
}, abort.signal);
// TODO: pass { superuser: try } depending on directory owner
}, abort.signal, options);

if (owner !== null) {
try {
await cockpit.spawn(["mv", destination, path + file.name],
{ superuser: "require" });
} catch (exc) {
console.warn("Unable to move file to final destination", exc);
addAlert(_("Upload error"), AlertVariant.danger, "upload-error",
_("Unable to move uploaded file to final destination"));
try {
await cockpit.file(destination, { superuser: "require" }).replace(null);
} catch (exc) {
console.warn(`Unable to cleanup file: ${destination}, err: ${exc}`);
}
}
}
} catch (exc) {
cockpit.assert(exc instanceof Error, "Unknown exception type");

// Clean up touched file
if (owner) {
try {
await cockpit.file(destination, { superuser: "require" }).replace(null);
} catch (exc) {
console.warn(`Unable to cleanup file: ${destination}, err: ${exc}`);
}
}
if (exc instanceof DOMException && exc.name === 'AbortError') {
addAlert(_("Cancelled"), AlertVariant.warning, "upload",
cockpit.format(_("Cancelled upload of $0"), file.name));
Expand All @@ -256,7 +358,43 @@ export const UploadButton = ({

// If all uploads are cancelled, don't show an alert
if (cancelledUploads.length !== toUploadFiles.length) {
addAlert(_("Upload complete"), AlertVariant.success, "upload-success", _("Successfully uploaded file(s)"));
const title = cockpit.ngettext(_("File uploaded"), _("Files uploaded"), toUploadFiles.length);
const key = window.btoa(toUploadFiles.join(""));
let description;
let action;

if (owner !== null) {
description = (
<UploadedFilesList
files={toUploadFiles}
modes={fileModes}
owner={owner}
/>
);
action = (
<AlertActionLink
onClick={() => {
removeAlert(key);
const [user, group] = owner.split(':');
const uploadedFiles: FolderFileInfo[] = toUploadFiles.map((file, idx) => {
return {
name: file.name,
to: null,
category: null,
user,
group,
mode: fileModes[idx]
};
});
edit_permissions(dialogs, uploadedFiles, path);
}}
>
{_("Change permissions")}
</AlertActionLink>
);
}

addAlert(title, AlertVariant.success, key, description, action);
}
};

Expand Down
Loading

0 comments on commit 2a1b189

Please sign in to comment.