Merge branch 'master' into disable-detection-option

This commit is contained in:
Julien Loir 2020-12-29 10:13:40 -08:00 committed by GitHub
commit 5509ea9424
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 522 additions and 121 deletions

View File

@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [2.11.0](https://github.com/filebrowser/filebrowser/compare/v2.10.0...v2.11.0) (2020-12-28)
### Features
* add sharing management ([#1178](https://github.com/filebrowser/filebrowser/issues/1178)) (closes [#1000](https://github.com/filebrowser/filebrowser/issues/1000)) ([677bce3](https://github.com/filebrowser/filebrowser/commit/677bce376b024d9ff38f34e74243034fe5a1ec3c))
* download shared subdirectory ([#1184](https://github.com/filebrowser/filebrowser/issues/1184)) ([fb5b28d](https://github.com/filebrowser/filebrowser/commit/fb5b28d9cbdee10d38fcd719b9fd832121be58ef))
### Bug Fixes
* check user input to prevent permission elevation ([#1196](https://github.com/filebrowser/filebrowser/issues/1196)) (closes [#1195](https://github.com/filebrowser/filebrowser/issues/1195)) ([f62806f](https://github.com/filebrowser/filebrowser/commit/f62806f6c9e9c7f392d1b747d65b8fe40b313e89))
* delete extra remove prefix ([#1186](https://github.com/filebrowser/filebrowser/issues/1186)) ([7a5298a](https://github.com/filebrowser/filebrowser/commit/7a5298a7556f7dcc52f59b8ea76d040d3ddc3d12))
* move files between different volumes (closes [#1177](https://github.com/filebrowser/filebrowser/issues/1177)) ([58835b7](https://github.com/filebrowser/filebrowser/commit/58835b7e535cc96e1c8a5d85821c1545743ca757))
* recaptcha race condition ([#1176](https://github.com/filebrowser/filebrowser/issues/1176)) ([ac3673e](https://github.com/filebrowser/filebrowser/commit/ac3673e111afac6616af9650ca07028b6c27e6cd))
## [2.10.0](https://github.com/filebrowser/filebrowser/compare/v2.9.0...v2.10.0) (2020-11-24) ## [2.10.0](https://github.com/filebrowser/filebrowser/compare/v2.9.0...v2.10.0) (2020-11-24)

View File

@ -9,6 +9,25 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// MoveFile moves file from src to dst.
// By default the rename filesystem system call is used. If src and dst point to different volumes
// the file copy is used as a fallback
func MoveFile(fs afero.Fs, src, dst string) error {
if fs.Rename(src, dst) == nil {
return nil
}
// fallback
err := CopyFile(fs, src, dst)
if err != nil {
_ = fs.Remove(dst)
return err
}
if err := fs.Remove(src); err != nil {
return err
}
return nil
}
// CopyFile copies a file from source to dest and returns // CopyFile copies a file from source to dest and returns
// an error if any. // an error if any.
func CopyFile(fs afero.Fs, source, dest string) error { func CopyFile(fs afero.Fs, source, dest string) error {
@ -39,14 +58,14 @@ func CopyFile(fs afero.Fs, source, dest string) error {
return err return err
} }
// Copy the mode if the user can't // Copy the mode
// open the file.
info, err := fs.Stat(source) info, err := fs.Stat(source)
if err != nil { if err != nil {
err = fs.Chmod(dest, info.Mode()) return err
if err != nil { }
return err err = fs.Chmod(dest, info.Mode())
} if err != nil {
return err
} }
return nil return nil

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]) + '?'
@ -121,7 +121,7 @@ function moveCopy (items, copy = false, overwrite = false, rename = false) {
let promises = [] let promises = []
for (let item of items) { for (let item of items) {
const from = removePrefix(item.from) const from = item.from
const to = encodeURIComponent(removePrefix(item.to)) const to = encodeURIComponent(removePrefix(item.to))
const url = `${from}?action=${copy ? 'copy' : 'rename'}&destination=${to}&override=${overwrite}&rename=${rename}` const url = `${from}?action=${copy ? 'copy' : 'rename'}&destination=${to}&override=${overwrite}&rename=${rename}`
promises.push(resourceAction(url, 'PATCH')) promises.push(resourceAction(url, 'PATCH'))

View File

@ -1,5 +1,9 @@
import { fetchURL, fetchJSON, removePrefix } from './utils' import { fetchURL, fetchJSON, removePrefix } from './utils'
export async function list() {
return fetchJSON('/api/shares')
}
export async function getHash(hash) { export async function getHash(hash) {
return fetchJSON(`/api/public/share/${hash}`) return fetchJSON(`/api/public/share/${hash}`)
} }

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

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrator", "administrator": "Administrator",
"allowCommands": "تنفيذ الأوامر", "allowCommands": "تنفيذ الأوامر",

View File

@ -132,7 +132,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrator", "administrator": "Administrator",
"allowCommands": "Execute commands", "allowCommands": "Execute commands",
@ -157,6 +157,9 @@
"permissions": "Permissions", "permissions": "Permissions",
"permissionsHelp": "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.\n", "permissionsHelp": "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.\n",
"profileSettings": "Profile Settings", "profileSettings": "Profile Settings",
"shareManagement": "Share Management",
"path": "Path",
"shareDuration": "Share Duration",
"ruleExample1": "prevents the access to any dot file (such as .git, .gitignore) in every folder.\n", "ruleExample1": "prevents the access to any dot file (such as .git, .gitignore) in every folder.\n",
"ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.", "ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.",
"rules": "Rules", "rules": "Rules",
@ -245,6 +248,7 @@
}, },
"download": { "download": {
"downloadFile": "Download File", "downloadFile": "Download File",
"downloadFolder": "Download Folder" "downloadFolder": "Download Folder",
"downloadSelected": "Download Selected"
} }
} }

View File

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrateur", "administrator": "Administrateur",
"allowCommands": "Exécuter des commandes", "allowCommands": "Exécuter des commandes",

View File

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Amministratore", "administrator": "Amministratore",
"allowCommands": "Esegui comandi", "allowCommands": "Esegui comandi",

View File

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "管理者", "admin": "管理者",
"administrator": "管理者", "administrator": "管理者",
"allowCommands": "コマンドの実行", "allowCommands": "コマンドの実行",

View File

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrator", "administrator": "Administrator",
"allowCommands": "Wykonaj polecenie", "allowCommands": "Wykonaj polecenie",

View File

@ -124,7 +124,7 @@
"documentation": "documentação", "documentation": "documentação",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrador", "administrator": "Administrador",
"allowCommands": "Executar comandos", "allowCommands": "Executar comandos",

View File

@ -124,7 +124,7 @@
"documentation": "documentation", "documentation": "documentation",
"branding": "Branding", "branding": "Branding",
"disableExternalLinks": "Disable external links (except documentation)", "disableExternalLinks": "Disable external links (except documentation)",
"brandingHelp": "You can costumize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.", "brandingHelp": "You can customize how your File Browser instance looks and feels by changing its name, replacing the logo, adding custom styles and even disable external links to GitHub.\nFor more information about custom branding, please check out the {0}.",
"admin": "Админ", "admin": "Админ",
"administrator": "Администратор", "administrator": "Администратор",
"allowCommands": "Запуск команд", "allowCommands": "Запуск команд",

View File

@ -156,6 +156,9 @@
"permissions": "权限", "permissions": "权限",
"permissionsHelp": "您可以将该用户设置为管理员,也可以单独选择各项权限。如果选择了“管理员”,则其他的选项会被自动勾上,同时该用户可以管理其他用户。", "permissionsHelp": "您可以将该用户设置为管理员,也可以单独选择各项权限。如果选择了“管理员”,则其他的选项会被自动勾上,同时该用户可以管理其他用户。",
"profileSettings": "个人设置", "profileSettings": "个人设置",
"shareManagement": "分享管理",
"path": "路径",
"shareDuration": "分享期限",
"ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore。", "ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore。",
"ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。", "ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
"rules": "规则", "rules": "规则",
@ -242,6 +245,7 @@
}, },
"download": { "download": {
"downloadFile": "下载文件", "downloadFile": "下载文件",
"downloadFolder": "下载文件夹" "downloadFolder": "下载文件夹",
"downloadSelected": "下载已选"
} }
} }

View File

@ -9,6 +9,7 @@ import User from '@/views/settings/User'
import Settings from '@/views/Settings' import Settings from '@/views/Settings'
import GlobalSettings from '@/views/settings/Global' import GlobalSettings from '@/views/settings/Global'
import ProfileSettings from '@/views/settings/Profile' import ProfileSettings from '@/views/settings/Profile'
import Shares from '@/views/settings/Shares'
import Error403 from '@/views/errors/403' import Error403 from '@/views/errors/403'
import Error404 from '@/views/errors/404' import Error404 from '@/views/errors/404'
import Error500 from '@/views/errors/500' import Error500 from '@/views/errors/500'
@ -67,6 +68,11 @@ const router = new Router({
name: 'Profile Settings', name: 'Profile Settings',
component: ProfileSettings component: ProfileSettings
}, },
{
path: '/settings/shares',
name: 'Shares',
component: Shares
},
{ {
path: '/settings/global', path: '/settings/global',
name: 'Global Settings', name: 'Global Settings',

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,9 +1,10 @@
<template> <template>
<div class="dashboard"> <div class="dashboard">
<ul id="nav" v-if="user.perm.admin"> <ul id="nav">
<li :class="{ active: $route.path === '/settings/profile' }"><router-link to="/settings/profile">{{ $t('settings.profileSettings') }}</router-link></li> <li :class="{ active: $route.path === '/settings/profile' }"><router-link to="/settings/profile">{{ $t('settings.profileSettings') }}</router-link></li>
<li :class="{ active: $route.path === '/settings/global' }"><router-link to="/settings/global">{{ $t('settings.globalSettings') }}</router-link></li> <li :class="{ active: $route.path === '/settings/shares' }"><router-link to="/settings/shares">{{ $t('settings.shareManagement') }}</router-link></li>
<li :class="{ active: $route.path === '/settings/users' }"><router-link to="/settings/users">{{ $t('settings.userManagement') }}</router-link></li> <li v-if="user.perm.admin" :class="{ active: $route.path === '/settings/global' }"><router-link to="/settings/global">{{ $t('settings.globalSettings') }}</router-link></li>
<li v-if="user.perm.admin" :class="{ active: $route.path === '/settings/users' }"><router-link to="/settings/users">{{ $t('settings.userManagement') }}</router-link></li>
</ul> </ul>
<router-view></router-view> <router-view></router-view>

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

@ -0,0 +1,98 @@
<template>
<div class="card">
<div class="card-title">
<h2>{{ $t('settings.shareManagement') }}</h2>
</div>
<div class="card-content full">
<table>
<tr>
<th>{{ $t('settings.path') }}</th>
<th>{{ $t('settings.shareDuration') }}</th>
<th v-if="user.perm.admin">{{ $t('settings.username') }}</th>
<th></th>
<th></th>
</tr>
<tr v-for="link in links" :key="link.hash">
<td><a :href="buildLink(link.hash)" target="_blank">{{ link.path }}</a></td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t('permanent') }}</template>
</td>
<td v-if="user.perm.admin">{{ link.username }}</td>
<td class="small">
<button class="action"
@click="deleteLink($event, link)"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"><i class="material-icons">delete</i></button>
</td>
<td class="small">
<button class="action copy-clipboard"
:data-clipboard-text="buildLink(link.hash)"
:aria-label="$t('buttons.copyToClipboard')"
:title="$t('buttons.copyToClipboard')"><i class="material-icons">content_paste</i></button>
</td>
</tr>
</table>
</div>
</div>
</template>
<script>
import { share as api, users } from '@/api'
import moment from 'moment'
import {baseURL} from "@/utils/constants"
import Clipboard from 'clipboard'
import {mapState} from "vuex";
export default {
name: 'shares',
computed: mapState([ 'user' ]),
data: function () {
return {
links: [],
clip: null
}
},
async created () {
try {
let links = await api.list()
if (this.user.perm.admin) {
let userMap = new Map()
for (let user of await users.getAll()) userMap.set(user.id, user.username)
for (let link of links) link.username = userMap.has(link.userID) ? userMap.get(link.userID) : ''
}
this.links = links
} catch (e) {
this.$showError(e)
}
},
mounted() {
this.clip = new Clipboard('.copy-clipboard')
this.clip.on('success', () => {
this.$showSuccess(this.$t('success.linkCopied'))
})
},
beforeDestroy () {
this.clip.destroy()
},
methods: {
deleteLink: async function (event, link) {
event.preventDefault()
try {
await api.remove(link.hash)
this.links = this.links.filter(item => item.hash !== link.hash)
} catch (e) {
this.$showError(e)
}
},
humanTime (time) {
return moment(time * 1000).fromNow()
},
buildLink (hash) {
return `${window.location.origin}${baseURL}/share/${hash}`
}
}
}
</script>

View File

@ -51,6 +51,7 @@ func NewHandler(imgSvc ImgService, fileCache FileCache, store *storage.Storage,
api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("PUT") api.PathPrefix("/resources").Handler(monkey(resourcePostPutHandler, "/api/resources")).Methods("PUT")
api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler, "/api/resources")).Methods("PATCH") api.PathPrefix("/resources").Handler(monkey(resourcePatchHandler, "/api/resources")).Methods("PATCH")
api.Path("/shares").Handler(monkey(shareListHandler, "/api/shares")).Methods("GET")
api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET") api.PathPrefix("/share").Handler(monkey(shareGetsHandler, "/api/share")).Methods("GET")
api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST") api.PathPrefix("/share").Handler(monkey(sharePostHandler, "/api/share")).Methods("POST")
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE") api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")

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) {

View File

@ -200,7 +200,7 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
src = path.Clean("/" + src) src = path.Clean("/" + src)
dst = path.Clean("/" + dst) dst = path.Clean("/" + dst)
return d.user.Fs.Rename(src, dst) return fileutils.MoveFile(d.user.Fs, src, dst)
default: default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams) return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
} }

View File

@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"path" "path"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -23,6 +24,34 @@ func withPermShare(fn handleFunc) handleFunc {
}) })
} }
var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
var (
s []*share.Link
err error
)
if d.user.Perm.Admin {
s, err = d.store.Share.All()
} else {
s, err = d.store.Share.FindByUserID(d.user.ID)
}
if err == errors.ErrNotExist {
return renderJSON(w, r, []*share.Link{})
}
if err != nil {
return http.StatusInternalServerError, err
}
sort.Slice(s, func(i, j int) bool {
if s[i].UserID != s[j].UserID {
return s[i].UserID < s[j].UserID
}
return s[i].Expire < s[j].Expire
})
return renderJSON(w, r, s)
})
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID) s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
if err == errors.ErrNotExist { if err == errors.ErrNotExist {

View File

@ -14,6 +14,10 @@ import (
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
var (
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
)
type modifyUserRequest struct { type modifyUserRequest struct {
modifyRequest modifyRequest
Data *users.User `json:"data"` Data *users.User `json:"data"`
@ -148,9 +152,9 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
return http.StatusBadRequest, nil return http.StatusBadRequest, nil
} }
if len(req.Which) == 1 && req.Which[0] == "all" { if len(req.Which) == 0 || (len(req.Which) == 1 && req.Which[0] == "all") {
if !d.user.Perm.Admin { if !d.user.Perm.Admin {
return http.StatusForbidden, err return http.StatusForbidden, nil
} }
if req.Data.Password != "" { if req.Data.Password != "" {
@ -169,7 +173,10 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
} }
for k, v := range req.Which { for k, v := range req.Which {
if v == "password" { v = strings.Title(v)
req.Which[k] = v
if v == "Password" {
if !d.user.Perm.Admin && d.user.LockPassword { if !d.user.Perm.Admin && d.user.LockPassword {
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
@ -180,11 +187,11 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
} }
} }
if !d.user.Perm.Admin && (v == "scope" || v == "perm" || v == "username") { for _, f := range NonModifiableFieldsForNonAdmin {
return http.StatusForbidden, nil if !d.user.Perm.Admin && v == f {
return http.StatusForbidden, nil
}
} }
req.Which[k] = strings.Title(v)
} }
err = d.store.Users.Update(req.Data, req.Which...) err = d.store.Users.Update(req.Data, req.Which...)

View File

@ -8,6 +8,8 @@ import (
// StorageBackend is the interface to implement for a share storage. // StorageBackend is the interface to implement for a share storage.
type StorageBackend interface { type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error) GetByHash(hash string) (*Link, error)
GetPermanent(path string, id uint) (*Link, error) GetPermanent(path string, id uint) (*Link, error)
Gets(path string, id uint) ([]*Link, error) Gets(path string, id uint) ([]*Link, error)
@ -25,6 +27,46 @@ func NewStorage(back StorageBackend) *Storage {
return &Storage{back: back} return &Storage{back: back}
} }
// All wraps a StorageBackend.All.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
}
}
return links, nil
}
// FindByUserID wraps a StorageBackend.FindByUserID.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
for i, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
if err := s.Delete(link.Hash); err != nil {
return nil, err
}
links = append(links[:i], links[i+1:]...)
}
}
return links, nil
}
// GetByHash wraps a StorageBackend.GetByHash. // GetByHash wraps a StorageBackend.GetByHash.
func (s *Storage) GetByHash(hash string) (*Link, error) { func (s *Storage) GetByHash(hash string) (*Link, error) {
link, err := s.back.GetByHash(hash) link, err := s.back.GetByHash(hash)

View File

@ -12,6 +12,26 @@ type shareBackend struct {
db *storm.DB db *storm.DB
} }
func (s shareBackend) All() ([]*share.Link, error) {
var v []*share.Link
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) FindByUserID(id uint) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("UserID", id)).Find(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) GetByHash(hash string) (*share.Link, error) { func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
var v share.Link var v share.Link
err := s.db.One("Hash", hash, &v) err := s.db.One("Hash", hash, &v)