Copy and cut files

Former-commit-id: ac9399efef37f13706590384e29295c6234acb7c [formerly f62dcb89aa49125769405c502688f3f8d4348da3] [formerly 4b9f0fd696f449c30da2d2f7a11cf5d6c7054c54 [formerly 64e70e39dc]]
Former-commit-id: 422165231c785b121b1f6725909d3ce7c8dcdfee [formerly 2b26582a03f674d40abb39e27b2cc880d2bd3ebb]
Former-commit-id: ee743808a3c012514c9b7d57a34c79e408a8a78b
pull/726/head
Henrique Dias 2017-07-26 15:55:39 +01:00
parent 42bab1458e
commit 149465ab52
19 changed files with 371 additions and 230 deletions

View File

@ -38,6 +38,7 @@
<div id="file-selection" v-if="isMobile && req.kind === 'listing'">
<span v-if="selectedCount > 0">{{ selectedCount }} selected</span>
<rename-button v-show="showRenameButton"></rename-button>
<copy-button v-show="showMoveButton"></copy-button>
<move-button v-show="showMoveButton"></move-button>
<delete-button v-show="showDeleteButton"></delete-button>
</div>
@ -46,6 +47,7 @@
<div id="dropdown" :class="{ active: showMore }">
<div v-if="!isListing || !isMobile">
<rename-button v-show="showRenameButton"></rename-button>
<copy-button v-show="showMoveButton"></copy-button>
<move-button v-show="showMoveButton"></move-button>
<delete-button v-show="showDeleteButton"></delete-button>
</div>
@ -88,6 +90,7 @@ import UploadButton from './buttons/Upload'
import DownloadButton from './buttons/Download'
import SwitchButton from './buttons/SwitchView'
import MoveButton from './buttons/Move'
import CopyButton from './buttons/Copy'
import {mapGetters, mapState} from 'vuex'
import api from '@/utils/api'
import buttons from '@/utils/buttons'
@ -100,6 +103,7 @@ export default {
DeleteButton,
RenameButton,
DownloadButton,
CopyButton,
UploadButton,
SwitchButton,
MoveButton

View File

@ -82,7 +82,7 @@ export default {
name: 'listing',
components: { Item },
computed: {
...mapState(['req']),
...mapState(['req', 'selected']),
nameSorted () {
return (this.req.sort === 'name')
},
@ -130,17 +130,69 @@ export default {
return
}
if (String.fromCharCode(event.which).toLowerCase() !== 'f') {
return
}
let key = String.fromCharCode(event.which).toLowerCase()
event.preventDefault()
this.$store.commit('showHover', 'search')
switch (key) {
case 'f':
event.preventDefault()
this.$store.commit('showHover', 'search')
break
case 'c':
case 'x':
this.copyCut(event, key)
break
case 'v':
this.paste(event)
break
}
},
preventDefault (event) {
// Wrapper around prevent default.
event.preventDefault()
},
copyCut (event, key) {
event.preventDefault()
let items = []
for (let i of this.selected) {
items.push({
from: this.req.items[i].url,
name: encodeURIComponent(this.req.items[i].name)
})
}
this.$store.commit('updateClipboard', {
key: key,
items: items
})
},
paste (event) {
event.preventDefault()
let items = []
for (let item of this.$store.state.clipboard.items) {
items.push({
from: item.from,
to: this.$route.path + item.name
})
}
if (this.$store.state.clipboard.key === 'x') {
api.move(items).then(() => {
this.$store.commit('setReload', true)
}).catch(error => {
this.$store.commit('showError', error)
})
return
}
api.copy(items).then(() => {
this.$store.commit('setReload', true)
}).catch(error => {
this.$store.commit('showError', error)
})
},
resizeEvent () {
// Update the columns size based on the window width.
let columns = Math.floor(document.querySelector('main').offsetWidth / 300)

View File

@ -92,16 +92,16 @@ export default {
if (this.selectedCount === 0) return
let promises = []
let items = []
for (let i of this.selected) {
let url = this.req.items[i].url
let name = this.req.items[i].name
promises.push(api.move(url, this.url + encodeURIComponent(name)))
items.push({
from: this.req.items[i].url,
to: this.url + encodeURIComponent(this.req.items[i].name)
})
}
Promise.all(promises)
api.move(items)
.then(() => {
this.$store.commit('setReload', true)
})

View File

@ -0,0 +1,17 @@
<template>
<button @click="show" aria-label="Copy" title="Copy" class="action" id="copy-button">
<i class="material-icons">content_copy</i>
<span>Copy file</span>
</button>
</template>
<script>
export default {
name: 'copy-button',
methods: {
show: function (event) {
this.$store.commit('showHover', 'copy')
}
}
}
</script>

View File

@ -0,0 +1,58 @@
<template>
<div class="prompt">
<h3>Copy</h3>
<p>Choose the place to copy your files:</p>
<file-list @update:selected="val => dest = val"></file-list>
<div>
<button class="ok" @click="copy">Copy</button>
<button class="cancel" @click="$store.commit('closeHovers')">Cancel</button>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import FileList from './FileList'
import api from '@/utils/api'
import buttons from '@/utils/buttons'
export default {
name: 'copy',
components: { FileList },
data: function () {
return {
current: window.location.pathname,
dest: null
}
},
computed: mapState(['req', 'selected']),
methods: {
copy: function (event) {
event.preventDefault()
buttons.loading('copy')
let items = []
// Create a new promise for each file.
for (let item of this.selected) {
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name)
})
}
// Execute the promises.
api.copy(items)
.then(() => {
buttons.done('copy')
this.$router.push({ path: this.dest })
})
.catch(error => {
buttons.done('copy')
this.$store.commit('showError', error)
})
}
}
}
</script>

View File

@ -0,0 +1,137 @@
<template>
<div>
<ul class="file-list">
<li @click="select"
@touchstart="touchstart"
@dblclick="next"
:aria-selected="selected == item.url"
:key="item.name" v-for="item in items"
:data-url="item.url">{{ item.name }}</li>
</ul>
<p>Currently navigating on: <code>{{ nav }}</code>.</p>
</div>
</template>
<script>
import { mapState } from 'vuex'
import url from '@/utils/url'
import api from '@/utils/api'
export default {
name: 'file-list',
data: function () {
return {
items: [],
touches: {
id: '',
count: 0
},
selected: null,
current: window.location.pathname
}
},
computed: {
...mapState(['req']),
nav () {
return decodeURIComponent(this.current)
}
},
mounted () {
// If we're showing this on a listing,
// we can use the current request object
// to fill the move options.
if (this.req.kind === 'listing') {
this.fillOptions(this.req)
return
}
// Otherwise, we must be on a preview or editor
// so we fetch the data from the previous directory.
api.fetch(url.removeLastDir(this.$route.path))
.then(this.fillOptions)
.catch(this.showError)
},
methods: {
fillOptions (req) {
// Sets the current path and resets
// the current items.
this.current = req.url
this.items = []
this.$emit('update:selected', this.current)
// If the path isn't the root path,
// show a button to navigate to the previous
// directory.
if (req.url !== '/files/') {
this.items.push({
name: '..',
url: url.removeLastDir(req.url) + '/'
})
}
// If this folder is empty, finish here.
if (req.items === null) return
// Otherwise we add every directory to the
// move options.
for (let item of req.items) {
if (!item.isDir) continue
this.items.push({
name: item.name,
url: item.url
})
}
},
next: function (event) {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
let uri = event.currentTarget.dataset.url
api.fetch(uri)
.then(this.fillOptions)
.catch(this.showError)
},
touchstart (event) {
let url = event.currentTarget.dataset.url
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
this.touches.count = 0
}, 300)
// If the element the user is touching
// is different from the last one he touched,
// reset the count.
if (this.touches.id !== url) {
this.touches.id = url
this.touches.count = 1
return
}
this.touches.count++
// If there is more than one touch already,
// open the next screen.
if (this.touches.count > 1) {
this.next(event)
}
},
select: function (event) {
// If the element is already selected, unselect it.
if (this.selected === event.currentTarget.dataset.url) {
this.selected = null
this.$emit('update:selected', this.current)
return
}
// Otherwise select the element.
this.selected = event.currentTarget.dataset.url
this.$emit('update:selected', this.selected)
}
}
}
</script>

View File

@ -3,16 +3,7 @@
<h3>Move</h3>
<p>Choose new house for your file(s)/folder(s):</p>
<ul class="file-list">
<li @click="select"
@touchstart="touchstart"
@dblclick="next"
:aria-selected="moveTo == item.url"
:key="item.name" v-for="item in items"
:data-url="item.url">{{ item.name }}</li>
</ul>
<p>Currently navigating on: <code>{{ current }}</code>.</p>
<file-list @update:selected="val => dest = val"></file-list>
<div>
<button class="ok" @click="move">Move</button>
@ -23,145 +14,46 @@
<script>
import { mapState } from 'vuex'
import url from '@/utils/url'
import FileList from './FileList'
import api from '@/utils/api'
import buttons from '@/utils/buttons'
export default {
name: 'move',
components: { FileList },
data: function () {
return {
items: [],
touches: {
id: '',
count: 0
},
current: window.location.pathname,
moveTo: null
dest: null
}
},
computed: mapState(['req', 'selected', 'baseURL']),
mounted () {
// If we're showing this on a listing,
// we can use the current request object
// to fill the move options.
if (this.req.kind === 'listing') {
this.fillOptions(this.req)
return
}
// Otherwise, we must be on a preview or editor
// so we fetch the data from the previous directory.
api.fetch(url.removeLastDir(this.$rute.path))
.then(this.fillOptions)
.catch(this.showError)
},
computed: mapState(['req', 'selected']),
methods: {
move: function (event) {
event.preventDefault()
// Set the destination and create the promises array.
let promises = []
let dest = (this.moveTo === null) ? this.current : this.moveTo
buttons.loading('move')
let items = []
// Create a new promise for each file.
for (let item of this.selected) {
let from = this.req.items[item].url
let to = dest + '/' + encodeURIComponent(this.req.items[item].name)
to = to.replace('//', '/')
promises.push(api.move(from, to))
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name)
})
}
// Execute the promises.
Promise.all(promises)
api.move(items)
.then(() => {
buttons.done('move')
this.$router.push({ path: dest })
this.$router.push({ path: this.dest })
})
.catch(error => {
buttons.done('move')
this.$store.commit('showError', error)
})
},
fillOptions (req) {
// Sets the current path and resets
// the current items.
this.current = req.url
this.items = []
// If the path isn't the root path,
// show a button to navigate to the previous
// directory.
if (req.url !== '/files/') {
this.items.push({
name: '..',
url: url.removeLastDir(req.url) + '/'
})
}
// If this folder is empty, finish here.
if (req.items === null) return
// Otherwise we add every directory to the
// move options.
for (let item of req.items) {
if (!item.isDir) continue
this.items.push({
name: item.name,
url: item.url
})
}
},
showError (error) {
this.$store.commit('showError', error)
},
next: function (event) {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
let uri = event.currentTarget.dataset.url
api.fetch(uri)
.then(this.fillOptions)
.catch(this.showError)
},
touchstart (event) {
let url = event.currentTarget.dataset.url
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
this.touches.count = 0
}, 300)
// If the element the user is touching
// is different from the last one he touched,
// reset the count.
if (this.touches.id !== url) {
this.touches.id = url
this.touches.count = 1
return
}
this.touches.count++
// If there is more than one touch already,
// open the next screen.
if (this.touches.count > 1) {
this.next(event)
}
},
select: function (event) {
// If the element is already selected, unselect it.
if (this.moveTo === event.currentTarget.dataset.url) {
this.moveTo = null
return
}
// Otherwise select the element.
this.moveTo = event.currentTarget.dataset.url
event.preventDefault()
}
}
}

