Compare commits

...

22 Commits

Author SHA1 Message Date
Oleg Lobanov
948e05c083 chore(release): 2.11.0 2020-12-28 17:37:19 +01:00
WeidiDeng
fb5b28d9cb feat: download shared subdirectory (#1184)
Co-authored-by: Oleg Lobanov <oleg@lobanov.me>
2020-12-28 17:35:29 +01:00
WeidiDeng
677bce376b feat: add sharing management (#1178) (closes #1000) 2020-12-24 19:02:28 +01:00
Alexis Lefebvre
8faa96f5e6 chore: fix typo costumize -> costumize (#1194) 2020-12-24 18:24:15 +01:00
WeidiDeng
f62806f6c9 fix: check user input to prevent permission elevation (#1196) (closes #1195) 2020-12-24 18:22:48 +01:00
Oleg Lobanov
58835b7e53 fix: move files between different volumes (closes #1177) 2020-12-24 17:50:27 +01:00
WeidiDeng
7a5298a755 fix: delete extra remove prefix (#1186)
Fix file actions within /files dir
2020-12-16 16:08:56 +01:00
WeidiDeng
bc4a6462ce chore: use command key to select multiple files on mac (#1183) 2020-12-12 14:09:50 +01:00
WeidiDeng
ac3673e111 fix: recaptcha race condition (#1176) 2020-12-08 11:26:29 +01:00
Oleg Lobanov
c746c1931d chore(release): 2.10.0 2020-11-24 12:00:10 +01:00
Oleg Lobanov
586d198d47 fix: fix hanging when reading a named pipe file (closes #1155) 2020-11-24 11:37:31 +01:00
Matt Doyle
9515ceeb42 feat: automatically jump to the next photo when deleting while previewing (#1143) 2020-11-23 19:08:14 +01:00
Julien Loir
e8b4e9af46 feat: add single click mode (#1139) 2020-11-23 19:06:37 +01:00
Tiger Nie
10e399b3c3 feat: add hide dotfiles param (#1148) 2020-11-20 11:51:28 +01:00
Oleg Lobanov
dcbc3286e2 Merge pull request #1133 from ramiresviana/fixes-5
Some fixes and shared view improvements
2020-11-04 17:58:18 +01:00
xufanglu
b185f9b56e chore: fix typo in config_init.go (#1131) 2020-11-04 17:47:36 +01:00
Ramires Viana
7096b3dab9 fix: empty folder in archive 2020-11-04 15:56:27 +00:00
Ramires Viana
36cacdf598 feat: shared item information 2020-11-04 15:56:27 +00:00
Ramires Viana
4e48ffc14d fix: previewer title overflow 2020-11-04 15:56:27 +00:00
Ramires Viana
e119bc55ea feat: shared folder file listing 2020-11-04 15:56:05 +00:00
Ramires Viana
1ce3068a99 fix: resource rename action invalid path 2020-11-03 12:30:56 +00:00
Liubomyr Piadyk
d562d1a60d chore: fix readme typo (#1128)
Commander runner -> Command runner
At other places it's referenced as Command runner.
2020-10-29 17:29:29 +01:00
54 changed files with 805 additions and 187 deletions

View File

@@ -2,6 +2,41 @@
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)
### Features
* add hide dotfiles param ([#1148](https://github.com/filebrowser/filebrowser/issues/1148)) ([10e399b](https://github.com/filebrowser/filebrowser/commit/10e399b3c3dbdcfb4465a9d4138e1da6bae0873d))
* add single click mode ([#1139](https://github.com/filebrowser/filebrowser/issues/1139)) ([e8b4e9a](https://github.com/filebrowser/filebrowser/commit/e8b4e9af46d6e99dbeb965dd9727d9ed017d52a2))
* automatically jump to the next photo when deleting while previewing ([#1143](https://github.com/filebrowser/filebrowser/issues/1143)) ([9515cee](https://github.com/filebrowser/filebrowser/commit/9515ceeb42e5ef5267400220a2082dec775e843d))
* shared folder file listing ([e119bc5](https://github.com/filebrowser/filebrowser/commit/e119bc55ea82cefcbcc0571650107dfd5d73f570))
* shared item information ([36cacdf](https://github.com/filebrowser/filebrowser/commit/36cacdf598e4e09f064c8ace0ca7a6c24b23028e))
### Bug Fixes
* empty folder in archive ([7096b3d](https://github.com/filebrowser/filebrowser/commit/7096b3dab92441981c9964e4a6175af0a255d2be))
* fix hanging when reading a named pipe file (closes [#1155](https://github.com/filebrowser/filebrowser/issues/1155)) ([586d198](https://github.com/filebrowser/filebrowser/commit/586d198d47b525eeccc6fe587573a3ad83adb4f6))
* previewer title overflow ([4e48ffc](https://github.com/filebrowser/filebrowser/commit/4e48ffc14d09dabeea12dc495144277db62b5b7d))
* resource rename action invalid path ([1ce3068](https://github.com/filebrowser/filebrowser/commit/1ce3068a99c80c153fd41359255d173bce6e79e8))
## [2.9.0](https://github.com/filebrowser/filebrowser/compare/v2.8.0...v2.9.0) (2020-10-21)

View File

@@ -24,7 +24,7 @@ For installation instructions please refer to our docs at [https://filebrowser.o
[Authentication Method](https://filebrowser.org/configuration/authentication-method) - You can change the way the user authenticates with the filebrowser server
[Commander Runner](https://filebrowser.org/configuration/command-runner) - The command runner is a feature that enables you to execute any shell command you want before or after a certain event.
[Command Runner](https://filebrowser.org/configuration/command-runner) - The command runner is a feature that enables you to execute any shell command you want before or after a certain event.
[Custom Branding](https://filebrowser.org/configuration/custom-branding) - You can customize your File Browser installation by change its name to any other you want, by adding a global custom style sheet and by using your own logotype if you want.

View File

@@ -145,6 +145,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tScope:\t%s\n", set.Defaults.Scope)
fmt.Fprintf(w, "\tLocale:\t%s\n", set.Defaults.Locale)
fmt.Fprintf(w, "\tView mode:\t%s\n", set.Defaults.ViewMode)
fmt.Fprintf(w, "\tSingle Click:\t%t\n", set.Defaults.SingleClick)
fmt.Fprintf(w, "\tCommands:\t%s\n", strings.Join(set.Defaults.Commands, " "))
fmt.Fprintf(w, "\tSorting:\n")
fmt.Fprintf(w, "\t\tBy:\t%s\n", set.Defaults.Sorting.By)

View File

@@ -61,7 +61,7 @@ override the options.`,
fmt.Printf(`
Congratulations! You've set up your database to use with File Browser.
Now add your first user via 'filebrowser users new' and then you just
Now add your first user via 'filebrowser users add' and then you just
need to call the main command to boot up the server.
`)
printSettings(ser, s, auther)

View File

@@ -302,8 +302,9 @@ func quickSetup(flags *pflag.FlagSet, d pythonData) {
Signup: false,
CreateUserDir: false,
Defaults: settings.UserDefaults{
Scope: ".",
Locale: "en",
Scope: ".",
Locale: "en",
SingleClick: false,
Perm: users.Permissions{
Admin: false,
Execute: true,

View File

@@ -27,15 +27,16 @@ var usersCmd = &cobra.Command{
func printUsers(usrs []*users.User) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
fmt.Fprintln(w, "ID\tUsername\tScope\tLocale\tV. Mode\tS.Click\tAdmin\tExecute\tCreate\tRename\tModify\tDelete\tShare\tDownload\tPwd Lock")
for _, u := range usrs {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t%t\t\n",
u.ID,
u.Username,
u.Scope,
u.Locale,
u.ViewMode,
u.SingleClick,
u.Perm.Admin,
u.Perm.Execute,
u.Perm.Create,
@@ -75,6 +76,7 @@ func addUserFlags(flags *pflag.FlagSet) {
flags.String("scope", ".", "scope for users")
flags.String("locale", "en", "locale for users")
flags.String("viewMode", string(users.ListViewMode), "view mode for users")
flags.Bool("singleClick", false, "use single clicks only")
}
func getViewMode(flags *pflag.FlagSet) users.ViewMode {
@@ -95,6 +97,8 @@ func getUserDefaults(flags *pflag.FlagSet, defaults *settings.UserDefaults, all
defaults.Locale = mustGetString(flags, flag.Name)
case "viewMode":
defaults.ViewMode = getViewMode(flags)
case "singleClick":
defaults.SingleClick = mustGetBool(flags, flag.Name)
case "perm.admin":
defaults.Perm.Admin = mustGetBool(flags, flag.Name)
case "perm.execute":

View File

@@ -41,17 +41,19 @@ options you want to change.`,
checkErr(err)
defaults := settings.UserDefaults{
Scope: user.Scope,
Locale: user.Locale,
ViewMode: user.ViewMode,
Perm: user.Perm,
Sorting: user.Sorting,
Commands: user.Commands,
Scope: user.Scope,
Locale: user.Locale,
ViewMode: user.ViewMode,
SingleClick: user.SingleClick,
Perm: user.Perm,
Sorting: user.Sorting,
Commands: user.Commands,
}
getUserDefaults(flags, &defaults, false)
user.Scope = defaults.Scope
user.Locale = defaults.Locale
user.ViewMode = defaults.ViewMode
user.SingleClick = defaults.SingleClick
user.Perm = defaults.Perm
user.Commands = defaults.Commands
user.Sorting = defaults.Sorting

View File

@@ -135,6 +135,10 @@ func (i *FileInfo) Checksum(algo string) error {
//nolint:goconst
//TODO: use constants
func (i *FileInfo) detectType(modify, saveContent bool) error {
if IsNamedPipe(i.Mode) {
i.Type = "blob"
return nil
}
// failing to detect the type should not return error.
// imagine the situation where a file in a dir with thousands
// of files couldn't be opened: we'd have immediately
@@ -232,9 +236,9 @@ func (i *FileInfo) readListing(checker rules.Checker) error {
continue
}
if strings.HasPrefix(f.Mode().String(), "L") {
if IsSymlink(f.Mode()) {
// It's a symbolic link. We try to follow it. If it doesn't work,
// we stay with the link information instead if the target's.
// we stay with the link information instead of the target's.
info, err := i.Fs.Stat(fPath)
if err == nil {
f = info

View File

@@ -1,6 +1,7 @@
package files
import (
"os"
"unicode/utf8"
)
@@ -48,3 +49,11 @@ func isBinary(content []byte, _ int) bool {
}
return false
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}

View File

@@ -9,6 +9,25 @@ import (
"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
// an error if any.
func CopyFile(fs afero.Fs, source, dest string) error {
@@ -39,14 +58,14 @@ func CopyFile(fs afero.Fs, source, dest string) error {
return err
}
// Copy the mode if the user can't
// open the file.
// Copy the mode
info, err := fs.Stat(source)
if err != nil {
err = fs.Chmod(dest, info.Mode())
if err != nil {
return err
}
return err
}
err = fs.Chmod(dest, info.Mode())
if err != nil {
return err
}
return nil

View File

@@ -191,10 +191,11 @@ table th {
}
}
.share__box, .share__box__download {
background: var(--surfaceSecondary) !important;
.share__box {
background: var(--surfacePrimary) !important;
color: var(--textPrimary);
}
.share__box__download {
border-bottom-color: var(--divider);
.share__box__element {
border-top-color: var(--divider);
}

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,8 @@
<search v-if="isLogged"></search>
</div>
<div>
<template v-if="isLogged">
<button @click="openSearch" :aria-label="$t('buttons.search')" :title="$t('buttons.search')" class="search-button action">
<template v-if="isLogged || isSharing">
<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>
</button>
@@ -18,7 +18,7 @@
</button>
<!-- 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>
<share-button v-show="showShareButton"></share-button>
<rename-button v-show="showRenameButton"></rename-button>
@@ -37,13 +37,13 @@
<delete-button v-show="showDeleteButton"></delete-button>
</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>
<download-button v-show="showDownloadButton"></download-button>
<upload-button v-show="showUpload"></upload-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>
<span>{{ $t('buttons.select') }}</span>
</button>
@@ -110,7 +110,8 @@ export default {
'isEditor',
'isPreview',
'isListing',
'isLogged'
'isLogged',
'isSharing'
]),
...mapState([
'req',
@@ -128,7 +129,7 @@ export default {
return this.isListing && this.user.perm.create
},
showDownloadButton () {
return this.isFiles && this.user.perm.download
return (this.isFiles && this.user.perm.download) || (this.isSharing && this.selectedCount > 0)
},
showDeleteButton () {
return this.isFiles && (this.isListing
@@ -156,7 +157,7 @@ export default {
: this.user.perm.create)
},
showMore () {
return this.isFiles && this.$store.state.show === 'more'
return (this.isFiles || this.isSharing) && this.$store.state.show === 'more'
},
showOverlay () {
return this.showMore

View File

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

View File

@@ -6,14 +6,14 @@
@dragstart="dragStart"
@dragover="dragOver"
@drop="drop"
@click="click"
@dblclick="open"
@click="itemClick"
@dblclick="dblclick"
@touchstart="touchstart"
:data-dir="isDir"
:aria-label="name"
:aria-selected="isSelected">
<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>
</div>
@@ -47,8 +47,12 @@ export default {
},
props: ['name', 'isDir', 'url', 'type', 'size', 'modified', 'index'],
computed: {
...mapState(['selected', 'req', 'user', 'jwt']),
...mapGetters(['selectedCount']),
...mapState(['user', 'selected', 'req', 'jwt']),
...mapGetters(['selectedCount', 'isSharing']),
singleClick () {
if (this.isSharing) return false
return this.user.singleClick
},
isSelected () {
return (this.selected.indexOf(this.index) !== -1)
},
@@ -60,10 +64,10 @@ export default {
return 'insert_drive_file'
},
isDraggable () {
return this.user.perm.rename
return !this.isSharing && this.user.perm.rename
},
canDrop () {
if (!this.isDir) return false
if (!this.isDir || this.isSharing) return false
for (let i of this.selected) {
if (this.req.items[i].url === this.url) {
@@ -170,8 +174,12 @@ export default {
action(overwrite, rename)
},
itemClick: function(event) {
if (this.singleClick && !this.$store.state.multiple) this.open()
else this.click(event)
},
click: function (event) {
if (this.selectedCount !== 0) event.preventDefault()
if (!this.singleClick && this.selectedCount !== 0) event.preventDefault()
if (this.$store.state.selected.indexOf(this.index) !== -1) {
this.removeSelected(this.index)
return
@@ -198,9 +206,12 @@ export default {
return
}
if (!event.ctrlKey && !this.$store.state.multiple) this.resetSelected()
if (!this.singleClick && !event.ctrlKey && !event.metaKey && !this.$store.state.multiple) this.resetSelected()
this.addSelected(this.index)
},
dblclick: function () {
if (!this.singleClick) this.open()
},
touchstart () {
setTimeout(() => {
this.touches = 0

View File

@@ -5,9 +5,7 @@
<i class="material-icons">close</i>
</button>
<div class="title">
<span>{{ this.name }}</span>
</div>
<div class="title">{{ this.name }}</div>
<preview-size-button v-if="isResizeEnabled && this.req.type === 'image'" @change-size="toggleSize" v-bind:size="fullSize" :disabled="loading"></preview-size-button>
<button @click="openMore" id="more" :aria-label="$t('buttons.more')" :title="$t('buttons.more')" class="action">
@@ -135,16 +133,29 @@ export default {
}
},
async mounted () {
window.addEventListener('keyup', this.key)
window.addEventListener('keydown', this.key)
this.$store.commit('setPreviewMode', true)
this.listing = this.oldReq.items
this.$root.$on('preview-deleted', this.deleted)
this.updatePreview()
},
beforeDestroy () {
window.removeEventListener('keyup', this.key)
window.removeEventListener('keydown', this.key)
this.$store.commit('setPreviewMode', false)
this.$root.$off('preview-deleted', this.deleted)
},
methods: {
deleted () {
this.listing = this.listing.filter(item => item.name !== this.name)
if (this.hasNext) {
this.next()
} else if (!this.hasPrevious && !this.hasNext) {
this.back()
} else {
this.prev()
}
},
back () {
this.$store.commit('setPreviewMode', false)
let uri = url.removeLastDir(this.$route.path) + '/'
@@ -157,7 +168,6 @@ export default {
this.$router.push({ path: this.nextLink })
},
key (event) {
event.preventDefault()
if (this.show !== null) {
return

View File

@@ -20,7 +20,6 @@
<script>
import {mapGetters, mapMutations, mapState} from 'vuex'
import { files as api } from '@/api'
import url from '@/utils/url'
import buttons from '@/utils/buttons'
export default {
@@ -32,17 +31,20 @@ export default {
methods: {
...mapMutations(['closeHovers']),
submit: async function () {
this.closeHovers()
buttons.loading('delete')
try {
if (!this.isListing) {
await api.remove(this.$route.path)
buttons.success('delete')
this.$router.push({ path: url.removeLastDir(this.$route.path) + '/' })
this.$root.$emit('preview-deleted')
this.closeHovers()
return
}
this.closeHovers()
if (this.selectedCount === 0) {
return
}

View File

@@ -24,6 +24,10 @@
<input type="checkbox" :disabled="user.perm.admin" v-model="user.lockPassword"> {{ $t('settings.lockPassword') }}
</p>
<p>
<input type="checkbox" v-model="user.singleClick"> {{ $t('settings.singleClick') }}
</p>
<permissions :perm.sync="user.perm" />
<commands v-if="isExecEnabled" :commands.sync="user.commands" />

View File

@@ -1,29 +1,65 @@
.share__box {
text-align: center;
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
background: #fff;
display: block;
border-radius: 0.2em;
width: 90%;
max-width: 25em;
margin: 6em auto;
.share {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
}
.share__box__download {
width: 100%;
@media (max-width: 736px) {
.share {
display: block;
}
}
.share__box {
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
background: #fff;
border-radius: 0.2em;
margin: 5px;
overflow: hidden;
}
.share__box__header {
padding: 1em;
cursor: pointer;
background: #ffffff;
color: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
text-align: center;
}
.share__box__icon i {
font-size: 10em;
color: #40c4ff;
}
.share__box__center {
text-align: center;
}
.share__box__info {
padding: 2em 3em;
flex: 1 1 auto;
}
.share__box__title {
margin-top: .2em;
overflow: hidden;
text-overflow: ellipsis;
.share__box__element {
padding: 1em;
border-top: 1px solid rgba(0, 0, 0, 0.1);
word-break: break-all;
}
.share__box__items {
text-align: left;
flex: 10 0 25em;
}
.share__box__items #listing.list .item {
cursor: pointer;
border-left: 0;
border-right: 0;
border-bottom: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.share__box__items #listing.list .item .name {
width: 50%;
}
.share__box__items #listing.list .item .modified {
width: 25%;
}

View File

@@ -119,18 +119,23 @@
#previewer .bar {
width: 100%;
text-align: right;
display: flex;
padding: 0.5em;
height: 3.7em;
}
#previewer .bar > * {
flex: 0 0 auto;
}
#previewer .bar .title {
margin-right: auto;
display: block;
flex: 1 1 auto;
padding: 0 1em;
line-height: 2.7em;
line-height: 2.3em;
overflow: hidden;
word-break: break-word;
text-overflow: ellipsis;
font-size: 1.2em;
color: #fff;
}
@@ -220,10 +225,6 @@
word-break: break-word;
}
#previewer .title span {
font-size: 1.2em;
}
#previewer .loading {
height: 100%;
width: 100%;

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "تنفيذ الأوامر",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

@@ -32,7 +32,8 @@
"toggleSidebar": "Toggle sidebar",
"update": "Update",
"upload": "Upload",
"permalink": "Get Permanent Link"
"permalink": "Get Permanent Link",
"hideDotfiles": "Hide dotfiles"
},
"success": {
"linkCopied": "Link copied!"
@@ -131,7 +132,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "Execute commands",
@@ -156,6 +157,9 @@
"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",
"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",
"ruleExample2": "blocks the access to the file named Caddyfile on the root of the scope.",
"rules": "Rules",
@@ -173,6 +177,7 @@
"globalRules": "This is a global set of allow and disallow rules. They apply to every user. You can define specific rules on each user's settings to override this ones.",
"allowSignup": "Allow users to signup",
"createUserDir": "Auto create user home dir while adding new user",
"singleClick": "Use single clicks to open files and directories",
"insertRegex": "Insert regex expression",
"insertPath": "Insert the path",
"userUpdated": "User updated!",
@@ -188,7 +193,8 @@
"execute": "Execute commands",
"rename": "Rename or move files and directories",
"share": "Share files"
}
},
"hideDotfiles": "Hide dotfiles"
},
"sidebar": {
"help": "Help",
@@ -242,6 +248,7 @@
},
"download": {
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
"downloadFolder": "Download Folder",
"downloadSelected": "Download Selected"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "Administrateur",
"allowCommands": "Exécuter des commandes",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "Amministratore",
"allowCommands": "Esegui comandi",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "管理者",
"administrator": "管理者",
"allowCommands": "コマンドの実行",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "Wykonaj polecenie",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentação",
"branding": "Branding",
"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": "Administrador",
"allowCommands": "Executar comandos",
@@ -233,4 +233,4 @@
"downloadFile": "Baixar arquivo",
"downloadFolder": "Baixar pasta"
}
}
}

View File

@@ -124,7 +124,7 @@
"documentation": "documentation",
"branding": "Branding",
"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": "Админ",
"administrator": "Администратор",
"allowCommands": "Запуск команд",
@@ -233,4 +233,4 @@
"downloadFile": "Download File",
"downloadFolder": "Download Folder"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,8 +41,10 @@ export default {
mounted () {
if (!recaptcha) return
window.grecaptcha.render('recaptcha', {
sitekey: recaptchaKey
window.grecaptcha.ready(function () {
window.grecaptcha.render('recaptcha', {
sitekey: recaptchaKey
})
})
},
methods: {

View File

@@ -1,9 +1,10 @@
<template>
<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/global' }"><router-link to="/settings/global">{{ $t('settings.globalSettings') }}</router-link></li>
<li :class="{ active: $route.path === '/settings/users' }"><router-link to="/settings/users">{{ $t('settings.userManagement') }}</router-link></li>
<li :class="{ active: $route.path === '/settings/shares' }"><router-link to="/settings/shares">{{ $t('settings.shareManagement') }}</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>
<router-view></router-view>

View File

@@ -1,66 +1,223 @@
<template>
<div class="share" v-if="loaded">
<a target="_blank" :href="link">
<div class="share__box">
<div class="share__box__download" v-if="file.isDir">{{ $t('download.downloadFolder') }}</div>
<div class="share__box__download" v-else>{{ $t('download.downloadFile') }}</div>
<div class="share__box__info">
<svg v-if="file.isDir" fill="#40c4ff" height="150" viewBox="0 0 24 24" width="150" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
<svg v-else fill="#40c4ff" height="150" viewBox="0 0 24 24" width="150" xmlns="http://www.w3.org/2000/svg">
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
<h1 class="share__box__title">{{ file.name }}</h1>
<qrcode-vue :value="fullLink" size="200" level="M"></qrcode-vue>
<div v-if="!loading">
<div id="breadcrumbs">
<router-link :to="'/share/' + hash" :aria-label="$t('files.home')" :title="$t('files.home')">
<i class="material-icons">home</i>
</router-link>
<span v-for="(link, index) in breadcrumbs" :key="index">
<span class="chevron"><i class="material-icons">keyboard_arrow_right</i></span>
<router-link :to="link.url">{{ link.name }}</router-link>
</span>
</div>
<div class="share">
<div class="share__box share__box__info">
<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 v-if="req.isDir && req.items.length > 0" class="share__box share__box__items">
<div class="share__box__header" v-if="req.isDir">
{{ $t('files.files') }}
</div>
<div id="listing" class="list">
<item v-for="(item) in req.items.slice(0, this.showLimit)"
: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>
</a>
<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 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>
<script>
import {mapState, mapMutations, mapGetters} from 'vuex';
import { share as api } from '@/api'
import { baseURL } from '@/utils/constants'
import filesize from 'filesize'
import moment from 'moment'
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 {
name: 'share',
components: {
Item,
Forbidden,
NotFound,
InternalError,
QrcodeVue
},
data: () => ({
loaded: false,
notFound: false,
file: null
error: null,
path: '',
showLimit: 500
}),
watch: {
'$route': 'fetchData'
},
created: function () {
this.fetchData()
created: async function () {
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: {
hash: function () {
return this.$route.params.pathMatch
...mapState(['hash', 'req', 'loading', 'multiple']),
...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 () {
return `${baseURL}/api/public/dl/${this.hash}/${encodeURI(this.file.name)}`
return `${baseURL}/api/public/dl/${this.hash}${this.path}`
},
fullLink: function () {
return window.location.origin + this.link
},
humanSize: function () {
if (this.req.isDir) {
return this.req.items.length
}
return filesize(this.req.size)
},
humanTime: function () {
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: {
...mapMutations([ 'setHash', 'resetSelected', 'updateRequest', 'setLoading' ]),
base64: function (name) {
return window.btoa(unescape(encodeURIComponent(name)))
},
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 {
this.file = await api.getHash(this.hash)
this.loaded = true
let file = await api.getHash(encodeURIComponent(this.$route.params.pathMatch))
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) {
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

@@ -6,6 +6,7 @@
</div>
<div class="card-content">
<p><input type="checkbox" v-model="hideDotfiles"> {{ $t('settings.hideDotfiles') }}</p>
<h3>{{ $t('settings.language') }}</h3>
<languages class="input input--block" :locale.sync="locale"></languages>
</div>
@@ -67,6 +68,7 @@ export default {
},
created () {
this.locale = this.user.locale
this.hideDotfiles = this.user.hideDotfiles
},
methods: {
...mapMutations([ 'updateUser' ]),
@@ -90,8 +92,8 @@ export default {
event.preventDefault()
try {
const data = { id: this.user.id, locale: this.locale }
await api.update(data, ['locale'])
const data = { id: this.user.id, locale: this.locale, hideDotfiles: this.hideDotfiles }
await api.update(data, ['locale', 'hideDotfiles'])
this.updateUser(data)
this.$showSuccess(this.$t('settings.settingsUpdated'))
} catch (e) {

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

@@ -23,9 +23,11 @@ type userInfo struct {
ID uint `json:"id"`
Locale string `json:"locale"`
ViewMode users.ViewMode `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Perm users.Permissions `json:"perm"`
Commands []string `json:"commands"`
LockPassword bool `json:"lockPassword"`
HideDotfiles bool `json:"hideDotfiles"`
}
type authToken struct {
@@ -172,9 +174,11 @@ func printToken(w http.ResponseWriter, _ *http.Request, d *data, user *users.Use
ID: user.ID,
Locale: user.Locale,
ViewMode: user.ViewMode,
SingleClick: user.SingleClick,
Perm: user.Perm,
LockPassword: user.LockPassword,
Commands: user.Commands,
HideDotfiles: user.HideDotfiles,
},
StandardClaims: jwt.StandardClaims{
IssuedAt: time.Now().Unix(),

View File

@@ -7,6 +7,7 @@ import (
"github.com/tomasen/realip"
"github.com/filebrowser/filebrowser/v2/rules"
"github.com/filebrowser/filebrowser/v2/runner"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage"
@@ -26,6 +27,10 @@ type data struct {
// Check implements rules.Checker.
func (d *data) Check(path string) bool {
if d.user.HideDotfiles && rules.MatchHidden(path) {
return false
}
allow := true
for _, rule := range d.settings.Rules {
if rule.Matches(path) {

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(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(sharePostHandler, "/api/share")).Methods("POST")
api.PathPrefix("/share").Handler(monkey(shareDeleteHandler, "/api/share")).Methods("DELETE")

View File

@@ -2,19 +2,21 @@ package http
import (
"net/http"
"path"
"path/filepath"
"strings"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
)
var withHashFile = func(fn handleFunc) handleFunc {
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 {
link, err = d.store.Share.GetByHash(ifPathWithName(r))
if err != nil {
return errToStatus(err), err
}
return errToStatus(err), err
}
user, err := d.store.Users.Get(d.server.Root, link.UserID)
@@ -28,13 +30,29 @@ var withHashFile = func(fn handleFunc) handleFunc {
Fs: d.user.Fs,
Path: link.Path,
Modify: d.user.Perm.Modify,
Expand: false,
Expand: true,
Checker: d,
})
if err != nil {
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
return fn(w, r, d)
}
@@ -42,19 +60,29 @@ var withHashFile = func(fn handleFunc) handleFunc {
// 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
func ifPathWithName(r *http.Request) string {
func ifPathWithName(r *http.Request) (id, filePath string) {
pathElements := strings.Split(r.URL.Path, "/")
// 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`
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) {
return renderJSON(w, r, d.raw)
file := d.raw.(*files.FileInfo)
if file.IsDir {
file.Listing.Sorting = files.Sorting{By: "name", Asc: false}
file.Listing.ApplySort()
return renderJSON(w, r, file)
}
return renderJSON(w, r, file)
})
var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {

View File

@@ -1,7 +1,9 @@
package http
import (
"bytes"
"errors"
"io/ioutil"
"net/http"
"net/url"
gopath "path"
@@ -9,6 +11,7 @@ import (
"strings"
"github.com/mholt/archiver"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils"
@@ -91,6 +94,11 @@ var rawHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data)
return errToStatus(err), err
}
if files.IsNamedPipe(file.Mode) {
setContentDisposition(w, r, file)
return 0, nil
}
if !file.IsDir {
return rawFileHandler(w, r, file)
}
@@ -110,23 +118,32 @@ func addFile(ar archiver.Writer, d *data, path, commonPath string) error {
return err
}
file, err := d.user.Fs.Open(path)
if err != nil {
return err
var (
file afero.File
arcReadCloser = ioutil.NopCloser(&bytes.Buffer{})
)
if !files.IsNamedPipe(info.Mode()) {
file, err = d.user.Fs.Open(path)
if err != nil {
return err
}
defer file.Close()
arcReadCloser = file
}
defer file.Close()
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, "/")
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: file,
})
if err != nil {
return err
if path != commonPath {
filename := strings.TrimPrefix(path, commonPath)
filename = strings.TrimPrefix(filename, "/")
err = ar.Write(archiver.File{
FileInfo: archiver.FileInfo{
FileInfo: info,
CustomName: filename,
},
ReadCloser: arcReadCloser,
})
if err != nil {
return err
}
}
if info.IsDir() {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
@@ -122,7 +123,7 @@ var resourcePostPutHandler = withUser(func(w http.ResponseWriter, r *http.Reques
}
err := d.RunHook(func() error {
dir, _ := filepath.Split(r.URL.Path)
dir, _ := path.Split(r.URL.Path)
err := d.user.Fs.MkdirAll(dir, 0775)
if err != nil {
return err
@@ -196,9 +197,10 @@ var resourcePatchHandler = withUser(func(w http.ResponseWriter, r *http.Request,
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
dst = filepath.Clean("/" + dst)
src = path.Clean("/" + src)
dst = path.Clean("/" + dst)
return d.user.Fs.Rename(src, dst)
return fileutils.MoveFile(d.user.Fs, src, dst)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)
}
@@ -221,20 +223,20 @@ func checkParent(src, dst string) error {
return nil
}
func addVersionSuffix(path string, fs afero.Fs) string {
func addVersionSuffix(source string, fs afero.Fs) string {
counter := 1
dir, name := filepath.Split(path)
dir, name := path.Split(source)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for {
if _, err := fs.Stat(path); err != nil {
if _, err := fs.Stat(source); err != nil {
break
}
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
path = filepath.ToSlash(dir) + renamed
source = path.Join(dir, renamed)
counter++
}
return path
return source
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64"
"net/http"
"path"
"sort"
"strconv"
"strings"
"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) {
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
if err == errors.ErrNotExist {

View File

@@ -14,6 +14,10 @@ import (
"github.com/filebrowser/filebrowser/v2/users"
)
var (
NonModifiableFieldsForNonAdmin = []string{"Username", "Scope", "LockPassword", "Perm", "Commands", "Rules"}
)
type modifyUserRequest struct {
modifyRequest
Data *users.User `json:"data"`
@@ -148,9 +152,9 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
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 {
return http.StatusForbidden, err
return http.StatusForbidden, nil
}
if req.Data.Password != "" {
@@ -169,7 +173,10 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
}
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 {
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") {
return http.StatusForbidden, nil
for _, f := range NonModifiableFieldsForNonAdmin {
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...)

View File

@@ -1,6 +1,7 @@
package rules
import (
"path/filepath"
"regexp"
"strings"
)
@@ -18,6 +19,12 @@ type Rule struct {
Regexp *Regexp `json:"regexp"`
}
// MatchHidden matches paths with a basename
// that begins with a dot.
func MatchHidden(path string) bool {
return strings.HasPrefix(filepath.Base(path), ".")
}
// Matches matches a path against a rule.
func (r *Rule) Matches(path string) bool {
if r.Regex {

23
rules/rules_test.go Normal file
View File

@@ -0,0 +1,23 @@
package rules
import "testing"
func TestMatchHidden(t *testing.T) {
cases := map[string]bool{
"/": false,
"/src": false,
"/src/": false,
"/.circleci": true,
"/a/b/c/.docker.json": true,
".docker.json": true,
"Dockerfile": false,
"/Dockerfile": false,
}
for path, want := range cases {
got := MatchHidden(path)
if got != want {
t.Errorf("MatchHidden(%s)=%v; want %v", path, got, want)
}
}
}

View File

@@ -8,12 +8,14 @@ import (
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
Scope string `json:"scope"`
Locale string `json:"locale"`
ViewMode users.ViewMode `json:"viewMode"`
Sorting files.Sorting `json:"sorting"`
Perm users.Permissions `json:"perm"`
Commands []string `json:"commands"`
Scope string `json:"scope"`
Locale string `json:"locale"`
ViewMode users.ViewMode `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Sorting files.Sorting `json:"sorting"`
Perm users.Permissions `json:"perm"`
Commands []string `json:"commands"`
HideDotfiles bool `json:"hideDotfiles"`
}
// Apply applies the default options to a user.
@@ -21,7 +23,9 @@ func (d *UserDefaults) Apply(u *users.User) {
u.Scope = d.Scope
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.Perm = d.Perm
u.Sorting = d.Sorting
u.Commands = d.Commands
u.HideDotfiles = d.HideDotfiles
}

View File

@@ -8,6 +8,8 @@ import (
// StorageBackend is the interface to implement for a share storage.
type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetPermanent(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}
}
// 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.
func (s *Storage) GetByHash(hash string) (*Link, error) {
link, err := s.back.GetByHash(hash)

View File

@@ -12,6 +12,26 @@ type shareBackend struct {
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) {
var v share.Link
err := s.db.One("Hash", hash, &v)

View File

@@ -28,11 +28,13 @@ type User struct {
Locale string `json:"locale"`
LockPassword bool `json:"lockPassword"`
ViewMode ViewMode `json:"viewMode"`
SingleClick bool `json:"singleClick"`
Perm Permissions `json:"perm"`
Commands []string `json:"commands"`
Sorting files.Sorting `json:"sorting"`
Fs afero.Fs `json:"-" yaml:"-"`
Rules []rules.Rule `json:"rules"`
HideDotfiles bool `json:"hideDotfiles"`
}
// GetRules implements rules.Provider.