diff --git a/assets/src/App.vue b/assets/src/App.vue index 43e89b0c..8baf0567 100644 --- a/assets/src/App.vue +++ b/assets/src/App.vue @@ -6,6 +6,7 @@ export default { name: 'app', mounted: function () { + // Remove loading animation. let loading = document.getElementById('loading') loading.classList.add('done') diff --git a/assets/src/components/Files.vue b/assets/src/components/Files.vue new file mode 100644 index 00000000..317b0a8b --- /dev/null +++ b/assets/src/components/Files.vue @@ -0,0 +1,171 @@ +<template> + <div v-if="error"> + <h2 class="message" v-if="error === 404"> + <i class="material-icons">gps_off</i> + <span>This location can't be reached.</span> + </h2> + <h2 class="message" v-else-if="error === 403"> + <i class="material-icons">error</i> + <span>You're not welcome here.</span> + </h2> + <h2 class="message" v-else> + <i class="material-icons">error_outline</i> + <span>Something really went wrong.</span> + </h2> + </div> + <editor v-else-if="isEditor"></editor> + <listing :class="{ multiple }" v-else-if="isListing"></listing> + <preview v-else-if="isPreview"></preview> +</template> + +<script> +import Preview from './Preview' +import Listing from './Listing' +import Editor from './Editor' +import css from '@/utils/css' +import api from '@/utils/api' +import { mapGetters, mapState, mapMutations } from 'vuex' + +function updateColumnSizes () { + let columns = Math.floor(document.querySelector('main').offsetWidth / 300) + let items = css(['#listing.mosaic .item', '.mosaic#listing .item']) + + if (columns === 0) columns = 1 + + items.style.width = `calc(${100 / columns}% - 1em)` +} + +export default { + name: 'files', + components: { + Preview, + Listing, + Editor + }, + computed: { + ...mapGetters([ + 'selectedCount' + ]), + ...mapState([ + 'req', + 'user', + 'reload', + 'multiple', + 'loading' + ]), + isListing () { + return this.req.kind === 'listing' && !this.loading + }, + isPreview () { + return this.req.kind === 'preview' && !this.loading + }, + isEditor () { + return this.req.kind === 'editor' && !this.loading + } + }, + data: function () { + return { + error: null + } + }, + created () { + this.fetchData() + console.log('created') + }, + watch: { + '$route': 'fetchData', + 'reload': function () { + this.$store.commit('setReload', false) + this.fetchData() + } + }, + mounted () { + updateColumnSizes() + window.addEventListener('resize', updateColumnSizes) + window.addEventListener('keydown', this.keyEvent) + }, + methods: { + ...mapMutations([ 'setLoading' ]), + fetchData () { + // Set loading to true and reset the error. + this.setLoading(true) + this.error = null + + let url = this.$route.path + if (url === '') url = '/' + if (url[0] !== '/') url = '/' + url + + api.fetch(url) + .then((trueURL) => { + if (!url.endsWith('/') && trueURL.endsWith('/')) { + console.log(trueURL) + window.history.replaceState(window.history.state, document.title, window.location.pathname + '/') + } + + this.setLoading(false) + }) + .catch(error => { + console.log(error) + this.error = error + this.setLoading(false) + }) + }, + keyEvent (event) { + // Esc! + if (event.keyCode === 27) { + this.$store.commit('closeHovers') + + if (this.req.kind !== 'listing') { + return + } + + // If we're on a listing, unselect all files and folders. + let items = document.getElementsByClassName('item') + Array.from(items).forEach(link => { + link.setAttribute('aria-selected', false) + }) + + this.$store.commit('resetSelected') + } + + // Del! + if (event.keyCode === 46) { + if (this.showDeleteButton && this.req.kind !== 'editor') { + this.$store.commit('showHover', 'delete') + } + } + + // F1! + if (event.keyCode === 112) { + event.preventDefault() + this.$store.commit('showHover', 'help') + } + + // F2! + if (event.keyCode === 113) { + if (this.showRenameButton) { + this.$store.commit('showHover', 'rename') + } + } + + // CTRL + S + if (event.ctrlKey || event.metaKey) { + if (String.fromCharCode(event.which).toLowerCase() === 's') { + event.preventDefault() + + if (this.req.kind !== 'editor') { + document.getElementById('download-button').click() + return + } + } + } + }, + openSidebar () { + this.$store.commit('showHover', 'sidebar') + }, + openSearch () { + this.$store.commit('showHover', 'search') + } + } +} +</script> diff --git a/assets/src/components/Header.vue b/assets/src/components/Header.vue new file mode 100644 index 00000000..efd8f3a0 --- /dev/null +++ b/assets/src/components/Header.vue @@ -0,0 +1,135 @@ +<template> + <header> + <div> + <button @click="openSidebar" aria-label="Toggle sidebar" title="Toggle sidebar" class="action"> + <i class="material-icons">menu</i> + </button> + <img src="../assets/logo.svg" alt="File Manager"> + <search></search> + </div> + <div> + <button @click="openSearch" aria-label="Search" title="Search" class="search-button action"> + <i class="material-icons">search</i> + </button> + + <button v-show="showSaveButton" aria-label="Save" class="action" id="save-button"> + <i class="material-icons" title="Save">save</i> + </button> + <rename-button v-show="showRenameButton"></rename-button> + <move-button v-show="showMoveButton"></move-button> + <delete-button v-show="showDeleteButton"></delete-button> + <switch-button v-show="showSwitchButton"></switch-button> + <download-button v-show="showCommonButton"></download-button> + <upload-button v-show="showUpload"></upload-button> + <info-button v-show="showCommonButton"></info-button> + + <button v-show="showSelectButton" @click="$store.commit('multiple', true)" aria-label="Select multiple" class="action"> + <i class="material-icons">check_circle</i> + <span>Select</span> + </button> + </div> + </header> +</template> + +<script> +import Search from './Search' +import InfoButton from './buttons/Info' +import DeleteButton from './buttons/Delete' +import RenameButton from './buttons/Rename' +import UploadButton from './buttons/Upload' +import DownloadButton from './buttons/Download' +import SwitchButton from './buttons/SwitchView' +import MoveButton from './buttons/Move' +import {mapGetters, mapState} from 'vuex' + +export default { + name: 'main', + components: { + Search, + InfoButton, + DeleteButton, + RenameButton, + DownloadButton, + UploadButton, + SwitchButton, + MoveButton + }, + computed: { + ...mapGetters([ + 'selectedCount' + ]), + ...mapState([ + 'req', + 'user', + 'loading', + 'reload', + 'multiple' + ]), + showSelectButton () { + return this.req.kind === 'listing' && !this.loading && this.$route.name === 'Files' + }, + showSaveButton () { + return (this.req.kind === 'editor' && !this.loading) || this.$route.name === 'User' + }, + showSwitchButton () { + return this.req.kind === 'listing' && this.$route.name === 'Files' && !this.loading + }, + showCommonButton () { + return !(this.$route.name !== 'Files' || this.loading) + }, + showUpload () { + if (this.$route.name !== 'Files' || this.loading) return false + + if (this.req.kind === 'editor') return false + return this.user.allowNew + }, + showDeleteButton () { + if (this.$route.name !== 'Files' || this.loading) return false + + if (this.req.kind === 'listing') { + if (this.selectedCount === 0) { + return false + } + + return this.user.allowEdit + } + + return this.user.allowEdit + }, + showRenameButton () { + if (this.$route.name !== 'Files' || this.loading) return false + + if (this.req.kind === 'listing') { + if (this.selectedCount === 1) { + return this.user.allowEdit + } + + return false + } + + return this.user.allowEdit + }, + showMoveButton () { + if (this.$route.name !== 'Files' || this.loading) return false + + if (this.req.kind !== 'listing') { + return false + } + + if (this.selectedCount > 0) { + return this.user.allowEdit + } + + return false + } + }, + methods: { + openSidebar () { + this.$store.commit('showHover', 'sidebar') + }, + openSearch () { + this.$store.commit('showHover', 'search') + } + } +} +</script> diff --git a/assets/src/components/Main.vue b/assets/src/components/Main.vue index fda49a1c..de9b0f87 100644 --- a/assets/src/components/Main.vue +++ b/assets/src/components/Main.vue @@ -1,273 +1,34 @@ <template> - <div :class="{ multiple, loading }"> - <header> - <div> - <button @click="openSidebar" aria-label="Toggle sidebar" title="Toggle sidebar" class="action"> - <i class="material-icons">menu</i> - </button> - <img src="../assets/logo.svg" alt="File Manager"> - <search></search> - </div> - <div> - <button @click="openSearch" aria-label="Search" title="Search" class="search-button action"> - <i class="material-icons">search</i> - </button> - - <button v-show="isEditor" aria-label="Save" class="action" id="save-button"> - <i class="material-icons" title="Save">save</i> - </button> - <rename-button v-show="!loading && showRenameButton"></rename-button> - <move-button v-show="!loading && showMoveButton"></move-button> - <delete-button v-show="!loading && showDeleteButton"></delete-button> - <switch-button v-show="!loading && req.kind !== 'editor'"></switch-button> - <download-button></download-button> - <upload-button v-show="!loading && showUpload"></upload-button> - <info-button></info-button> - - <button v-show="isListing" @click="$store.commit('multiple', true)" aria-label="Select multiple" class="action"> - <i class="material-icons">check_circle</i> - <span>Select</span> - </button> - </div> - </header> - + <div> + <site-header></site-header> <sidebar></sidebar> - <main> - <div v-if="loading"> - <h2 class="message"> - <span>Loading...</span> - </h2> - </div> - <div v-else-if="error"> - <h2 class="message" v-if="error === 404"> - <i class="material-icons">gps_off</i> - <span>This location can't be reached.</span> - </h2> - <h2 class="message" v-else-if="error === 403"> - <i class="material-icons">error</i> - <span>You're not welcome here.</span> - </h2> - <h2 class="message" v-else> - <i class="material-icons">error_outline</i> - <span>Something really went wrong.</span> - </h2> - </div> - <editor v-else-if="isEditor"></editor> - <listing v-else-if="isListing"></listing> - <preview v-else-if="isPreview"></preview> + <router-view></router-view> </main> - <prompts></prompts> </div> </template> <script> import Search from './Search' -import Preview from './Preview' -import Listing from './Listing' -import Editor from './Editor' import Sidebar from './Sidebar' import Prompts from './prompts/Prompts' -import InfoButton from './buttons/Info' -import DeleteButton from './buttons/Delete' -import RenameButton from './buttons/Rename' -import UploadButton from './buttons/Upload' -import DownloadButton from './buttons/Download' -import SwitchButton from './buttons/SwitchView' -import MoveButton from './buttons/Move' -import css from '@/utils/css' -import api from '@/utils/api' -import {mapGetters, mapState} from 'vuex' - -function updateColumnSizes () { - let columns = Math.floor(document.querySelector('main').offsetWidth / 300) - let items = css(['#listing.mosaic .item', '.mosaic#listing .item']) - - if (columns === 0) columns = 1 - - items.style.width = `calc(${100 / columns}% - 1em)` -} +import SiteHeader from './Header' export default { name: 'main', components: { Search, - Preview, - Listing, - Editor, Sidebar, - InfoButton, - DeleteButton, - RenameButton, - DownloadButton, - UploadButton, - SwitchButton, - MoveButton, + SiteHeader, Prompts }, - computed: { - ...mapGetters([ - 'selectedCount' - ]), - ...mapState([ - 'req', - 'user', - 'reload', - 'multiple' - ]), - isListing () { - return this.req.kind === 'listing' && !this.loading - }, - isPreview () { - return this.req.kind === 'preview' && !this.loading - }, - isEditor () { - return this.req.kind === 'editor' && !this.loading - }, - showUpload () { - if (this.req.kind === 'editor') return false - return this.user.allowNew - }, - showDeleteButton () { - if (this.req.kind === 'listing') { - if (this.selectedCount === 0) { - return false - } - - return this.user.allowEdit - } - - return this.user.allowEdit - }, - showRenameButton () { - if (this.req.kind === 'listing') { - if (this.selectedCount === 1) { - return this.user.allowEdit - } - - return false - } - - return this.user.allowEdit - }, - showMoveButton () { - if (this.req.kind !== 'listing') { - return false - } - - if (this.selectedCount > 0) { - return this.user.allowEdit - } - - return false - } - }, - data: function () { - return { - loading: true, - error: null - } - }, - created () { - this.fetchData() - }, watch: { - '$route': 'fetchData', - 'reload': function () { - this.$store.commit('setReload', false) - this.fetchData() - } - }, - mounted () { - updateColumnSizes() - window.addEventListener('resize', updateColumnSizes) - window.addEventListener('keydown', this.keyEvent) - }, - methods: { - fetchData () { - // Set loading to true and reset the error. - this.loading = true - this.error = null - + '$route': function () { // Reset selected items and multiple selection. this.$store.commit('resetSelected') this.$store.commit('multiple', false) this.$store.commit('closeHovers') - - let url = this.$route.path - if (url === '') url = '/' - if (url[0] !== '/') url = '/' + url - - api.fetch(url) - .then((trueURL) => { - if (!url.endsWith('/') && trueURL.endsWith('/')) { - window.history.replaceState(window.history.state, document.title, window.location.pathname + '/') - } - - this.loading = false - }) - .catch(error => { - console.log(error) - this.error = error - this.loading = false - }) - }, - keyEvent (event) { - // Esc! - if (event.keyCode === 27) { - this.$store.commit('closeHovers') - - if (this.req.kind !== 'listing') { - return - } - - // If we're on a listing, unselect all files and folders. - let items = document.getElementsByClassName('item') - Array.from(items).forEach(link => { - link.setAttribute('aria-selected', false) - }) - - this.$store.commit('resetSelected') - } - - // Del! - if (event.keyCode === 46) { - if (this.showDeleteButton && this.req.kind !== 'editor') { - this.$store.commit('showHover', 'delete') - } - } - - // F1! - if (event.keyCode === 112) { - event.preventDefault() - this.$store.commit('showHover', 'help') - } - - // F2! - if (event.keyCode === 113) { - if (this.showRenameButton) { - this.$store.commit('showHover', 'rename') - } - } - - // CTRL + S - if (event.ctrlKey || event.metaKey) { - if (String.fromCharCode(event.which).toLowerCase() === 's') { - event.preventDefault() - - if (this.req.kind !== 'editor') { - document.getElementById('download-button').click() - return - } - } - } - }, - openSidebar () { - this.$store.commit('showHover', 'sidebar') - }, - openSearch () { - this.$store.commit('showHover', 'search') } } } diff --git a/assets/src/components/Settings.vue b/assets/src/components/Settings.vue new file mode 100644 index 00000000..1229ddab --- /dev/null +++ b/assets/src/components/Settings.vue @@ -0,0 +1,11 @@ +<template> + <div> + <h1>Settings</h1> + </div> +</template> + +<script> +export default { + name: 'settings' +} +</script> diff --git a/assets/src/components/Sidebar.vue b/assets/src/components/Sidebar.vue index fc5457a9..8f2d8adb 100644 --- a/assets/src/components/Sidebar.vue +++ b/assets/src/components/Sidebar.vue @@ -25,7 +25,7 @@ </div> <div> - <router-link class="action" to="/dashboard" aria-label="Settings" title="Settings"> + <router-link class="action" to="/settings" aria-label="Settings" title="Settings"> <i class="material-icons">settings_applications</i> <span>Settings</span> </router-link> diff --git a/assets/src/components/User.vue b/assets/src/components/User.vue new file mode 100644 index 00000000..43b971e6 --- /dev/null +++ b/assets/src/components/User.vue @@ -0,0 +1,177 @@ +<template> + <div class="dashboard"> + <h1>User</h1> + + <p><label for="username">Username</label><input type="text" v-model="username" name="username"></p> + <p><label for="password">Password</label><input type="password" :disabled="passwordBlock" v-model="password" name="password"></p> + <p><label for="scope">Scope</label><input type="text" v-model="scope" name="scope"></p> + + <hr> + + <h2>Permissions</h2> + + <p class="small">You can set the user to be an administrator or choose the permissions individually. + If you select "Administrator", all of the other options will be automatically checked. + The management of users remains a privilege of an administrator.</p> + + <p><input type="checkbox" v-model="admin"> Administrator</p> + <p><input type="checkbox" :disabled="admin" v-model="allowNew"> Create new files and directories</p> + <p><input type="checkbox" :disabled="admin" v-model="allowEdit"> Edit, rename and delete files or directories.</p> + <p><input type="checkbox" :disabled="admin" v-model="allowCommands"> Execute commands</p> + + <h3>Commands</h3> + + <p class="small">A space separated list with the available commands for this user. Example: <i>git svn hg</i>.</p> + + <input type="text" v-model="commands"> + + <hr> + + <h2>Rules</h2> + + <p class="small">Here you can define a set of allow and disallow rules for this specific user. The blocked files won't + show up in the listings and they won't be accessible to the user. We support regex and paths relative to + the user's scope.</p> + + <p class="small">Each rule goes in one different line and must start with the keyword <code>allow</code> or <code>disallow</code>. + Then you should write <code>regex</code> if you are using a regular expression and then the expression or the path.</p> + + <p class="small"><strong>Examples</strong></p> + + <ul class="small"> + <li><code>disallow regex \\/\\..+</code> - prevents the access to any dot file (such as .git, .gitignore) in every folder.</li> + <li><code>disallow /Caddyfile</code> - blocks the access to the file named <i>Caddyfile</i> on the root of the scope</li> + </ul> + + <textarea v-model="rules"></textarea> + + <hr> + + <h2>CSS</h2> + + <p class="small">Costum user CSS</p> + + <textarea name="css"></textarea> + </div> +</template> + +<script> +import api from '@/utils/api' + +export default { + name: 'user', + data: () => { + return { + admin: false, + allowNew: false, + allowEdit: false, + allowCommands: false, + passwordBlock: true, + password: '', + username: '', + scope: '', + rules: '', + css: '', + commands: '' + } + }, + created () { + if (this.$route.path === '/users/new') return + + api.getUser(this.$route.params[0]).then(user => { + this.admin = user.admin + this.allowCommands = user.allowCommands + this.allowNew = user.allowNew + this.allowEdit = user.allowEdit + this.scope = user.filesystem + this.username = user.username + this.commands = user.commands.join(' ') + this.css = user.css + + for (let rule of user.rules) { + if (rule.allow) { + this.rules += 'allow ' + } else { + this.rules += 'disallow ' + } + + if (rule.regex) { + this.rules += 'regex ' + rule.regexp.raw + } else { + this.rules += rule.path + } + + this.rules += '\n' + } + }).catch(error => { + console.log(error) + }) + }, + watch: { + admin: function () { + if (!this.admin) return + this.allowCommands = true + this.allowEdit = true + this.allowNew = true + } + } +} +</script> + +<style> +.dashboard { + max-width: 600px; +} + +.dashboard textarea, +.dashboard input[type="text"], +.dashboard input[type="password"] { + padding: .5em 1em; + display: block; + border: 1px solid #e9e9e9; + transition: .2s ease border; + color: #333; + width: 100%; +} + +.dashboard textarea:focus, +.dashboard textarea:hover, +.dashboard input[type="text"]:focus, +.dashboard input[type="password"]:focus, +.dashboard input[type="text"]:hover, +.dashboard input[type="password"]:hover { + border-color: #9f9f9f; +} + +.dashboard textarea { + font-family: monospace; + min-height: 10em; + resize: vertical; +} + +.dashboard p label { + margin-bottom: .2em; + display: block; + font-size: .8em +} + +hr { + border-bottom: 2px solid rgba(181, 181, 181, 0.5); + border-top: 0; + border-right: 0; + border-left: 0; + margin: 1em 0; +} + +li code, +p code { + background: rgba(0, 0, 0, 0.05); + padding: .1em; + border-radius: .2em; +} + +.small { + font-size: .8em; + line-height: 1.5; +} +</style> diff --git a/assets/src/components/Users.vue b/assets/src/components/Users.vue new file mode 100644 index 00000000..b6c01803 --- /dev/null +++ b/assets/src/components/Users.vue @@ -0,0 +1,11 @@ +<template> + <div> + <h1>Users</h1> + </div> +</template> + +<script> +export default { + name: 'users' +} +</script> diff --git a/assets/src/css/base.css b/assets/src/css/base.css index 3d9e6624..898ece28 100644 --- a/assets/src/css/base.css +++ b/assets/src/css/base.css @@ -3,6 +3,7 @@ body { padding-top: 4em; background-color: #f8f8f8; user-select: none; + color: #333; } * { diff --git a/assets/src/router/index.js b/assets/src/router/index.js index 5695a74e..16a6b01d 100644 --- a/assets/src/router/index.js +++ b/assets/src/router/index.js @@ -2,6 +2,10 @@ import Vue from 'vue' import Router from 'vue-router' import Login from '@/components/Login' import Main from '@/components/Main' +import Files from '@/components/Files' +import Users from '@/components/Users' +import User from '@/components/User' +import Settings from '@/components/Settings' import auth from '@/utils/auth.js' Vue.use(Router) @@ -40,11 +44,29 @@ const router = new Router({ children: [ { path: '/files/*', - name: 'Files' + name: 'Files', + component: Files }, { - path: '/dashboard', - name: 'Dashboard' + path: '/settings', + name: 'Settings', + component: Settings + }, + { + path: '/users', + name: 'Users', + component: Users + }, + { + path: '/users/', + redirect: { + path: '/users' + } + }, + { + path: '/users/*', + name: 'User', + component: User }, { path: '/*', diff --git a/assets/src/store/index.js b/assets/src/store/index.js index 43a79789..825237d8 100644 --- a/assets/src/store/index.js +++ b/assets/src/store/index.js @@ -10,6 +10,7 @@ const state = { req: {}, baseURL: document.querySelector('meta[name="base"]').getAttribute('content'), jwt: '', + loading: false, reload: false, selected: [], multiple: false, diff --git a/assets/src/store/mutations.js b/assets/src/store/mutations.js index 42581bf7..4035439a 100644 --- a/assets/src/store/mutations.js +++ b/assets/src/store/mutations.js @@ -16,6 +16,7 @@ const mutations = { state.show = 'error' state.showMessage = value }, + setLoading: (state, value) => { state.loading = value }, setReload: (state, value) => { state.reload = value }, setUser: (state, value) => (state.user = value), setJWT: (state, value) => (state.jwt = value), diff --git a/assets/src/utils/api.js b/assets/src/utils/api.js index b20c67b5..d76f1397 100644 --- a/assets/src/utils/api.js +++ b/assets/src/utils/api.js @@ -105,7 +105,7 @@ function move (oldLink, newLink) { return new Promise((resolve, reject) => { let request = new window.XMLHttpRequest() - request.open('POST', `${store.state.baseURL}/api/resource${oldLink}`, true) + request.open('PATCH', `${store.state.baseURL}/api/resource${oldLink}`, true) request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) request.setRequestHeader('Destination', newLink) @@ -190,6 +190,27 @@ function download (format, ...files) { window.open(url) } +function getUser (id) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/users/${id}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + export default { delete: rm, fetch, @@ -199,5 +220,6 @@ export default { post, command, search, - download + download, + getUser } diff --git a/filemanager.go b/filemanager.go index b4f75ace..1aee0f76 100644 --- a/filemanager.go +++ b/filemanager.go @@ -85,21 +85,21 @@ type User struct { // Rule is a dissalow/allow rule. type Rule struct { // Regex indicates if this rule uses Regular Expressions or not. - Regex bool + Regex bool `json:"regex"` // Allow indicates if this is an allow rule. Set 'false' to be a disallow rule. - Allow bool + Allow bool `json:"allow"` // Path is the corresponding URL path for this rule. - Path string + Path string `json:"path"` // Regexp is the regular expression. Only use this when 'Regex' was set to true. - Regexp *Regexp + Regexp *Regexp `json:"regexp"` } // Regexp is a regular expression wrapper around native regexp. type Regexp struct { - Raw string + Raw string `json:"raw"` regexp *regexp.Regexp }