View File

@ -8,6 +8,7 @@
<delete v-else-if="showDelete"></delete>
<info v-else-if="showInfo"></info>
<move v-else-if="showMove"></move>
<copy v-else-if="showCopy"></copy>
<error v-else-if="showError"></error>
<success v-else-if="showSuccess"></success>
@ -42,6 +43,7 @@ import Delete from './Delete'
import Rename from './Rename'
import Download from './Download'
import Move from './Move'
import Copy from './Copy'
import Error from './Error'
import Success from './Success'
import NewFile from './NewFile'
@ -60,6 +62,7 @@ export default {
Download,
Success,
Move,
Copy,
NewFile,
NewDir,
Help
@ -83,6 +86,7 @@ export default {
showDelete: function () { return this.show === 'delete' },
showRename: function () { return this.show === 'rename' },
showMove: function () { return this.show === 'move' },
showCopy: function () { return this.show === 'copy' },
showNewFile: function () { return this.show === 'newFile' },
showNewDir: function () { return this.show === 'newDir' },
showDownload: function () { return this.show === 'download' },

View File

@ -53,7 +53,7 @@ export default {
this.name = encodeURIComponent(this.name)
newLink = url.removeLastDir(oldLink) + '/' + this.name
api.move(oldLink, newLink)
api.move([{ from: oldLink, to: newLink }])
.then(() => {
if (this.req.kind !== 'listing') {
this.$router.push({ path: newLink })

View File

@ -61,7 +61,7 @@
background: #fff;
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
width: 95%;
max-width: 16em;
max-width: 18em;
}
#file-selection .action {
border-radius: 50%;

View File

@ -49,7 +49,7 @@
word-wrap: break-word;
}
.prompt div {
.prompt div:last-of-type {
margin-top: 1em;
display: flex;
justify-content: flex-start;

View File

@ -9,6 +9,10 @@ const state = {
user: {},
req: {},
plugins: window.plugins || [],
clipboard: {
key: '',
items: []
},
baseURL: document.querySelector('meta[name="base"]').getAttribute('content'),
jwt: '',
loading: false,

View File

@ -40,6 +40,14 @@ const mutations = {
},
updateRequest: (state, value) => {
state.req = value
},
updateClipboard: (state, value) => {
state.clipboard.key = value.key
state.clipboard.items = value.items
},
resetClipboard: (state) => {
state.clipboard.key = ''
state.clipboard.items = []
}
}

View File

@ -99,27 +99,45 @@ function put (url, content = '') {
})
}
function move (oldLink, newLink) {
oldLink = removePrefix(oldLink)
newLink = removePrefix(newLink)
function moveCopy (items, copy = false) {
let promises = []
return new Promise((resolve, reject) => {
let request = new window.XMLHttpRequest()
request.open('PATCH', `${store.state.baseURL}/api/resource${oldLink}`, true)
request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`)
request.setRequestHeader('Destination', newLink)
for (let item of items) {
let from = removePrefix(item.from)
let to = removePrefix(item.to)
request.onload = () => {
if (request.status === 200) {
resolve(request.responseText)
} else {
reject(request.responseText)
promises.push(new Promise((resolve, reject) => {
let request = new window.XMLHttpRequest()
request.open('PATCH', `${store.state.baseURL}/api/resource${from}`, true)
request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`)
request.setRequestHeader('Destination', to)
if (copy) {
request.setRequestHeader('Action', 'copy')
}
}
request.onerror = (error) => reject(error)
request.send()
})
request.onload = () => {
if (request.status === 200) {
resolve(request.responseText)
} else {
reject(request.responseText)
}
}
request.onerror = (error) => reject(error)
request.send()
}))
}
return Promise.all(promises)
}
function move (items) {
return moveCopy(items)
}
function copy (items) {
return moveCopy(items, true)
}
function checksum (url, algo) {
@ -425,6 +443,7 @@ export default {
checksum,
move,
put,
copy,
post,
command,
search,

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,11 @@ func (d Dir) Copy(src, dst string) error {
return os.ErrInvalid
}
info, err := d.Stat(src)
if dst == src {
return os.ErrInvalid
}
info, err := os.Stat(src)
if err != nil {
return err
}

View File

@ -82,7 +82,7 @@ func getInfo(url *url.URL, c *FileManager, u *User) (*file, error) {
var err error
i := &file{
URL: "/files" + url.Path,
URL: "/files" + url.String(),
VirtualPath: url.Path,
Path: filepath.Join(string(u.FileSystem), url.Path),
}
@ -127,6 +127,11 @@ func (i *file) getListing(c *RequestContext, r *http.Request) error {
dirCount, fileCount int
)
baseurl, err := url.PathUnescape(i.URL)
if err != nil {
return err
}
for _, f := range files {
name := f.Name()
allowed := c.User.Allowed("/" + name)
@ -143,7 +148,7 @@ func (i *file) getListing(c *RequestContext, r *http.Request) error {
}
// Absolute URL
url := url.URL{Path: i.URL + name}
url := url.URL{Path: baseurl + name}
i := &file{
Name: f.Name(),

View File

@ -13,11 +13,18 @@ import (
"github.com/hacdias/filemanager/dir"
)
func resourceHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = dir.SlashClean(r.URL.Path)
if !c.User.Allowed(r.URL.Path) {
return http.StatusForbidden, nil
// sanitizeURL sanitizes the URL to prevent path transversal
// using dir.SlashClean and adds the trailing slash bar.
func sanitizeURL(url string) string {
path := dir.SlashClean(url)
if strings.HasSuffix(url, "/") && path != "/" {
return path + "/"
}
return path
}
func resourceHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = sanitizeURL(r.URL.Path)
switch r.Method {
case http.MethodGet:

View File

@ -1 +0,0 @@
17ca832c4f21ad3498dc9929167ddc6d372b3df1