feat: download shared subdirectory (#1184)

Co-authored-by: Oleg Lobanov <oleg@lobanov.me>
pull/1241/head
WeidiDeng 2020-12-29 00:35:29 +08:00 committed by GitHub
parent 677bce376b
commit fb5b28d9cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 240 additions and 88 deletions

View File

@ -58,7 +58,7 @@ export async function put (url, content = '') {
} }
export function download (format, ...files) { export function download (format, ...files) {
let url = `${baseURL}/api/raw` let url = store.getters['isSharing'] ? `${baseURL}/api/public/dl/${store.state.hash}` : `${baseURL}/api/raw`
if (files.length === 1) { if (files.length === 1) {
url += removePrefix(files[0]) + '?' url += removePrefix(files[0]) + '?'

View File

@ -36,6 +36,8 @@ export async function fetchJSON (url, opts) {
export function removePrefix (url) { export function removePrefix (url) {
if (url.startsWith('/files')) { if (url.startsWith('/files')) {
url = url.slice(6) url = url.slice(6)
} else if (store.getters['isSharing']) {
url = url.slice(7 + store.state.hash.length)
} }
if (url === '') url = '/' if (url === '') url = '/'

View File

@ -8,8 +8,8 @@
<search v-if="isLogged"></search> <search v-if="isLogged"></search>
</div> </div>
<div> <div>
<template v-if="isLogged"> <template v-if="isLogged || isSharing">
<button @click="openSearch" :aria-label="$t('buttons.search')" :title="$t('buttons.search')" class="search-button action"> <button v-show="!isSharing" @click="openSearch" :aria-label="$t('buttons.search')" :title="$t('buttons.search')" class="search-button action">
<i class="material-icons">search</i> <i class="material-icons">search</i>
</button> </button>
@ -18,7 +18,7 @@
</button> </button>
<!-- Menu that shows on listing AND mobile when there are files selected --> <!-- Menu that shows on listing AND mobile when there are files selected -->
<div id="file-selection" v-if="isMobile && isListing"> <div id="file-selection" v-if="isMobile && isListing && !isSharing">
<span v-if="selectedCount > 0">{{ selectedCount }} selected</span> <span v-if="selectedCount > 0">{{ selectedCount }} selected</span>
<share-button v-show="showShareButton"></share-button> <share-button v-show="showShareButton"></share-button>
<rename-button v-show="showRenameButton"></rename-button> <rename-button v-show="showRenameButton"></rename-button>
@ -37,13 +37,13 @@
<delete-button v-show="showDeleteButton"></delete-button> <delete-button v-show="showDeleteButton"></delete-button>
</div> </div>
<shell-button v-if="isExecEnabled && user.perm.execute" /> <shell-button v-if="isExecEnabled && !isSharing && user.perm.execute" />
<switch-button v-show="isListing"></switch-button> <switch-button v-show="isListing"></switch-button>
<download-button v-show="showDownloadButton"></download-button> <download-button v-show="showDownloadButton"></download-button>
<upload-button v-show="showUpload"></upload-button> <upload-button v-show="showUpload"></upload-button>
<info-button v-show="isFiles"></info-button> <info-button v-show="isFiles"></info-button>
<button v-show="isListing" @click="toggleMultipleSelection" :aria-label="$t('buttons.selectMultiple')" :title="$t('buttons.selectMultiple')" class="action" > <button v-show="isListing || (isSharing && req.isDir)" @click="toggleMultipleSelection" :aria-label="$t('buttons.selectMultiple')" :title="$t('buttons.selectMultiple')" class="action" >
<i class="material-icons">check_circle</i> <i class="material-icons">check_circle</i>
<span>{{ $t('buttons.select') }}</span> <span>{{ $t('buttons.select') }}</span>
</button> </button>
@ -110,7 +110,8 @@ export default {
'isEditor', 'isEditor',
'isPreview', 'isPreview',
'isListing', 'isListing',
'isLogged' 'isLogged',
'isSharing'
]), ]),
...mapState([ ...mapState([
'req', 'req',
@ -128,7 +129,7 @@ export default {
return this.isListing && this.user.perm.create return this.isListing && this.user.perm.create
}, },
showDownloadButton () { showDownloadButton () {
return this.isFiles && this.user.perm.download return (this.isFiles && this.user.perm.download) || (this.isSharing && this.selectedCount > 0)
}, },
showDeleteButton () { showDeleteButton () {
return this.isFiles && (this.isListing return this.isFiles && (this.isListing
@ -156,7 +157,7 @@ export default {
: this.user.perm.create) : this.user.perm.create)
}, },
showMore () { showMore () {
return this.isFiles && this.$store.state.show === 'more' return (this.isFiles || this.isSharing) && this.$store.state.show === 'more'
}, },
showOverlay () { showOverlay () {
return this.showMore return this.showMore

View File

@ -14,11 +14,11 @@ export default {
name: 'download-button', name: 'download-button',
computed: { computed: {
...mapState(['req', 'selected']), ...mapState(['req', 'selected']),
...mapGetters(['isListing', 'selectedCount']) ...mapGetters(['isListing', 'selectedCount', 'isSharing'])
}, },
methods: { methods: {
download: function () { download: function () {
if (!this.isListing) { if (!this.isListing && !this.isSharing) {
api.download(null, this.$route.path) api.download(null, this.$route.path)
return return
} }

View File

@ -13,7 +13,7 @@
:aria-label="name" :aria-label="name"
:aria-selected="isSelected"> :aria-selected="isSelected">
<div> <div>
<img v-if="type==='image' && isThumbsEnabled" v-lazy="thumbnailUrl"> <img v-if="type==='image' && isThumbsEnabled && !isSharing" v-lazy="thumbnailUrl">
<i v-else class="material-icons">{{ icon }}</i> <i v-else class="material-icons">{{ icon }}</i>
</div> </div>
@ -47,8 +47,12 @@ export default {
}, },
props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'], props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'],
computed: { computed: {
...mapState(['user', 'selected', 'req', 'user', 'jwt']), ...mapState(['user', 'selected', 'req', 'jwt']),
...mapGetters(['selectedCount']), ...mapGetters(['selectedCount', 'isSharing']),
singleClick () {
if (this.isSharing) return false
return this.user.singleClick
},
isSelected () { isSelected () {
return (this.selected.indexOf(this.index) !== -1) return (this.selected.indexOf(this.index) !== -1)
}, },
@ -60,10 +64,10 @@ export default {
return 'insert_drive_file' return 'insert_drive_file'
}, },
isDraggable () { isDraggable () {
return this.user.perm.rename return !this.isSharing && this.user.perm.rename
}, },
canDrop () { canDrop () {
if (!this.isDir) return false if (!this.isDir || this.isSharing) return false
for (let i of this.selected) { for (let i of this.selected) {
if (this.req.items[i].url === this.url) { if (this.req.items[i].url === this.url) {
@ -171,11 +175,11 @@ export default {
action(overwrite, rename) action(overwrite, rename)
}, },
itemClick: function(event) { itemClick: function(event) {
if (this.user.singleClick && !this.$store.state.multiple) this.open() if (this.singleClick && !this.$store.state.multiple) this.open()
else this.click(event) else this.click(event)
}, },
click: function (event) { click: function (event) {
if (!this.user.singleClick && this.selectedCount !== 0) event.preventDefault() if (!this.singleClick && this.selectedCount !== 0) event.preventDefault()
if (this.$store.state.selected.indexOf(this.index) !== -1) { if (this.$store.state.selected.indexOf(this.index) !== -1) {
this.removeSelected(this.index) this.removeSelected(this.index)
return return
@ -202,11 +206,11 @@ export default {
return return
} }
if (!this.user.singleClick && !event.ctrlKey && !event.metaKey && !this.$store.state.multiple) this.resetSelected() if (!this.singleClick && !event.ctrlKey && !event.metaKey && !this.$store.state.multiple) this.resetSelected()
this.addSelected(this.index) this.addSelected(this.index)
}, },
dblclick: function () { dblclick: function () {
if (!this.user.singleClick) this.open() if (!this.singleClick) this.open()
}, },
touchstart () { touchstart () {
setTimeout(() => { setTimeout(() => {

View File

@ -49,7 +49,7 @@
} }
.share__box__items #listing.list .item { .share__box__items #listing.list .item {
cursor: auto; cursor: pointer;
border-left: 0; border-left: 0;
border-right: 0; border-right: 0;
border-bottom: 0; border-bottom: 0;
@ -57,5 +57,9 @@
} }
.share__box__items #listing.list .item .name { .share__box__items #listing.list .item .name {
width: auto; width: 50%;
}
.share__box__items #listing.list .item .modified {
width: 25%;
} }

View File

@ -248,6 +248,7 @@
}, },
"download": { "download": {
"downloadFile": "Download File", "downloadFile": "Download File",
"downloadFolder": "Download Folder" "downloadFolder": "Download Folder",
"downloadSelected": "Download Selected"
} }
} }

View File

@ -245,6 +245,7 @@
}, },
"download": { "download": {
"downloadFile": "下载文件", "downloadFile": "下载文件",
"downloadFolder": "下载文件夹" "downloadFolder": "下载文件夹",
"downloadSelected": "下载已选"
} }
} }

View File

@ -4,6 +4,7 @@ const getters = {
isListing: (state, getters) => getters.isFiles && state.req.isDir, isListing: (state, getters) => getters.isFiles && state.req.isDir,
isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'), isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'),
isPreview: state => state.previewMode, isPreview: state => state.previewMode,
isSharing: state => !state.loading && state.route.name === 'Share',
selectedCount: state => state.selected.length, selectedCount: state => state.selected.length,
progress : state => { progress : state => {
if (state.upload.progress.length == 0) { if (state.upload.progress.length == 0) {

View File

@ -24,7 +24,8 @@ const state = {
showShell: false, showShell: false,
showMessage: null, showMessage: null,
showConfirm: null, showConfirm: null,
previewMode: false previewMode: false,
hash: ''
} }
export default new Vuex.Store({ export default new Vuex.Store({

View File

@ -86,7 +86,8 @@ const mutations = {
}, },
setPreviewMode(state, value) { setPreviewMode(state, value) {
state.previewMode = value state.previewMode = value
} },
setHash: (state, value) => (state.hash = value),
} }
export default mutations export default mutations

View File

@ -1,107 +1,223 @@
<template> <template>
<div class="share" v-if="loaded"> <div v-if="!loading">
<div class="share__box share__box__info"> <div id="breadcrumbs">
<div class="share__box__header"> <router-link :to="'/share/' + hash" :aria-label="$t('files.home')" :title="$t('files.home')">
{{ file.isDir ? $t('download.downloadFolder') : $t('download.downloadFile') }} <i class="material-icons">home</i>
</div> </router-link>
<div class="share__box__element share__box__center share__box__icon">
<i class="material-icons">{{ file.isDir ? 'folder' : 'insert_drive_file'}}</i> <span v-for="(link, index) in breadcrumbs" :key="index">
</div> <span class="chevron"><i class="material-icons">keyboard_arrow_right</i></span>
<div class="share__box__element"> <router-link :to="link.url">{{ link.name }}</router-link>
<strong>{{ $t('prompts.displayName') }}</strong> {{ file.name }} </span>
</div>
<div class="share__box__element">
<strong>{{ $t('prompts.lastModified') }}:</strong> {{ humanTime }}
</div>
<div class="share__box__element">
<strong>{{ $t('prompts.size') }}:</strong> {{ humanSize }}
</div>
<div class="share__box__element share__box__center">
<a target="_blank" :href="link" class="button button--flat">{{ $t('buttons.download') }}</a>
</div>
<div class="share__box__element share__box__center">
<qrcode-vue :value="fullLink" size="200" level="M"></qrcode-vue>
</div>
</div> </div>
<div v-if="file.isDir" class="share__box share__box__items"> <div class="share">
<div class="share__box__header" v-if="file.isDir"> <div class="share__box share__box__info">
{{ $t('files.files') }} <div class="share__box__header">
{{ req.isDir ? $t('download.downloadFolder') : $t('download.downloadFile') }}
</div>
<div class="share__box__element share__box__center share__box__icon">
<i class="material-icons">{{ icon }}</i>
</div>
<div class="share__box__element">
<strong>{{ $t('prompts.displayName') }}</strong> {{ req.name }}
</div>
<div class="share__box__element">
<strong>{{ $t('prompts.lastModified') }}:</strong> {{ humanTime }}
</div>
<div class="share__box__element">
<strong>{{ $t('prompts.size') }}:</strong> {{ humanSize }}
</div>
<div class="share__box__element share__box__center">
<a target="_blank" :href="link" class="button button--flat">{{ $t('buttons.download') }}</a>
</div>
<div class="share__box__element share__box__center">
<qrcode-vue :value="fullLink" size="200" level="M"></qrcode-vue>
</div>
</div> </div>
<div id="listing" class="list"> <div v-if="req.isDir && req.items.length > 0" class="share__box share__box__items">
<div class="item" v-for="(item) in file.items.slice(0, this.showLimit)" :key="base64(item.name)"> <div class="share__box__header" v-if="req.isDir">
<div> {{ $t('files.files') }}
<i class="material-icons">{{ item.isDir ? 'folder' : (item.type==='image') ? 'insert_photo' : 'insert_drive_file' }}</i>
</div>
<div>
<p class="name">{{ item.name }}</p>
</div>
</div> </div>
<div v-if="file.items.length > showLimit" class="item"> <div id="listing" class="list">
<div> <item v-for="(item) in req.items.slice(0, this.showLimit)"
<p class="name"> + {{ file.items.length - showLimit }} </p> :key="base64(item.name)"
v-bind:index="item.index"
v-bind:name="item.name"
v-bind:isDir="item.isDir"
v-bind:url="item.url"
v-bind:modified="item.modified"
v-bind:type="item.type"
v-bind:size="item.size">
</item>
<div v-if="req.items.length > showLimit" class="item">
<div>
<p class="name"> + {{ req.items.length - showLimit }} </p>
</div>
</div>
<div :class="{ active: $store.state.multiple }" id="multiple-selection">
<p>{{ $t('files.multipleSelectionEnabled') }}</p>
<div @click="$store.commit('multiple', false)" tabindex="0" role="button" :title="$t('files.clear')" :aria-label="$t('files.clear')" class="action">
<i class="material-icons">clear</i>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="req.isDir && req.items.length === 0" class="share__box share__box__items">
<h2 class="message">
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t('files.lonely') }}</span>
</h2>
</div>
</div> </div>
</div> </div>
<div v-else-if="error">
<not-found v-if="error.message === '404'"></not-found>
<forbidden v-else-if="error.message === '403'"></forbidden>
<internal-error v-else></internal-error>
</div>
</template> </template>
<script> <script>
import {mapState, mapMutations, mapGetters} from 'vuex';
import { share as api } from '@/api' import { share as api } from '@/api'
import { baseURL } from '@/utils/constants' import { baseURL } from '@/utils/constants'
import filesize from 'filesize' import filesize from 'filesize'
import moment from 'moment' import moment from 'moment'
import QrcodeVue from 'qrcode.vue' import QrcodeVue from 'qrcode.vue'
import Item from "@/components/files/ListingItem"
import Forbidden from './errors/403'
import NotFound from './errors/404'
import InternalError from './errors/500'
export default { export default {
name: 'share', name: 'share',
components: { components: {
Item,
Forbidden,
NotFound,
InternalError,
QrcodeVue QrcodeVue
}, },
data: () => ({ data: () => ({
loaded: false, error: null,
notFound: false, path: '',
file: null,
showLimit: 500 showLimit: 500
}), }),
watch: { watch: {
'$route': 'fetchData' '$route': 'fetchData'
}, },
created: function () { created: async function () {
this.fetchData() const hash = this.$route.params.pathMatch.split('/')[0]
this.setHash(hash)
await this.fetchData()
},
mounted () {
window.addEventListener('keydown', this.keyEvent)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keyEvent)
}, },
computed: { computed: {
hash: function () { ...mapState(['hash', 'req', 'loading', 'multiple']),
return this.$route.params.pathMatch ...mapGetters(['selectedCount']),
icon: function () {
if (this.req.isDir) return 'folder'
if (this.req.type === 'image') return 'insert_photo'
if (this.req.type === 'audio') return 'volume_up'
if (this.req.type === 'video') return 'movie'
return 'insert_drive_file'
}, },
link: function () { link: function () {
return `${baseURL}/api/public/dl/${this.hash}/${encodeURI(this.file.name)}` return `${baseURL}/api/public/dl/${this.hash}${this.path}`
}, },
fullLink: function () { fullLink: function () {
return window.location.origin + this.link return window.location.origin + this.link
}, },
humanSize: function () { humanSize: function () {
if (this.file.isDir) { if (this.req.isDir) {
return this.file.items.length return this.req.items.length
} }
return filesize(this.file.size) return filesize(this.req.size)
}, },
humanTime: function () { humanTime: function () {
return moment(this.file.modified).fromNow() return moment(this.req.modified).fromNow()
},
breadcrumbs () {
let parts = this.path.split('/')
if (parts[0] === '') {
parts.shift()
}
if (parts[parts.length - 1] === '') {
parts.pop()
}
let breadcrumbs = []
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
breadcrumbs.push({ name: decodeURIComponent(parts[i]), url: '/share/' + this.hash + '/' + parts[i] + '/' })
} else {
breadcrumbs.push({ name: decodeURIComponent(parts[i]), url: breadcrumbs[i - 1].url + parts[i] + '/' })
}
}
if (breadcrumbs.length > 3) {
while (breadcrumbs.length !== 4) {
breadcrumbs.shift()
}
breadcrumbs[0].name = '...'
}
return breadcrumbs
} }
}, },
methods: { methods: {
...mapMutations([ 'setHash', 'resetSelected', 'updateRequest', 'setLoading' ]),
base64: function (name) { base64: function (name) {
return window.btoa(unescape(encodeURIComponent(name))) return window.btoa(unescape(encodeURIComponent(name)))
}, },
fetchData: async function () { fetchData: async function () {
// Reset view information.
this.$store.commit('setReload', false)
this.$store.commit('resetSelected')
this.$store.commit('multiple', false)
this.$store.commit('closeHovers')
// Set loading to true and reset the error.
this.setLoading(true)
this.error = null
try { try {
this.file = await api.getHash(this.hash) let file = await api.getHash(encodeURIComponent(this.$route.params.pathMatch))
this.loaded = true this.path = file.path
if (file.isDir) file.items = file.items.map((item, index) => {
item.index = index
item.url = `/share/${this.hash}${this.path}/${encodeURIComponent(item.name)}`
return item
})
this.updateRequest(file)
this.setLoading(false)
} catch (e) { } catch (e) {
this.notFound = true this.error = e
} }
},
keyEvent (event) {
// Esc!
if (event.keyCode === 27) {
// If we're on a listing, unselect all
// files and folders.
if (this.selectedCount > 0) {
this.resetSelected()
}
}
},
toggleMultipleSelection () {
this.$store.commit('multiple', !this.multiple)
} }
} }
} }

View File

@ -2,19 +2,21 @@ package http
import ( import (
"net/http" "net/http"
"path"
"path/filepath"
"strings" "strings"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
) )
var withHashFile = func(fn handleFunc) handleFunc { var withHashFile = func(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
link, err := d.store.Share.GetByHash(r.URL.Path) id, path := ifPathWithName(r)
link, err := d.store.Share.GetByHash(id)
if err != nil { if err != nil {
link, err = d.store.Share.GetByHash(ifPathWithName(r)) return errToStatus(err), err
if err != nil {
return errToStatus(err), err
}
} }
user, err := d.store.Users.Get(d.server.Root, link.UserID) user, err := d.store.Users.Get(d.server.Root, link.UserID)
@ -35,6 +37,22 @@ var withHashFile = func(fn handleFunc) handleFunc {
return errToStatus(err), err return errToStatus(err), err
} }
if file.IsDir {
// set fs root to the shared folder
d.user.Fs = afero.NewBasePathFs(d.user.Fs, filepath.Dir(link.Path))
file, err = files.NewFileInfo(files.FileOptions{
Fs: d.user.Fs,
Path: path,
Modify: d.user.Perm.Modify,
Expand: true,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
}
d.raw = file d.raw = file
return fn(w, r, d) return fn(w, r, d)
} }
@ -42,15 +60,17 @@ var withHashFile = func(fn handleFunc) handleFunc {
// ref to https://github.com/filebrowser/filebrowser/pull/727 // ref to https://github.com/filebrowser/filebrowser/pull/727
// `/api/public/dl/MEEuZK-v/file-name.txt` for old browsers to save file with correct name // `/api/public/dl/MEEuZK-v/file-name.txt` for old browsers to save file with correct name
func ifPathWithName(r *http.Request) string { func ifPathWithName(r *http.Request) (id, filePath string) {
pathElements := strings.Split(r.URL.Path, "/") pathElements := strings.Split(r.URL.Path, "/")
// prevent maliciously constructed parameters like `/api/public/dl/XZzCDnK2_not_exists_hash_name` // prevent maliciously constructed parameters like `/api/public/dl/XZzCDnK2_not_exists_hash_name`
// len(pathElements) will be 1, and golang will panic `runtime error: index out of range` // len(pathElements) will be 1, and golang will panic `runtime error: index out of range`
if len(pathElements) < 2 { //nolint: mnd
return r.URL.Path switch len(pathElements) {
case 1:
return r.URL.Path, "/"
default:
return pathElements[0], path.Join("/", path.Join(pathElements[1:]...))
} }
id := pathElements[len(pathElements)-2]
return id
} }
var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {