add editing file permissions

This commit is contained in:
joshuaboud 2022-06-17 15:46:51 -03:00
parent 06c6a04d59
commit 29bad44c52
No known key found for this signature in database
GPG Key ID: 17EFB59E2A8BF50E
6 changed files with 314 additions and 11 deletions

View File

@ -80,7 +80,7 @@
<span v-if="level > 0">{{ entry.path.split('/').slice(-1 * (level + 1)).join('/') }}:</span>
<span v-else>{{ entry.name }}:</span>
{{ entry.mode.toString(8) }}, {{ entry.owner }}:{{ entry.group }}, {{ entry.sizeHuman }},
modified: {{ entry.mtime }}
modified: {{ entry.mtimeStr }}
</div>
</Teleport>
</template>
@ -121,10 +121,8 @@ export default {
const doubleClickCallback = () => {
if (props.entry.resolvedType === 'd') {
emit('browserAction', 'cd', props.entry);
} else if (props.entry.resolvedType === 'f') {
emit('browserAction', 'openFilePrompt', props.entry);
} else {
notifications.value.constructNotification('Cannot open or download file', `${props.entry.nameHTML} is a ${props.entry.resolvedTypeHuman}`, 'denied');
emit('browserAction', 'openFilePrompt', props.entry);
}
}

View File

@ -0,0 +1,98 @@
<!--
Copyright (C) 2022 Josh Boudreau <jboudreau@45drives.com>
This file is part of Cockpit File Sharing.
Cockpit File Sharing 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.
Cockpit File Sharing 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 Cockpit File Sharing.
If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<div class="inline-grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 justify-items-center">
<label class="justify-self-start block text-sm font-medium"></label>
<label class="text-label">Read</label>
<label class="text-label">Write</label>
<label class="text-label">Execute</label>
<label class="justify-self-start text-label">Owner</label>
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.owner.read" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.owner.write" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.owner.execute" />
<label class="justify-self-start text-label">Group</label>
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.group.read" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.group.write" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.group.execute" />
<label class="justify-self-start text-label">Other</label>
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.other.read" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.other.write" />
<input type="checkbox" class="input-checkbox" v-model="modeMatrix.other.execute" />
<label class="justify-self-start text-label">Mode</label>
<span class="col-span-3 font-mono font-medium whitespace-nowrap">{{ modeStr }}</span>
</div>
</template>
<script>
import { reactive, computed } from 'vue';
export default {
props: {
modelValue: Number,
},
setup(props, { emit }) {
const modeStr = computed(
() =>
(props.modelValue & 0b100000000 ? 'r' : '-')
+ (props.modelValue & 0b010000000 ? 'w' : '-')
+ (props.modelValue & 0b001000000 ? 'x' : '-')
+ (props.modelValue & 0b000100000 ? 'r' : '-')
+ (props.modelValue & 0b000010000 ? 'w' : '-')
+ (props.modelValue & 0b000001000 ? 'x' : '-')
+ (props.modelValue & 0b000000100 ? 'r' : '-')
+ (props.modelValue & 0b000000010 ? 'w' : '-')
+ (props.modelValue & 0b000000001 ? 'x' : '-')
+ ` (${props.modelValue.toString(8).padStart(3, '0')})`
);
const computedBit = (mask, getter) => computed({
get: () => getter() & mask ? true : false,
set: (value) => emit('update:modelValue', value ? (props.modelValue | mask) : (props.modelValue & (~mask))),
})
const modeMatrix = reactive({
other: {
execute: computedBit(0b000000001, () => props.modelValue),
write: computedBit(0b000000010, () => props.modelValue),
read: computedBit(0b000000100, () => props.modelValue),
},
group: {
execute: computedBit(0b000001000, () => props.modelValue),
write: computedBit(0b000010000, () => props.modelValue),
read: computedBit(0b000100000, () => props.modelValue),
},
owner: {
execute: computedBit(0b001000000, () => props.modelValue),
write: computedBit(0b010000000, () => props.modelValue),
read: computedBit(0b100000000, () => props.modelValue),
},
});
return {
modeMatrix,
modeStr,
}
},
emits: [
'update:modelValue',
],
}
</script>

View File

@ -0,0 +1,128 @@
<!--
Copyright (C) 2022 Josh Boudreau <jboudreau@45drives.com>
This file is part of Cockpit File Sharing.
Cockpit File Sharing 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.
Cockpit File Sharing 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 Cockpit File Sharing.
If not, see <https://www.gnu.org/licenses/>.
-->
<template>
<ModalPopup :showModal="show" :headerText="entry?.nameHTML" @apply="apply"
@cancel="$emit('hide')">
<div class="flex flex-col space-y-content items-start">
<FileModeMatrix v-model="mode" />
<div>
<label class="block text-sm font-medium">Owner</label>
<select class="input-textlike" v-model="owner">
<option v-for="user in users" :value="user.user">{{ user.pretty }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Group</label>
<select class="input-textlike" v-model="group">
<option v-for="group in groups" :value="group.group">{{ group.pretty }}</option>
</select>
</div>
</div>
</ModalPopup>
</template>
<script>
import { watch, ref, onMounted } from 'vue';
import ModalPopup from "./ModalPopup.vue";
import FileModeMatrix from "./FileModeMatrix.vue";
import { useSpawn, errorString, canonicalPath } from "@45drives/cockpit-helpers";
import { getUsers } from '../functions/getUsers';
import { getGroups } from '../functions/getGroups';
export default {
props: {
show: Boolean,
entry: Object,
onError: {
type: Function,
required: false,
default: console.error,
},
},
setup(props, { emit }) {
const mode = ref(0);
const owner = ref("");
const group = ref("");
const users = ref([]);
const groups = ref([]);
const getPermissions = async () => {
try {
let modeStr;
[modeStr, owner.value, group.value] = (
await useSpawn(['stat', '--format=%a:%U:%G', props.entry.path], { superuser: 'try', host: props.entry.host }).promise()
).stdout.trim().split(':');
mode.value = parseInt(modeStr, 8);
} catch (state) {
const error = new Error(errorString(state));
error.name = "Permissions Query Error";
props.onError(error);
emit('hide');
}
}
const apply = async () => {
if (canonicalPath(props.entry.path) === '/') {
const error = new Error("Cannot Edit Permissions for '/'. If you think you need to do this, you don't.");
error.name = "Permissions Apply Error";
props.onError(error);
emit('hide');
return;
}
const procs = [];
procs.push(useSpawn(['chown', owner.value, props.entry.path], { superuser: 'try', host: props.entry.host }).promise());
procs.push(useSpawn(['chgrp', group.value, props.entry.path], { superuser: 'try', host: props.entry.host }).promise());
procs.push(useSpawn(['chmod', mode.value.toString(8), props.entry.path], { superuser: 'try', host: props.entry.host }).promise());
for (const proc of procs) {
try {
await proc;
} catch (state) {
const error = new Error(errorString(state));
error.name = "Permissions Apply Error";
props.onError(error);
}
}
emit('hide');
}
onMounted(async () => {
users.value = await getUsers();
groups.value = await getGroups();
});
watch(() => props.show, (show, lastShow) => {
if (show && props.entry) getPermissions();
}, { immediate: true });
return {
mode,
owner,
group,
users,
groups,
apply,
}
},
components: {
ModalPopup,
FileModeMatrix,
},
emits: [
'hide',
]
}
</script>

View File

@ -0,0 +1,24 @@
import { useSpawn } from "@45drives/cockpit-helpers";
export async function getGroups() {
let groups = [];
const groupDB = (await useSpawn(['getent', 'group'], { superuser: 'try' }).promise()).stdout;
groupDB.split('\n').forEach((record) => {
const fields = record.split(':');
const group = fields[0];
const gid = fields[2];
if (gid >= 1000 || gid === '0')
groups.push({ group: group, domain: false, pretty: group });
})
try {
await useSpawn(['realm', 'list'], { superuser: 'try' }).promise(); // throws if not domain
const domainGroupsDB = (await useSpawn(['wbinfo', '-g'], { superuser: 'try' }).promise()).stdout
domainGroupsDB.split('\n').forEach((record) => {
if (/^\s*$/.test(record))
return;
groups.push({ group: record.replace(/^[^\\]+\\/, ""), domain: true, pretty: record.replace(/^[^\\]+\\/, "") + " (domain)" });
})
} catch {}
groups.sort((a, b) => a.pretty.localeCompare(b.pretty));
return groups
}

View File

@ -0,0 +1,24 @@
import { useSpawn } from "@45drives/cockpit-helpers";
export async function getUsers() {
let users = [];
const passwdDB = (await useSpawn(['getent', 'passwd'], { superuser: 'try' }).promise()).stdout;
passwdDB.split('\n').forEach((record) => {
const fields = record.split(':');
const user = fields[0];
const uid = fields[2];
if (uid >= 1000 || uid === '0') // include root
users.push({ user: user, domain: false, pretty: user });
})
try {
await useSpawn(['realm', 'list'], { superuser: 'try' }).promise(); // throws if not domain
const domainUsersDB = (await useSpawn(['wbinfo', '-u'], { superuser: 'try' }).promise()).stdout;
domainUsersDB.split('\n').forEach((record) => {
if (/^\s*$/.test(record))
return;
users.push({ user: record.replace(/^[^\\]+\\/, ""), domain: true, pretty: record.replace(/^[^\\]+\\/, "") + " (domain)" });
})
} catch {}
users.sort((a, b) => a.pretty.localeCompare(b.pretty));
return users;
}

View File

@ -68,25 +68,31 @@
<div class="grow overflow-hidden">
<DirectoryView :host="pathHistory.current()?.host" :path="pathHistory.current()?.path"
:searchFilterRegExp="searchFilterRegExp" @cd="path => cd({ path })" @edit="openEditor"
@browserAction="handleAction"
ref="directoryViewRef" />
@browserAction="handleAction" ref="directoryViewRef" />
</div>
</div>
</div>
<ModalPopup :showModal="openFilePromptModal.show" :headerText="openFilePromptModal.entry?.name ?? 'NULL'" @close="() => openFilePromptModal.close()">
What would you like to do with this file?
<ModalPopup :showModal="openFilePromptModal.show" :headerText="openFilePromptModal.entry?.name ?? 'NULL'"
@close="() => openFilePromptModal.close()" autoWidth>
What would you like to do with this {{ openFilePromptModal.entry?.resolvedTypeHuman }}?
<template #footer>
<button type="button" class="btn btn-secondary" @click="() => openFilePromptModal.close()">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="() => openFilePromptModal.action('edit') ">
<button type="button" class="btn btn-primary" @click="() => openFilePromptModal.action('editPermissions')">
Edit permissions
</button>
<button v-if="openFilePromptModal.entry?.resolvedType === 'f'" type="button" class="btn btn-primary"
@click="() => openFilePromptModal.action('edit')">
Open for editing
</button>
<button type="button" class="btn btn-primary" @click="() => openFilePromptModal.action('download') ">
<button v-if="openFilePromptModal.entry?.resolvedType === 'f'" type="button" class="btn btn-primary"
@click="() => openFilePromptModal.action('download')">
Download
</button>
</template>
</ModalPopup>
<FilePermissions :show="filePermissions.show" @hide="filePermissions.close" :entry="filePermissions.entry" />
<Teleport to="#footer-buttons">
<IconToggle v-model="darkMode" v-slot="{ value }">
<MoonIcon v-if="value" class="size-icon icon-default" />
@ -113,6 +119,7 @@ import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, RefreshIcon, ChevronDownIco
import IconToggle from '../components/IconToggle.vue';
import ModalPopup from '../components/ModalPopup.vue';
import { fileDownload } from '@45drives/cockpit-helpers';
import FilePermissions from '../components/FilePermissions.vue';
const encodePartial = (string) =>
encodeURIComponent(string)
@ -179,6 +186,20 @@ export default {
openFilePromptModal.close();
}
});
const filePermissions = reactive({
show: false,
entry: null,
resetTimeoutHandle: null,
open: (entry) => {
clearTimeout(filePermissions.resetTimeoutHandle);
filePermissions.entry = entry;
filePermissions.show = true;
},
close: () => {
filePermissions.show = false;
filePermissions.resetTimeoutHandle = setTimeout(() => filePermissions.resetTimeoutHandle = filePermissions.entry = null, 500);
},
});
const cd = ({ path, host }) => {
const newHost = host ?? (pathHistory.current().host);
@ -195,7 +216,7 @@ export default {
}
const up = () => {
cd({path: pathHistory.current().path + '/..'});
cd({ path: pathHistory.current().path + '/..' });
}
const openEditor = ({ path, host }) => {
@ -213,6 +234,10 @@ export default {
openFilePromptModal.open(entry);
}
const openFilePermissions = (entry) => {
filePermissions.open(entry);
}
const getSelected = () => directoryViewRef.value?.getSelected?.() ?? [];
const handleAction = (action, ...args) => {
@ -223,6 +248,9 @@ export default {
case 'edit':
openEditor(...args);
break;
case 'editPermissions':
openFilePermissions(...args);
break;
case 'openFilePrompt':
openFilePrompt(...args);
break;
@ -267,6 +295,7 @@ export default {
backHistoryDropdown,
forwardHistoryDropdown,
openFilePromptModal,
filePermissions,
cd,
back,
forward,
@ -274,6 +303,7 @@ export default {
openEditor,
download,
openFilePrompt,
openFilePermissions,
getSelected,
handleAction,
}
@ -295,6 +325,7 @@ export default {
ViewListIcon,
ViewGridIcon,
ModalPopup,
FilePermissions,
},
}
</script>