Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: filebrowser/filebrowser
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: master
Choose a base ref
...
head repository: zhiyue-archive/filebrowser
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: master
Choose a head ref

Commits on Dec 27, 2016

  1. close #43

    hacdias committed Dec 27, 2016
    Copy the full SHA
    f50583c View commit details
  2. closd #54

    hacdias committed Dec 27, 2016
    Copy the full SHA
    bf6f2d8 View commit details

Commits on Dec 29, 2016

  1. Fixing bug with search box

    fabioswartz committed Dec 29, 2016
    Copy the full SHA
    c2a382f View commit details
  2. Updating search box

    fabioswartz committed Dec 29, 2016
    Copy the full SHA
    15a821b View commit details
  3. Clean search events

    hacdias committed Dec 29, 2016
    Copy the full SHA
    1cb511b View commit details
  4. tweak styles

    hacdias committed Dec 29, 2016
    Copy the full SHA
    5ab85ee View commit details
  5. take more advantage of space

    hacdias committed Dec 29, 2016
    Copy the full SHA
    25039ca View commit details
  6. updates and improvements

    hacdias committed Dec 29, 2016
    Copy the full SHA
    c6bcc3a View commit details
  7. style updates

    hacdias committed Dec 29, 2016
    Copy the full SHA
    23115e5 View commit details
  8. fix bug, download on root

    hacdias committed Dec 29, 2016
    Copy the full SHA
    72e65e8 View commit details
  9. updates

    hacdias committed Dec 29, 2016
    Copy the full SHA
    ebbe370 View commit details
  10. select multiple with ctrl key

    hacdias committed Dec 29, 2016
    Copy the full SHA
    0f774b3 View commit details

Commits on Dec 30, 2016

  1. push

    hacdias committed Dec 30, 2016
    Copy the full SHA
    f94f182 View commit details
  2. Cleaning common.js

    fabioswartz committed Dec 30, 2016
    Copy the full SHA
    a86bbc4 View commit details
  3. updates

    hacdias committed Dec 30, 2016
    Copy the full SHA
    a1cefe9 View commit details
  4. Copy the full SHA
    5adefd6 View commit details
  5. fix bug

    hacdias committed Dec 30, 2016
    Copy the full SHA
    698bdb7 View commit details
  6. Cleaning editor.js

    fabioswartz committed Dec 30, 2016
    Copy the full SHA
    07b8956 View commit details
  7. Copy the full SHA
    41cac9a View commit details
  8. update

    hacdias committed Dec 30, 2016
    Copy the full SHA
    af219c0 View commit details
  9. Copy the full SHA
    69d175e View commit details
  10. update common.js

    hacdias committed Dec 30, 2016
    Copy the full SHA
    092dbf4 View commit details
  11. Cleaning listing.js

    fabioswartz committed Dec 30, 2016
    Copy the full SHA
    ba9b025 View commit details
  12. Copy the full SHA
    57cee1e View commit details
  13. hey

    hacdias committed Dec 30, 2016
    Copy the full SHA
    414a76a View commit details
  14. Copy the full SHA
    23ef6e4 View commit details
  15. update

    hacdias committed Dec 30, 2016
    Copy the full SHA
    4f06373 View commit details
  16. updates

    hacdias committed Dec 30, 2016
    Copy the full SHA
    997f42a View commit details
  17. updates

    hacdias committed Dec 30, 2016
    Copy the full SHA
    52b1e8d View commit details

Commits on Dec 31, 2016

  1. html5 templates

    hacdias committed Dec 31, 2016
    Copy the full SHA
    0ceb15f View commit details
  2. update templates

    hacdias committed Dec 31, 2016
    Copy the full SHA
    0a3ca3a View commit details
  3. updates

    hacdias committed Dec 31, 2016
    Copy the full SHA
    758fe34 View commit details
  4. transition all

    hacdias committed Dec 31, 2016
    Copy the full SHA
    72b07d1 View commit details
  5. update rename dialog

    hacdias committed Dec 31, 2016
    Copy the full SHA
    1f5dcf5 View commit details
  6. updates

    hacdias committed Dec 31, 2016
    Copy the full SHA
    9759400 View commit details
  7. updates

    hacdias committed Dec 31, 2016
    Copy the full SHA
    ae54ba4 View commit details

Commits on Jan 1, 2017

  1. Related to #59

    hacdias committed Jan 1, 2017
    Copy the full SHA
    fc489d6 View commit details
  2. mobile bunch of updayes

    hacdias committed Jan 1, 2017
    Copy the full SHA
    28552c1 View commit details
  3. updates on mobile stuff

    hacdias committed Jan 1, 2017
    Copy the full SHA
    1d3d5e9 View commit details
  4. update

    hacdias committed Jan 1, 2017
    Copy the full SHA
    3a8caae View commit details
  5. fix download buttons

    hacdias committed Jan 1, 2017
    Copy the full SHA
    a3c2f0a View commit details

Commits on Jan 2, 2017

  1. close #59

    hacdias committed Jan 2, 2017
    Copy the full SHA
    18a1b65 View commit details
  2. close #2

    hacdias committed Jan 2, 2017
    Copy the full SHA
    b8c82bb View commit details
  3. updates

    hacdias committed Jan 2, 2017
    Copy the full SHA
    cbad4a5 View commit details
  4. close #63

    hacdias committed Jan 2, 2017
    Copy the full SHA
    c41fe7b View commit details
  5. updates on #63

    hacdias committed Jan 2, 2017
    Copy the full SHA
    6b9d5bc View commit details
  6. fix #65

    hacdias committed Jan 2, 2017
    Copy the full SHA
    8c76980 View commit details
  7. fix css bug

    hacdias committed Jan 2, 2017
    Copy the full SHA
    cb77ca9 View commit details
  8. Some changes

    Fábio Ferreira committed Jan 2, 2017
    Copy the full SHA
    4bd1d42 View commit details
  9. Copy the full SHA
    32c9fe0 View commit details
Showing with 5,661 additions and 0 deletions.
  1. +83 −0 .circleci/config.yml
  2. +2 −0 .dockerignore
  3. +24 −0 .github/ISSUE_TEMPLATE.md
  4. +13 −0 .gitignore
  5. +43 −0 .goreleaser.yml
  6. +10 −0 Docker.json
  7. +24 −0 Dockerfile
  8. +423 −0 Gopkg.lock
  9. +70 −0 Gopkg.toml
  10. +202 −0 LICENSE
  11. +75 −0 README.md
  12. +26 −0 bolt/config.go
  13. +66 −0 bolt/share.go
  14. +90 −0 bolt/users.go
  15. +14 −0 build.sh
  16. +52 −0 caddy/filemanager/filemanager.go
  17. +52 −0 caddy/hugo/hugo.go
  18. +52 −0 caddy/jekyll/jekyll.go
  19. +312 −0 caddy/parser/parser.go
  20. +259 −0 cmd/filebrowser/main.go
  21. +73 −0 doc.go
  22. +486 −0 file.go
  23. +558 −0 filebrowser.go
  24. +196 −0 http/auth.go
  25. +103 −0 http/download.go
  26. +344 −0 http/http.go
  27. +386 −0 http/resource.go
  28. +146 −0 http/settings.go
  29. +127 −0 http/share.go
  30. +383 −0 http/users.go
  31. +340 −0 http/websockets.go
  32. +8 −0 package.json
  33. +21 −0 publish.sh
  34. +259 −0 rice-box.go
  35. +195 −0 staticgen/hugo.go
  36. +125 −0 staticgen/jekyll.go
  37. +19 −0 staticgen/staticgen.go
83 changes: 83 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
version: 2
jobs:
linting:
docker:
- image: circleci/golang:1.10
working_directory: /go/src/github.com/filebrowser/filebrowser
steps:
- checkout
- run:
name: Install Dependencies
command: |
curl -sL -o $GOPATH/bin/dep https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64
chmod +x $GOPATH/bin/dep
dep ensure -v
go get github.com/alecthomas/gometalinter
gometalinter --install
- run:
name: Run linting
command: |
gometalinter --exclude="rice-box.go" \
--deadline=300s \
-D goconst \
-D gocyclo \
-D vetshadow \
-D errcheck \
-D golint \
-D gas
build:
docker:
- image: circleci/golang:1.10
working_directory: /go/src/github.com/filebrowser/filebrowser
steps:
- checkout
- run:
name: Install Dependencies
command: |
curl -sL -o $GOPATH/bin/dep https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64
chmod +x $GOPATH/bin/dep
dep ensure -v
- run:
name: Building
command: go build github.com/filebrowser/filebrowser/cmd/filebrowser
deploy:
docker:
- image: circleci/golang:1.10
working_directory: /go/src/github.com/filebrowser/filebrowser
steps:
- checkout
- run:
name: Install Dependencies
command: |
curl -sL -o $GOPATH/bin/dep https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64
chmod +x $GOPATH/bin/dep
dep ensure -v
- run:
name: Deploy
command: curl -sL https://git.io/goreleaser | bash

workflows:
version: 2
lint-build-deploy:
jobs:
- linting:
filters:
tags:
only: /.*/
branches:
only: /.*/
- build:
filters:
tags:
only: /.*/
branches:
only: /.*/
- deploy:
requires:
- linting
- build
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*(-.*)*/
branches:
ignore: /.*/
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
testdata/
.github/
24 changes: 24 additions & 0 deletions .github/ISSUE_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
### Instructions (remove before submitting):

1. Are you asking for help with using Caddy or File Browser? Please use our forum instead: https://forum.caddyserver.com.
2. If you are filing a bug report, please answer the following questions.
3. If your issue is not a bug report, you do not need to use this template.
4. If not using with Caddy, ignore questions 1 and 2.

### 1. Have you downloaded File Browser from caddyserver.com? If yes, when have you done that? If no, and you are running a custom build, which is the revision of File Browser's repository?

### 2. What is your entire Caddyfile?
```text
(Put Caddyfile here)
```

### 3. What are you trying to do?


### 4. What did you expect to see?


### 5. What did you see instead (give full error messages and/or log)?


### 6. How can someone who is starting from scratch reproduce this behaviour as minimally as possible?
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.DS_Store
node_modules/
*/dist/*
*.db
*.db.lock
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
.vscode
package-lock.json
yarn.lock
vendor
43 changes: 43 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
build:
main: cmd/filebrowser/main.go
binary: filebrowser
goos:
- darwin
- linux
- windows
- freebsd
- netbsd
- openbsd
- dragonfly
- solaris
goarch:
- amd64
- 386
- arm
- arm64
- mips
- mips64
- mipsle
- mips64le
goarm:
- 5
- 6
- 7
ignore:
- goos: darwin
goarch: 386
- goos: openbsd
goarch: arm
- goos: freebsd
goarch: arm
- goos: netbsd
goarch: arm
- goos: solaris
goarch: arm

archive:
name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
format: tar.gz
format_overrides:
- goos: windows
format: zip
10 changes: 10 additions & 0 deletions Docker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"port": 80,
"address": "",
"database": "/database.db",
"scope": "/srv",
"allowCommands": true,
"allowEdit": true,
"allowNew": true,
"commands": []
}
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM golang:alpine

COPY . /go/src/github.com/filebrowser/filebrowser

WORKDIR /go/src/github.com/filebrowser/filebrowser
RUN apk add --no-cache git curl && \
curl -fsSL -o /usr/local/bin/dep https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64 && \
chmod +x /usr/local/bin/dep
RUN dep ensure -vendor-only

WORKDIR /go/src/github.com/filebrowser/filebrowser/cmd/filebrowser
RUN CGO_ENABLED=0 go build -a
RUN mv filebrowser /go/bin/filebrowser

FROM scratch
COPY --from=0 /go/bin/filebrowser /filebrowser

VOLUME /tmp
VOLUME /srv
EXPOSE 80

COPY Docker.json /config.json

ENTRYPOINT ["/filebrowser", "--config", "/config.json"]
423 changes: 423 additions & 0 deletions Gopkg.lock

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions Gopkg.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
[[constraint]]
branch = "master"
name = "github.com/GeertJohan/go.rice"

[[constraint]]
name = "github.com/asdine/storm"
version = "2.0.2"

[[constraint]]
name = "github.com/dgrijalva/jwt-go"
version = "3.1.0"

[[constraint]]
name = "github.com/gohugoio/hugo"
version = "0.36.1"

[[constraint]]
name = "github.com/gorilla/websocket"
version = "1.2.0"

[[constraint]]
branch = "master"
name = "github.com/hacdias/fileutils"

[[constraint]]
branch = "master"
name = "github.com/hacdias/varutils"

[[constraint]]
name = "github.com/mholt/archiver"
# TODO: switch to version when it's available
# this is for Archiver.Write() which was introduced in 548c791
revision = "26cf5bb32d07aa4e8d0de15f56ce516f4641d7df"
# version = "2.0.0"

[[constraint]]
name = "github.com/mholt/caddy"
version = "0.10.11"

[[constraint]]
branch = "master"
name = "github.com/mitchellh/mapstructure"

[[constraint]]
name = "github.com/robfig/cron"
version = "1.0.0"

[[constraint]]
name = "github.com/spf13/pflag"
version = "1.0.0"

[[constraint]]
name = "github.com/spf13/viper"
version = "1.0.0"

[[constraint]]
branch = "master"
name = "golang.org/x/crypto"

[[constraint]]
name = "gopkg.in/natefinch/lumberjack.v2"
version = "2.1.0"

[[override]]
name = "github.com/russross/blackfriday"
version = "^1.0.0"

[prune]
go-tests = true
unused-packages = true
202 changes: 202 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@

Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

1. Definitions.

"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.

"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.

"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.

"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.

"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.

"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.

"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).

"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.

"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.

3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.

4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:

(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and

(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and

(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and

(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.

You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.

5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.

6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.

7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.

8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.

9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.

END OF TERMS AND CONDITIONS

APPENDIX: How to apply the Apache License to your work.

To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2018 File Browser contributors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
![Preview](https://user-images.githubusercontent.com/5447088/28537288-39be4288-70a2-11e7-8ce9-0813d59f46b7.gif)

# filebrowser

[![CircleCI](https://img.shields.io/circleci/project/github/filebrowser/filebrowser.svg?style=flat-square)](https://circleci.com/gh/filebrowser/filebrowser)
[![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser?style=flat-square)](https://goreportcard.com/report/github.com/filebrowser/filebrowser)
[![Documentation](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/filebrowser/filebrowser)
[![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg?style=flat-square)](https://github.com/filebrowser/filebrowser/releases/latest)

filebrowser provides a file managing interface within a specified directory and it can be used to upload, delete, preview, rename and edit your files. It allows the creation of multiple users and each user can have its own directory. It can be used as a standalone app or as a middleware.

# Table of contents

+ [Getting started](#getting-started)
+ [Features](#features)
- [Users](#users)
- [Search](#search)
+ [Contributing](#contributing)
+ [Donate](#donate)

# Getting started

You can find the Getting Started guide on the [documentation](https://filebrowser.github.io/quick-start/).

# Features

Easy login system.

![Login Page](https://user-images.githubusercontent.com/5447088/28432382-975493dc-6d7f-11e7-9190-23f8037159dc.jpg)

Listings of your files, available in two styles: mosaic and list. You can delete, move, rename, upload and create new files, as well as directories. Single files can be downloaded directly, and multiple files as *.zip*, *.tar*, *.tar.gz*, *.tar.bz2* or *.tar.xz*.

![Mosaic Listing](https://user-images.githubusercontent.com/5447088/28432384-9771bb4c-6d7f-11e7-8564-3a9bd6a3ce3a.jpg)

File Browser editor is powered by [Codemirror](https://codemirror.net/) and if you're working with markdown files with metadata, both parts will be separated from each other so you can focus on the content.

![Markdown Editor](https://user-images.githubusercontent.com/5447088/28432383-9756fdac-6d7f-11e7-8e58-fec49470d15f.jpg)

On the settings page, a regular user can set its own custom CSS to personalize the experience and change its password. For admins, they can manage the permissions of each user, set commands which can be executed when certain events are triggered (such as before saving and after saving) and change plugin's settings.

![Settings](https://user-images.githubusercontent.com/5447088/28432385-9776ec66-6d7f-11e7-90a5-891bacd4d02f.jpg)

We also allow the users to search in the directories and execute commands if allowed.

## Users

We support multiple users and each user can have its own scope and custom stylesheet. The administrator is able to choose which permissions should be given to the users, as well as the commands they can execute. Each user also have a set of rules, in which he can be prevented or allowed to access some directories (regular expressions included!).

![Users](https://user-images.githubusercontent.com/5447088/28432386-977f388a-6d7f-11e7-9006-87d16f05f1f8.jpg)

## Search

File Browser allows you to search through your files and it has some options. By default, your search will be something like this:

```
this are keywords
```

If you search for that it will look at every file that contains "this", "are" or "keywords" on their name. If you want to search for an exact term, you should surround your search by double quotes:

```
"this is the name"
```

That will search for any file that contains "this is the name" on its name. It won't search for each separated term this time.

By default, every search will be case sensitive. Although, you can make a case insensitive search by adding `case:insensitive` to the search terms, like this:

```
this are keywords case:insensitive
```

# Contributing

The contributing guidelines can be found [here](https://github.com/filebrowser/community).
26 changes: 26 additions & 0 deletions bolt/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package bolt

import (
"github.com/asdine/storm"
fb "github.com/filebrowser/filebrowser"
)

// ConfigStore is a configuration store.
type ConfigStore struct {
DB *storm.DB
}

// Get gets a configuration from the database to an interface.
func (c ConfigStore) Get(name string, to interface{}) error {
err := c.DB.Get("config", name, to)
if err == storm.ErrNotFound {
return fb.ErrNotExist
}

return err
}

// Save saves a configuration from an interface to the database.
func (c ConfigStore) Save(name string, from interface{}) error {
return c.DB.Set("config", name, from)
}
66 changes: 66 additions & 0 deletions bolt/share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package bolt

import (
"github.com/asdine/storm"
"github.com/asdine/storm/q"
fb "github.com/filebrowser/filebrowser"
)

// ShareStore is a shareable links store.
type ShareStore struct {
DB *storm.DB
}

// Get gets a Share Link from an hash.
func (s ShareStore) Get(hash string) (*fb.ShareLink, error) {
var v fb.ShareLink
err := s.DB.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}

return &v, err
}

// GetPermanent gets the permanent link from a path.
func (s ShareStore) GetPermanent(path string) (*fb.ShareLink, error) {
var v fb.ShareLink
err := s.DB.Select(q.Eq("Path", path), q.Eq("Expires", false)).First(&v)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}

return &v, err
}

// GetByPath gets all the links for a specific path.
func (s ShareStore) GetByPath(hash string) ([]*fb.ShareLink, error) {
var v []*fb.ShareLink
err := s.DB.Find("Path", hash, &v)
if err == storm.ErrNotFound {
return v, fb.ErrNotExist
}

return v, err
}

// Gets retrieves all the shareable links.
func (s ShareStore) Gets() ([]*fb.ShareLink, error) {
var v []*fb.ShareLink
err := s.DB.All(&v)
if err == storm.ErrNotFound {
return v, fb.ErrNotExist
}

return v, err
}

// Save stores a Share Link on the database.
func (s ShareStore) Save(l *fb.ShareLink) error {
return s.DB.Save(l)
}

// Delete deletes a Share Link from the database.
func (s ShareStore) Delete(hash string) error {
return s.DB.DeleteStruct(&fb.ShareLink{Hash: hash})
}
90 changes: 90 additions & 0 deletions bolt/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package bolt

import (
"reflect"

"github.com/asdine/storm"
fm "github.com/filebrowser/filebrowser"
)

// UsersStore is a users store.
type UsersStore struct {
DB *storm.DB
}

// Get gets a user with a certain id from the database.
func (u UsersStore) Get(id int, builder fm.FSBuilder) (*fm.User, error) {
var us fm.User
err := u.DB.One("ID", id, &us)
if err == storm.ErrNotFound {
return nil, fm.ErrNotExist
}

if err != nil {
return nil, err
}

us.FileSystem = builder(us.Scope)
return &us, nil
}

// GetByUsername gets a user with a certain username from the database.
func (u UsersStore) GetByUsername(username string, builder fm.FSBuilder) (*fm.User, error) {
var us fm.User
err := u.DB.One("Username", username, &us)
if err == storm.ErrNotFound {
return nil, fm.ErrNotExist
}

if err != nil {
return nil, err
}

us.FileSystem = builder(us.Scope)
return &us, nil
}

// Gets gets all the users from the database.
func (u UsersStore) Gets(builder fm.FSBuilder) ([]*fm.User, error) {
var us []*fm.User
err := u.DB.All(&us)
if err == storm.ErrNotFound {
return nil, fm.ErrNotExist
}

if err != nil {
return us, err
}

for _, user := range us {
user.FileSystem = builder(user.Scope)
}

return us, err
}

// Update updates the whole user object or only certain fields.
func (u UsersStore) Update(us *fm.User, fields ...string) error {
if len(fields) == 0 {
return u.Save(us)
}

for _, field := range fields {
val := reflect.ValueOf(us).Elem().FieldByName(field).Interface()
if err := u.DB.UpdateField(us, field, val); err != nil {
return err
}
}

return nil
}

// Save saves a user to the database.
func (u UsersStore) Save(us *fm.User) error {
return u.DB.Save(us)
}

// Delete deletes a user from the database.
func (u UsersStore) Delete(id int) error {
return u.DB.DeleteStruct(&fm.User{ID: id})
}
14 changes: 14 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash
set -e

# Install rice tool if not present
if ! [ -x "$(command -v rice)" ]; then
go get github.com/GeertJohan/go.rice/rice
fi

# Clean the dist folder and build the assets
rm -rf node_modules
npm install

# Embed the assets using rice
rice embed-go
52 changes: 52 additions & 0 deletions caddy/filemanager/filemanager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package filemanager

import (
"net/http"

"github.com/filebrowser/filebrowser"
"github.com/filebrowser/filebrowser/caddy/parser"
h "github.com/filebrowser/filebrowser/http"
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyhttp/httpserver"
)

func init() {
caddy.RegisterPlugin("filemanager", caddy.Plugin{
ServerType: "http",
Action: setup,
})
}

type plugin struct {
Next httpserver.Handler
Configs []*filebrowser.FileBrowser
}

// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (f plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
for i := range f.Configs {
// Checks if this Path should be handled by File Manager.
if !httpserver.Path(r.URL.Path).Matches(f.Configs[i].BaseURL) {
continue
}

h.Handler(f.Configs[i]).ServeHTTP(w, r)
return 0, nil
}

return f.Next.ServeHTTP(w, r)
}

// setup configures a new FileManager middleware instance.
func setup(c *caddy.Controller) error {
configs, err := parser.Parse(c, "")
if err != nil {
return err
}

httpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
return plugin{Configs: configs, Next: next}
})

return nil
}
52 changes: 52 additions & 0 deletions caddy/hugo/hugo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package hugo

import (
"net/http"

"github.com/filebrowser/filebrowser"
"github.com/filebrowser/filebrowser/caddy/parser"
h "github.com/filebrowser/filebrowser/http"
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyhttp/httpserver"
)

func init() {
caddy.RegisterPlugin("hugo", caddy.Plugin{
ServerType: "http",
Action: setup,
})
}

type plugin struct {
Next httpserver.Handler
Configs []*filebrowser.FileBrowser
}

// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (f plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
for i := range f.Configs {
// Checks if this Path should be handled by File Manager.
if !httpserver.Path(r.URL.Path).Matches(f.Configs[i].BaseURL) {
continue
}

h.Handler(f.Configs[i]).ServeHTTP(w, r)
return 0, nil
}

return f.Next.ServeHTTP(w, r)
}

// setup configures a new FileManager middleware instance.
func setup(c *caddy.Controller) error {
configs, err := parser.Parse(c, "hugo")
if err != nil {
return err
}

httpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
return plugin{Configs: configs, Next: next}
})

return nil
}
52 changes: 52 additions & 0 deletions caddy/jekyll/jekyll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package jekyll

import (
"net/http"

"github.com/filebrowser/filebrowser"
"github.com/filebrowser/filebrowser/caddy/parser"
h "github.com/filebrowser/filebrowser/http"
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyhttp/httpserver"
)

func init() {
caddy.RegisterPlugin("jekyll", caddy.Plugin{
ServerType: "http",
Action: setup,
})
}

type plugin struct {
Next httpserver.Handler
Configs []*filebrowser.FileBrowser
}

// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (f plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
for i := range f.Configs {
// Checks if this Path should be handled by File Manager.
if !httpserver.Path(r.URL.Path).Matches(f.Configs[i].BaseURL) {
continue
}

h.Handler(f.Configs[i]).ServeHTTP(w, r)
return 0, nil
}

return f.Next.ServeHTTP(w, r)
}

// setup configures a new FileManager middleware instance.
func setup(c *caddy.Controller) error {
configs, err := parser.Parse(c, "jekyll")
if err != nil {
return err
}

httpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
return plugin{Configs: configs, Next: next}
})

return nil
}
312 changes: 312 additions & 0 deletions caddy/parser/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
package parser

import (
"crypto/md5"
"encoding/hex"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/asdine/storm"
"github.com/filebrowser/filebrowser"
"github.com/filebrowser/filebrowser/bolt"
"github.com/filebrowser/filebrowser/staticgen"
"github.com/hacdias/fileutils"
"github.com/mholt/caddy"
"github.com/mholt/caddy/caddyhttp/httpserver"
)

var databases = map[string]*storm.DB{}

// Parse ...
func Parse(c *caddy.Controller, plugin string) ([]*filebrowser.FileBrowser, error) {
var (
configs []*filebrowser.FileBrowser
err error
)

for c.Next() {
u := &filebrowser.User{
Locale: "en",
AllowCommands: true,
AllowEdit: true,
AllowNew: true,
AllowPublish: true,
Commands: []string{"git", "svn", "hg"},
CSS: "",
ViewMode: "mosaic",
Rules: []*filebrowser.Rule{{
Regex: true,
Allow: false,
Regexp: &filebrowser.Regexp{Raw: "\\/\\..+"},
}},
}

baseURL := "/"
scope := "."
database := ""
noAuth := false
alterRecaptcha := false
reCaptchaKey := ""
reCaptchaSecret := ""

if plugin != "" {
baseURL = "/admin"
}

// Get the baseURL and scope
args := c.RemainingArgs()

if plugin == "" {
if len(args) >= 1 {
baseURL = args[0]
}

if len(args) > 1 {
scope = args[1]
}
} else {
if len(args) >= 1 {
scope = args[0]
}

if len(args) > 1 {
baseURL = args[1]
}
}

for c.NextBlock() {
switch c.Val() {
case "database":
if !c.NextArg() {
return nil, c.ArgErr()
}

database = c.Val()
case "locale":
if !c.NextArg() {
return nil, c.ArgErr()
}

u.Locale = c.Val()
case "allow_commands":
if !c.NextArg() {
u.AllowCommands = true
continue
}

u.AllowCommands, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
case "allow_edit":
if !c.NextArg() {
u.AllowEdit = true
continue
}

u.AllowEdit, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
case "allow_new":
if !c.NextArg() {
u.AllowNew = true
continue
}

u.AllowNew, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
case "allow_publish":
if !c.NextArg() {
u.AllowPublish = true
continue
}

u.AllowPublish, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
case "commands":
if !c.NextArg() {
return nil, c.ArgErr()
}

u.Commands = strings.Split(c.Val(), " ")
case "css":
if !c.NextArg() {
return nil, c.ArgErr()
}

file := c.Val()
css, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}

u.CSS = string(css)
case "view_mode":
if !c.NextArg() {
return nil, c.ArgErr()
}

u.ViewMode = c.Val()
if u.ViewMode != filebrowser.MosaicViewMode && u.ViewMode != filebrowser.ListViewMode {
return nil, c.ArgErr()
}
case "alternative_recaptcha":
if !c.NextArg() {
alterRecaptcha = true
continue
}

alterRecaptcha, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
case "recaptcha_key":
if !c.NextArg() {
return nil, c.ArgErr()
}

reCaptchaKey = c.Val()
case "recaptcha_secret":
if !c.NextArg() {
return nil, c.ArgErr()
}

reCaptchaSecret = c.Val()
case "no_auth":
if !c.NextArg() {
noAuth = true
continue
}

noAuth, err = strconv.ParseBool(c.Val())
if err != nil {
return nil, err
}
}
}

caddyConf := httpserver.GetConfig(c)

path := filepath.Join(caddy.AssetsPath(), "filemanager")
err := os.MkdirAll(path, 0700)
if err != nil {
return nil, err
}

// if there is a database path and it is not absolute,
// it will be relative to Caddy folder.
if !filepath.IsAbs(database) && database != "" {
database = filepath.Join(path, database)
}

// If there is no database path on the settings,
// store one in .caddy/filemanager/name.db.
if database == "" {
// The name of the database is the hashed value of a string composed
// by the host, address path and the baseurl of this File Manager
// instance.
hasher := md5.New()
hasher.Write([]byte(caddyConf.Addr.Host + caddyConf.Addr.Path + baseURL))
sha := hex.EncodeToString(hasher.Sum(nil))
database = filepath.Join(path, sha+".db")

fmt.Println("[WARNING] A database is going to be created for your File Manager instance at " + database +
". It is highly recommended that you set the 'database' option to '" + sha + ".db'\n")
}

u.Scope = scope
u.FileSystem = fileutils.Dir(scope)

var db *storm.DB
if stored, ok := databases[database]; ok {
db = stored
} else {
db, err = storm.Open(database)
databases[database] = db
}

if err != nil {
return nil, err
}

recaptchaHost := "https://www.google.com"
if alterRecaptcha {
recaptchaHost = "https://recaptcha.net"
}

m := &filebrowser.FileBrowser{
NoAuth: noAuth,
BaseURL: "",
PrefixURL: "",
ReCaptchaHost: recaptchaHost,
ReCaptchaKey: reCaptchaKey,
ReCaptchaSecret: reCaptchaSecret,
DefaultUser: u,
Store: &filebrowser.Store{
Config: bolt.ConfigStore{DB: db},
Users: bolt.UsersStore{DB: db},
Share: bolt.ShareStore{DB: db},
},
NewFS: func(scope string) filebrowser.FileSystem {
return fileutils.Dir(scope)
},
}

err = m.Setup()
if err != nil {
return nil, err
}

switch plugin {
case "hugo":
// Initialize the default settings for Hugo.
hugo := &staticgen.Hugo{
Root: scope,
Public: filepath.Join(scope, "public"),
Args: []string{},
CleanPublic: true,
}

// Attaches Hugo plugin to this file manager instance.
err = m.Attach(hugo)
if err != nil {
return nil, err
}
case "jekyll":
// Initialize the default settings for Jekyll.
jekyll := &staticgen.Jekyll{
Root: scope,
Public: filepath.Join(scope, "_site"),
Args: []string{},
CleanPublic: true,
}

// Attaches Hugo plugin to this file manager instance.
err = m.Attach(jekyll)
if err != nil {
return nil, err
}
}

if err != nil {
return nil, err
}

m.NoAuth = noAuth
m.SetBaseURL(baseURL)
m.SetPrefixURL(strings.TrimSuffix(caddyConf.Addr.Path, "/"))

configs = append(configs, m)
}

return configs, nil
}
259 changes: 259 additions & 0 deletions cmd/filebrowser/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package main

import (
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/asdine/storm"

"gopkg.in/natefinch/lumberjack.v2"

"github.com/filebrowser/filebrowser"
"github.com/filebrowser/filebrowser/bolt"
h "github.com/filebrowser/filebrowser/http"
"github.com/filebrowser/filebrowser/staticgen"
"github.com/hacdias/fileutils"
flag "github.com/spf13/pflag"
"github.com/spf13/viper"
)

var (
addr string
config string
database string
scope string
commands string
logfile string
staticg string
locale string
baseurl string
prefixurl string
viewMode string
recaptchakey string
recaptchasecret string
port int
noAuth bool
allowCommands bool
allowEdit bool
allowNew bool
allowPublish bool
showVer bool
alterRecaptcha bool
)

func init() {
flag.StringVarP(&config, "config", "c", "", "Configuration file")
flag.IntVarP(&port, "port", "p", 0, "HTTP Port (default is random)")
flag.StringVarP(&addr, "address", "a", "", "Address to listen to (default is all of them)")
flag.StringVarP(&database, "database", "d", "./filebrowser.db", "Database file")
flag.StringVarP(&logfile, "log", "l", "stdout", "Errors logger; can use 'stdout', 'stderr' or file")
flag.StringVarP(&scope, "scope", "s", ".", "Default scope option for new users")
flag.StringVarP(&baseurl, "baseurl", "b", "", "Base URL")
flag.StringVar(&commands, "commands", "git svn hg", "Default commands option for new users")
flag.StringVar(&prefixurl, "prefixurl", "", "Prefix URL")
flag.StringVar(&viewMode, "view-mode", "mosaic", "Default view mode for new users")
flag.StringVar(&recaptchakey, "recaptcha-key", "", "ReCaptcha site key")
flag.StringVar(&recaptchasecret, "recaptcha-secret", "", "ReCaptcha secret")
flag.BoolVar(&allowCommands, "allow-commands", true, "Default allow commands option for new users")
flag.BoolVar(&allowEdit, "allow-edit", true, "Default allow edit option for new users")
flag.BoolVar(&allowPublish, "allow-publish", true, "Default allow publish option for new users")
flag.BoolVar(&allowNew, "allow-new", true, "Default allow new option for new users")
flag.BoolVar(&noAuth, "no-auth", false, "Disables authentication")
flag.BoolVar(&alterRecaptcha, "alternative-recaptcha", false, "Use recaptcha.net for serving and handling, useful in China")
flag.StringVar(&locale, "locale", "", "Default locale for new users, set it empty to enable auto detect from browser")
flag.StringVar(&staticg, "staticgen", "", "Static Generator you want to enable")
flag.BoolVarP(&showVer, "version", "v", false, "Show version")
}

func setupViper() {
viper.SetDefault("Address", "")
viper.SetDefault("Port", "0")
viper.SetDefault("Database", "./filebrowser.db")
viper.SetDefault("Scope", ".")
viper.SetDefault("Logger", "stdout")
viper.SetDefault("Commands", []string{"git", "svn", "hg"})
viper.SetDefault("AllowCommmands", true)
viper.SetDefault("AllowEdit", true)
viper.SetDefault("AllowNew", true)
viper.SetDefault("AllowPublish", true)
viper.SetDefault("StaticGen", "")
viper.SetDefault("Locale", "")
viper.SetDefault("NoAuth", false)
viper.SetDefault("BaseURL", "")
viper.SetDefault("PrefixURL", "")
viper.SetDefault("ViewMode", filebrowser.MosaicViewMode)
viper.SetDefault("AlternativeRecaptcha", false)
viper.SetDefault("ReCaptchaKey", "")
viper.SetDefault("ReCaptchaSecret", "")

viper.BindPFlag("Port", flag.Lookup("port"))
viper.BindPFlag("Address", flag.Lookup("address"))
viper.BindPFlag("Database", flag.Lookup("database"))
viper.BindPFlag("Scope", flag.Lookup("scope"))
viper.BindPFlag("Logger", flag.Lookup("log"))
viper.BindPFlag("Commands", flag.Lookup("commands"))
viper.BindPFlag("AllowCommands", flag.Lookup("allow-commands"))
viper.BindPFlag("AllowEdit", flag.Lookup("allow-edit"))
viper.BindPFlag("AllowNew", flag.Lookup("allow-new"))
viper.BindPFlag("AllowPublish", flag.Lookup("allow-publish"))
viper.BindPFlag("Locale", flag.Lookup("locale"))
viper.BindPFlag("StaticGen", flag.Lookup("staticgen"))
viper.BindPFlag("NoAuth", flag.Lookup("no-auth"))
viper.BindPFlag("BaseURL", flag.Lookup("baseurl"))
viper.BindPFlag("PrefixURL", flag.Lookup("prefixurl"))
viper.BindPFlag("ViewMode", flag.Lookup("view-mode"))
viper.BindPFlag("AlternativeRecaptcha", flag.Lookup("alternative-recaptcha"))
viper.BindPFlag("ReCaptchaKey", flag.Lookup("recaptcha-key"))
viper.BindPFlag("ReCaptchaSecret", flag.Lookup("recaptcha-secret"))

viper.SetConfigName("filebrowser")
viper.AddConfigPath(".")
}

func printVersion() {
fmt.Println("filebrowser version", filebrowser.Version)
os.Exit(0)
}

func main() {
setupViper()
flag.Parse()

if showVer {
printVersion()
}

// Add a configuration file if set.
if config != "" {
ext := filepath.Ext(config)
dir := filepath.Dir(config)
config = strings.TrimSuffix(config, ext)

if dir != "" {
viper.AddConfigPath(dir)
config = strings.TrimPrefix(config, dir)
}

viper.SetConfigName(config)
}

// Read configuration from a file if exists.
err := viper.ReadInConfig()
if err != nil {
if _, ok := err.(viper.ConfigParseError); ok {
panic(err)
}
}

// Set up process log before anything bad happens.
switch viper.GetString("Logger") {
case "stdout":
log.SetOutput(os.Stdout)
case "stderr":
log.SetOutput(os.Stderr)
case "":
log.SetOutput(ioutil.Discard)
default:
log.SetOutput(&lumberjack.Logger{
Filename: logfile,
MaxSize: 100,
MaxAge: 14,
MaxBackups: 10,
})
}

// Builds the address and a listener.
laddr := viper.GetString("Address") + ":" + viper.GetString("Port")
listener, err := net.Listen("tcp", laddr)
if err != nil {
log.Fatal(err)
}

// Tell the user the port in which is listening.
fmt.Println("Listening on", listener.Addr().String())

// Starts the server.
if err := http.Serve(listener, handler()); err != nil {
log.Fatal(err)
}
}

func handler() http.Handler {
db, err := storm.Open(viper.GetString("Database"))
if err != nil {
log.Fatal(err)
}

recaptchaHost := "https://www.google.com"
if viper.GetBool("AlternativeRecaptcha") {
recaptchaHost = "https://recaptcha.net"
}

fm := &filebrowser.FileBrowser{
NoAuth: viper.GetBool("NoAuth"),
BaseURL: viper.GetString("BaseURL"),
PrefixURL: viper.GetString("PrefixURL"),
ReCaptchaHost: recaptchaHost,
ReCaptchaKey: viper.GetString("ReCaptchaKey"),
ReCaptchaSecret: viper.GetString("ReCaptchaSecret"),
DefaultUser: &filebrowser.User{
AllowCommands: viper.GetBool("AllowCommands"),
AllowEdit: viper.GetBool("AllowEdit"),
AllowNew: viper.GetBool("AllowNew"),
AllowPublish: viper.GetBool("AllowPublish"),
Commands: viper.GetStringSlice("Commands"),
Rules: []*filebrowser.Rule{},
Locale: viper.GetString("Locale"),
CSS: "",
Scope: viper.GetString("Scope"),
FileSystem: fileutils.Dir(viper.GetString("Scope")),
ViewMode: viper.GetString("ViewMode"),
},
Store: &filebrowser.Store{
Config: bolt.ConfigStore{DB: db},
Users: bolt.UsersStore{DB: db},
Share: bolt.ShareStore{DB: db},
},
NewFS: func(scope string) filebrowser.FileSystem {
return fileutils.Dir(scope)
},
}

err = fm.Setup()
if err != nil {
log.Fatal(err)
}

switch viper.GetString("StaticGen") {
case "hugo":
hugo := &staticgen.Hugo{
Root: viper.GetString("Scope"),
Public: filepath.Join(viper.GetString("Scope"), "public"),
Args: []string{},
CleanPublic: true,
}

if err = fm.Attach(hugo); err != nil {
log.Fatal(err)
}
case "jekyll":
jekyll := &staticgen.Jekyll{
Root: viper.GetString("Scope"),
Public: filepath.Join(viper.GetString("Scope"), "_site"),
Args: []string{"build"},
CleanPublic: true,
}

if err = fm.Attach(jekyll); err != nil {
log.Fatal(err)
}
}

return h.Handler(fm)
}
73 changes: 73 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
Package filebrowser provides a web interface to access your files
wherever you are. To use this package as a middleware for your app,
you'll need to import both File Browser and File Browser HTTP packages.
import (
fm "github.com/filebrowser/filebrowser"
h "github.com/filebrowser/filebrowser/http"
)
Then, you should create a new FileBrowser object with your options. In this
case, I'm using BoltDB (via Storm package) as a Store. So, you'll also need
to import "github.com/filebrowser/filebrowser/bolt".
db, _ := storm.Open("bolt.db")
m := &fm.FileBrowser{
NoAuth: false,
DefaultUser: &fm.User{
AllowCommands: true,
AllowEdit: true,
AllowNew: true,
AllowPublish: true,
Commands: []string{"git"},
Rules: []*fm.Rule{},
Locale: "en",
CSS: "",
Scope: ".",
FileSystem: fileutils.Dir("."),
},
Store: &fm.Store{
Config: bolt.ConfigStore{DB: db},
Users: bolt.UsersStore{DB: db},
Share: bolt.ShareStore{DB: db},
},
NewFS: func(scope string) fm.FileSystem {
return fileutils.Dir(scope)
},
}
The credentials for the first user are always 'admin' for both the user and
the password, and they can be changed later through the settings. The first
user is always an Admin and has all of the permissions set to 'true'.
Then, you should set the Prefix URL and the Base URL, using the following
functions:
m.SetBaseURL("/")
m.SetPrefixURL("/")
The Prefix URL is a part of the path that is already stripped from the
r.URL.Path variable before the request arrives to File Browser's handler.
This is a function that will rarely be used. You can see one example on Caddy
filemanager plugin.
The Base URL is the URL path where you want File Browser to be available in. If
you want to be available at the root path, you should call:
m.SetBaseURL("/")
But if you want to access it at '/admin', you would call:
m.SetBaseURL("/admin")
Now, that you already have a File Browser instance created, you just need to
add it to your handlers using m.ServeHTTP which is compatible to http.Handler.
We also have a m.ServeWithErrorsHTTP that returns the status code and an error.
One simple implementation for this, at port 80, in the root of the domain, would be:
http.ListenAndServe(":80", h.Handler(m))
*/
package filebrowser
486 changes: 486 additions & 0 deletions file.go

Large diffs are not rendered by default.

558 changes: 558 additions & 0 deletions filebrowser.go

Large diffs are not rendered by default.

196 changes: 196 additions & 0 deletions http/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package http

import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"

"github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
fb "github.com/filebrowser/filebrowser"
)

const reCaptchaAPI = "/recaptcha/api/siteverify"

type cred struct {
Password string `json:"password"`
Username string `json:"username"`
ReCaptcha string `json:"recaptcha"`
}

// reCaptcha checks the reCaptcha code.
func reCaptcha(host, secret, response string) (bool, error) {
body := url.Values{}
body.Set("secret", secret)
body.Add("response", response)

client := &http.Client{}

resp, err := client.Post(host+reCaptchaAPI, "application/x-www-form-urlencoded", strings.NewReader(body.Encode()))
if err != nil {
return false, err
}

if resp.StatusCode != http.StatusOK {
return false, nil
}

var data struct {
Success bool `json:"success"`
}

err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return false, err
}

return data.Success, nil
}

// authHandler processes the authentication for the user.
func authHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// NoAuth instances shouldn't call this method.
if c.NoAuth {
return 0, nil
}

// Receive the credentials from the request and unmarshal them.
var cred cred
if r.Body == nil {
return http.StatusForbidden, nil
}

err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
return http.StatusForbidden, nil
}

// If ReCaptcha is enabled, check the code.
if len(c.ReCaptchaSecret) > 0 {
ok, err := reCaptcha(c.ReCaptchaHost, c.ReCaptchaSecret, cred.ReCaptcha)
if err != nil {
return http.StatusForbidden, err
}

if !ok {
return http.StatusForbidden, nil
}
}

// Checks if the user exists.
u, err := c.Store.Users.GetByUsername(cred.Username, c.NewFS)
if err != nil {
return http.StatusForbidden, nil
}

// Checks if the password is correct.
if !fb.CheckPasswordHash(cred.Password, u.Password) {
return http.StatusForbidden, nil
}

c.User = u
return printToken(c, w)
}

// renewAuthHandler is used when the front-end already has a JWT token
// and is checking if it is up to date. If so, updates its info.
func renewAuthHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
ok, u := validateAuth(c, r)
if !ok {
return http.StatusForbidden, nil
}

c.User = u
return printToken(c, w)
}

// claims is the JWT claims.
type claims struct {
fb.User
jwt.StandardClaims
}

// printToken prints the final JWT token to the user.
func printToken(c *fb.Context, w http.ResponseWriter) (int, error) {
// Creates a copy of the user and removes it password
// hash so it never arrives to the user.
u := fb.User{}
u = *c.User
u.Password = ""

// Builds the claims.
claims := claims{
u,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Hour * 24).Unix(),
Issuer: "File Browser",
},
}

// Creates the token and signs it.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(c.Key)

if err != nil {
return http.StatusInternalServerError, err
}

// Writes the token.
w.Header().Set("Content-Type", "cty")
w.Write([]byte(signed))
return 0, nil
}

type extractor []string

func (e extractor) ExtractToken(r *http.Request) (string, error) {
token, _ := request.AuthorizationHeaderExtractor.ExtractToken(r)

// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
}

cookie, err := r.Cookie("auth")
if err != nil {
return "", request.ErrNoTokenInRequest
}

return cookie.Value, nil
}

// validateAuth is used to validate the authentication and returns the
// User if it is valid.
func validateAuth(c *fb.Context, r *http.Request) (bool, *fb.User) {
if c.NoAuth {
c.User = c.DefaultUser
return true, c.User
}

keyFunc := func(token *jwt.Token) (interface{}, error) {
return c.Key, nil
}

var claims claims
token, err := request.ParseFromRequestWithClaims(r,
extractor{},
&claims,
keyFunc,
)

if err != nil || !token.Valid {
return false, nil
}

u, err := c.Store.Users.Get(claims.User.ID, c.NewFS)
if err != nil {
return false, nil
}

c.User = u
return true, u
}
103 changes: 103 additions & 0 deletions http/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package http

import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"

fb "github.com/filebrowser/filebrowser"
"github.com/hacdias/fileutils"
"github.com/mholt/archiver"
)

// downloadHandler creates an archive in one of the supported formats (zip, tar,
// tar.gz or tar.bz2) and sends it to be downloaded.
func downloadHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If the file isn't a directory, serve it using http.ServeFile. We display it
// inline if it is requested.
if !c.File.IsDir {
return downloadFileHandler(c, w, r)
}

query := r.URL.Query().Get("format")
files := []string{}
names := strings.Split(r.URL.Query().Get("files"), ",")

// If there are files in the query, sanitize their names.
// Otherwise, just append the current path.
if len(names) != 0 {
for _, name := range names {
// Unescape the name.
name, err := url.QueryUnescape(name)
if err != nil {
return http.StatusInternalServerError, err
}

// Clean the slashes.
name = fileutils.SlashClean(name)
files = append(files, filepath.Join(c.File.Path, name))
}
} else {
files = append(files, c.File.Path)
}

var (
extension string
ar archiver.Archiver
)

switch query {
// If the format is true, just set it to "zip".
case "zip", "true", "":
extension, ar = ".zip", archiver.Zip
case "tar":
extension, ar = ".tar", archiver.Tar
case "targz":
extension, ar = ".tar.gz", archiver.TarGz
case "tarbz2":
extension, ar = ".tar.bz2", archiver.TarBz2
case "tarxz":
extension, ar = ".tar.xz", archiver.TarXZ
default:
return http.StatusNotImplemented, nil
}

// Defines the file name.
name := c.File.Name
if name == "." || name == "" {
name = "archive"
}
name += extension

w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
err := ar.Write(w, files)

return 0, err
}

func downloadFileHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
file, err := os.Open(c.File.Path)
defer file.Close()

if err != nil {
return http.StatusInternalServerError, err
}

stat, err := file.Stat()
if err != nil {
return http.StatusInternalServerError, err
}

if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(c.File.Name))
}

http.ServeContent(w, r, stat.Name(), stat.ModTime(), file)

return 0, nil
}
344 changes: 344 additions & 0 deletions http/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
package http

import (
"encoding/json"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"

fb "github.com/filebrowser/filebrowser"
)

// Handler returns a function compatible with http.HandleFunc.
func Handler(m *fb.FileBrowser) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code, err := serve(&fb.Context{
FileBrowser: m,
User: nil,
File: nil,
}, w, r)

if code >= 400 {
w.WriteHeader(code)

txt := http.StatusText(code)
log.Printf("%v: %v %v\n", r.URL.Path, code, txt)
w.Write([]byte(txt + "\n"))
}

if err != nil {
log.Print(err)
}
})
}

// serve is the main entry point of this HTML application.
func serve(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Checks if the URL contains the baseURL and strips it. Otherwise, it just
// returns a 404 fb.Error because we're not supposed to be here!
p := strings.TrimPrefix(r.URL.Path, c.BaseURL)

if len(p) >= len(r.URL.Path) && c.BaseURL != "" {
return http.StatusNotFound, nil
}

r.URL.Path = p

// Check if this request is made to the service worker. If so,
// pass it through a template to add the needed variables.
if r.URL.Path == "/sw.js" {
return renderFile(c, w, "sw.js")
}

// Checks if this request is made to the static assets folder. If so, and
// if it is a GET request, returns with the asset. Otherwise, returns
// a status not implemented.
if matchURL(r.URL.Path, "/static") {
if r.Method != http.MethodGet {
return http.StatusNotImplemented, nil
}

return staticHandler(c, w, r)
}

// Checks if this request is made to the API and directs to the
// API handler if so.
if matchURL(r.URL.Path, "/api") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
return apiHandler(c, w, r)
}

// If it is a request to the preview and a static website generator is
// active, build the preview.
if strings.HasPrefix(r.URL.Path, "/preview") && c.StaticGen != nil {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/preview")
return c.StaticGen.Preview(c, w, r)
}

if strings.HasPrefix(r.URL.Path, "/share/") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/share/")
return sharePage(c, w, r)
}

// Any other request should show the index.html file.
w.Header().Set("x-frame-options", "SAMEORIGIN")
w.Header().Set("x-content-type-options", "nosniff")
w.Header().Set("x-xss-protection", "1; mode=block")

return renderFile(c, w, "index.html")
}

// staticHandler handles the static assets path.
func staticHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "/static/manifest.json" {
http.FileServer(c.Assets.HTTPBox()).ServeHTTP(w, r)
return 0, nil
}

return renderFile(c, w, "static/manifest.json")
}

// apiHandler is the main entry point for the /api endpoint.
func apiHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path == "/auth/get" {
return authHandler(c, w, r)
}

if r.URL.Path == "/auth/renew" {
return renewAuthHandler(c, w, r)
}

valid, _ := validateAuth(c, r)
if !valid {
return http.StatusForbidden, nil
}

c.Router, r.URL.Path = splitURL(r.URL.Path)

if !c.User.Allowed(r.URL.Path) {
return http.StatusForbidden, nil
}

if c.StaticGen != nil {
// If we are using the 'magic url' for the settings,
// we should redirect the request for the acutual path.
if r.URL.Path == "/settings" {
r.URL.Path = c.StaticGen.SettingsPath()
}

// Executes the Static website generator hook.
code, err := c.StaticGen.Hook(c, w, r)
if code != 0 || err != nil {
return code, err
}
}

if c.Router == "checksum" || c.Router == "download" {
var err error
c.File, err = fb.GetInfo(r.URL, c.FileBrowser, c.User)
if err != nil {
return ErrorToHTTP(err, false), err
}
}

var code int
var err error

switch c.Router {
case "download":
code, err = downloadHandler(c, w, r)
case "checksum":
code, err = checksumHandler(c, w, r)
case "command":
code, err = command(c, w, r)
case "search":
code, err = search(c, w, r)
case "resource":
code, err = resourceHandler(c, w, r)
case "users":
code, err = usersHandler(c, w, r)
case "settings":
code, err = settingsHandler(c, w, r)
case "share":
code, err = shareHandler(c, w, r)
default:
code = http.StatusNotFound
}

return code, err
}

// serveChecksum calculates the hash of a file. Supports MD5, SHA1, SHA256 and SHA512.
func checksumHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
query := r.URL.Query().Get("algo")

val, err := c.File.Checksum(query)
if err == fb.ErrInvalidOption {
return http.StatusBadRequest, err
} else if err != nil {
return http.StatusInternalServerError, err
}

w.Write([]byte(val))
return 0, nil
}

// splitURL splits the path and returns everything that stands
// before the first slash and everything that goes after.
func splitURL(path string) (string, string) {
if path == "" {
return "", ""
}

path = strings.TrimPrefix(path, "/")

i := strings.Index(path, "/")
if i == -1 {
return "", path
}

return path[0:i], path[i:]
}

// renderFile renders a file using a template with some needed variables.
func renderFile(c *fb.Context, w http.ResponseWriter, file string) (int, error) {
tpl := template.Must(template.New("file").Parse(c.Assets.MustString(file)))

var contentType string
switch filepath.Ext(file) {
case ".html":
contentType = "text/html"
case ".js":
contentType = "application/javascript"
case ".json":
contentType = "application/json"
default:
contentType = "text"
}

w.Header().Set("Content-Type", contentType+"; charset=utf-8")

data := map[string]interface{}{
"BaseURL": c.RootURL(),
"NoAuth": c.NoAuth,
"Version": fb.Version,
"CSS": template.CSS(c.CSS),
"ReCaptcha": c.ReCaptchaKey != "" && c.ReCaptchaSecret != "",
"ReCaptchaHost": c.ReCaptchaHost,
"ReCaptchaKey": c.ReCaptchaKey,
}

if c.StaticGen != nil {
data["StaticGen"] = c.StaticGen.Name()
}

err := tpl.Execute(w, data)

if err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}

// sharePage build the share page.
func sharePage(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
s, err := c.Store.Share.Get(r.URL.Path)
if err == fb.ErrNotExist {
w.WriteHeader(http.StatusNotFound)
return renderFile(c, w, "static/share/404.html")
}

if err != nil {
return http.StatusInternalServerError, err
}

if s.Expires && s.ExpireDate.Before(time.Now()) {
c.Store.Share.Delete(s.Hash)
w.WriteHeader(http.StatusNotFound)
return renderFile(c, w, "static/share/404.html")
}

r.URL.Path = s.Path

info, err := os.Stat(s.Path)
if err != nil {
c.Store.Share.Delete(s.Hash)
return ErrorToHTTP(err, false), err
}

c.File = &fb.File{
Path: s.Path,
Name: info.Name(),
ModTime: info.ModTime(),
Mode: info.Mode(),
IsDir: info.IsDir(),
Size: info.Size(),
}

dl := r.URL.Query().Get("dl")

if dl == "" || dl == "0" {
tpl := template.Must(template.New("file").Parse(c.Assets.MustString("static/share/index.html")))
w.Header().Set("Content-Type", "text/html; charset=utf-8")

err := tpl.Execute(w, map[string]interface{}{
"BaseURL": c.RootURL(),
"File": c.File,
})

if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}

return downloadHandler(c, w, r)
}

// renderJSON prints the JSON version of data to the browser.
func renderJSON(w http.ResponseWriter, data interface{}) (int, error) {
marsh, err := json.Marshal(data)
if err != nil {
return http.StatusInternalServerError, err
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
if _, err := w.Write(marsh); err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}

// matchURL checks if the first URL matches the second.
func matchURL(first, second string) bool {
first = strings.ToLower(first)
second = strings.ToLower(second)

return strings.HasPrefix(first, second)
}

// ErrorToHTTP converts errors to HTTP Status Code.
func ErrorToHTTP(err error, gone bool) int {
switch {
case err == nil:
return http.StatusOK
case os.IsPermission(err):
return http.StatusForbidden
case os.IsNotExist(err):
if !gone {
return http.StatusNotFound
}

return http.StatusGone
case os.IsExist(err):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}
386 changes: 386 additions & 0 deletions http/resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
package http

import (
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

fb "github.com/filebrowser/filebrowser"
"github.com/hacdias/fileutils"
)

// sanitizeURL sanitizes the URL to prevent path transversal
// using fileutils.SlashClean and adds the trailing slash bar.
func sanitizeURL(url string) string {
path := fileutils.SlashClean(url)
if strings.HasSuffix(url, "/") && path != "/" {
return path + "/"
}
return path
}

func resourceHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = sanitizeURL(r.URL.Path)

switch r.Method {
case http.MethodGet:
return resourceGetHandler(c, w, r)
case http.MethodDelete:
return resourceDeleteHandler(c, w, r)
case http.MethodPut:
// Before save command handler.
path := filepath.Join(c.User.Scope, r.URL.Path)
if err := c.Runner("before_save", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

code, err := resourcePostPutHandler(c, w, r)
if code != http.StatusOK {
return code, err
}

// After save command handler.
if err := c.Runner("after_save", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

return code, err
case http.MethodPatch:
return resourcePatchHandler(c, w, r)
case http.MethodPost:
return resourcePostPutHandler(c, w, r)
}

return http.StatusNotImplemented, nil
}

func resourceGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Gets the information of the directory/file.
f, err := fb.GetInfo(r.URL, c.FileBrowser, c.User)
if err != nil {
return ErrorToHTTP(err, false), err
}

// If it's a dir and the path doesn't end with a trailing slash,
// add a trailing slash to the path.
if f.IsDir && !strings.HasSuffix(r.URL.Path, "/") {
r.URL.Path = r.URL.Path + "/"
}

// If it is a dir, go and serve the listing.
if f.IsDir {
c.File = f
return listingHandler(c, w, r)
}

// Tries to get the file type.
if err = f.GetFileType(true); err != nil {
return ErrorToHTTP(err, true), err
}

// Serve a preview if the file can't be edited or the
// user has no permission to edit this file. Otherwise,
// just serve the editor.
if !f.CanBeEdited() || !c.User.AllowEdit {
f.Kind = "preview"
return renderJSON(w, f)
}

f.Kind = "editor"

// Tries to get the editor data.
if err = f.GetEditor(); err != nil {
return http.StatusInternalServerError, err
}

return renderJSON(w, f)
}

func listingHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
f := c.File
f.Kind = "listing"

// Tries to get the listing data.
if err := f.GetListing(c.User, r); err != nil {
return ErrorToHTTP(err, true), err
}

listing := f.Listing

// Defines the cookie scope.
cookieScope := c.RootURL()
if cookieScope == "" {
cookieScope = "/"
}

// Copy the query values into the Listing struct
if sort, order, err := handleSortOrder(w, r, cookieScope); err == nil {
listing.Sort = sort
listing.Order = order
} else {
return http.StatusBadRequest, err
}

listing.ApplySort()
return renderJSON(w, f)
}

func resourceDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Prevent the removal of the root directory.
if r.URL.Path == "/" || !c.User.AllowEdit {
return http.StatusForbidden, nil
}

// Fire the before trigger.
if err := c.Runner("before_delete", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

// Remove the file or folder.
err := c.User.FileSystem.RemoveAll(r.URL.Path)
if err != nil {
return ErrorToHTTP(err, true), err
}

// Fire the after trigger.
if err := c.Runner("after_delete", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

func resourcePostPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.AllowNew && r.Method == http.MethodPost {
return http.StatusForbidden, nil
}

if !c.User.AllowEdit && r.Method == http.MethodPut {
return http.StatusForbidden, nil
}

// Discard any invalid upload before returning to avoid connection
// reset error.
defer func() {
io.Copy(ioutil.Discard, r.Body)
}()

// Checks if the current request is for a directory and not a file.
if strings.HasSuffix(r.URL.Path, "/") {
// If the method is PUT, we return 405 Method not Allowed, because
// POST should be used instead.
if r.Method == http.MethodPut {
return http.StatusMethodNotAllowed, nil
}

// Otherwise we try to create the directory.
err := c.User.FileSystem.Mkdir(r.URL.Path, 0776)
return ErrorToHTTP(err, false), err
}

// If using POST method, we are trying to create a new file so it is not
// desirable to override an already existent file. Thus, we check
// if the file already exists. If so, we just return a 409 Conflict.
if r.Method == http.MethodPost && r.Header.Get("Action") != "override" {
if _, err := c.User.FileSystem.Stat(r.URL.Path); err == nil {
return http.StatusConflict, errors.New("There is already a file on that path")
}
}

// Fire the before trigger.
if err := c.Runner("before_upload", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

// Create/Open the file.
f, err := c.User.FileSystem.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0776)
if err != nil {
return ErrorToHTTP(err, false), err
}
defer f.Close()

// Copies the new content for the file.
_, err = io.Copy(f, r.Body)
if err != nil {
return ErrorToHTTP(err, false), err
}

// Gets the info about the file.
fi, err := f.Stat()
if err != nil {
return ErrorToHTTP(err, false), err
}

// Check if this instance has a Static Generator and handles publishing
// or scheduling if it's the case.
if c.StaticGen != nil {
code, err := resourcePublishSchedule(c, w, r)
if code != 0 {
return code, err
}
}

// Writes the ETag Header.
etag := fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size())
w.Header().Set("ETag", etag)

// Fire the after trigger.
if err := c.Runner("after_upload", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

func resourcePublishSchedule(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
publish := r.Header.Get("Publish")
schedule := r.Header.Get("Schedule")

if publish != "true" && schedule == "" {
return 0, nil
}

if !c.User.AllowPublish {
return http.StatusForbidden, nil
}

if publish == "true" {
return resourcePublish(c, w, r)
}

t, err := time.Parse("2006-01-02T15:04", schedule)
if err != nil {
return http.StatusInternalServerError, err
}

c.Cron.AddFunc(t.Format("05 04 15 02 01 *"), func() {
_, err := resourcePublish(c, w, r)
if err != nil {
log.Print(err)
}
})

return http.StatusOK, nil
}

func resourcePublish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)

// Before save command handler.
if err := c.Runner("before_publish", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

code, err := c.StaticGen.Publish(c, w, r)
if err != nil {
return code, err
}

// Executed the before publish command.
if err := c.Runner("before_publish", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}

return code, nil
}

// resourcePatchHandler is the entry point for resource handler.
func resourcePatchHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.AllowEdit {
return http.StatusForbidden, nil
}

dst := r.Header.Get("Destination")
action := r.Header.Get("Action")
dst, err := url.QueryUnescape(dst)
if err != nil {
return ErrorToHTTP(err, true), err
}

src := r.URL.Path

if dst == "/" || src == "/" {
return http.StatusForbidden, nil
}

if action == "copy" {
// Fire the after trigger.
if err := c.Runner("before_copy", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}

// Copy the file.
err = c.User.FileSystem.Copy(src, dst)

// Fire the after trigger.
if err := c.Runner("after_copy", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
} else {
// Fire the after trigger.
if err := c.Runner("before_rename", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}

// Rename the file.
err = c.User.FileSystem.Rename(src, dst)

// Fire the after trigger.
if err := c.Runner("after_rename", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
}

return ErrorToHTTP(err, true), err
}

// handleSortOrder gets and stores for a Listing the 'sort' and 'order',
// and reads 'limit' if given. The latter is 0 if not given. Sets cookies.
func handleSortOrder(w http.ResponseWriter, r *http.Request, scope string) (sort string, order string, err error) {
sort = r.URL.Query().Get("sort")
order = r.URL.Query().Get("order")

// If the query 'sort' or 'order' is empty, use defaults or any values
// previously saved in Cookies.
switch sort {
case "":
sort = "name"
if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil {
sort = sortCookie.Value
}
case "name", "size":
http.SetCookie(w, &http.Cookie{
Name: "sort",
Value: sort,
MaxAge: 31536000,
Path: scope,
Secure: r.TLS != nil,
})
}

switch order {
case "":
order = "asc"
if orderCookie, orderErr := r.Cookie("order"); orderErr == nil {
order = orderCookie.Value
}
case "asc", "desc":
http.SetCookie(w, &http.Cookie{
Name: "order",
Value: order,
MaxAge: 31536000,
Path: scope,
Secure: r.TLS != nil,
})
}

return
}
146 changes: 146 additions & 0 deletions http/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package http

import (
"bytes"
"encoding/json"
"net/http"
"reflect"

fb "github.com/filebrowser/filebrowser"
"github.com/mitchellh/mapstructure"
)

type modifySettingsRequest struct {
modifyRequest
Data struct {
CSS string `json:"css"`
Commands map[string][]string `json:"commands"`
StaticGen map[string]interface{} `json:"staticGen"`
} `json:"data"`
}

type option struct {
Variable string `json:"variable"`
Name string `json:"name"`
Value interface{} `json:"value"`
}

func parsePutSettingsRequest(r *http.Request) (*modifySettingsRequest, error) {
// Checks if the request body is empty.
if r.Body == nil {
return nil, fb.ErrEmptyRequest
}

// Parses the request body and checks if it's well formed.
mod := &modifySettingsRequest{}
err := json.NewDecoder(r.Body).Decode(mod)
if err != nil {
return nil, err
}

// Checks if the request type is right.
if mod.What != "settings" {
return nil, fb.ErrWrongDataType
}

return mod, nil
}

func settingsHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "" && r.URL.Path != "/" {
return http.StatusNotFound, nil
}

switch r.Method {
case http.MethodGet:
return settingsGetHandler(c, w, r)
case http.MethodPut:
return settingsPutHandler(c, w, r)
}

return http.StatusMethodNotAllowed, nil
}

type settingsGetRequest struct {
CSS string `json:"css"`
Commands map[string][]string `json:"commands"`
StaticGen []option `json:"staticGen"`
}

func settingsGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.Admin {
return http.StatusForbidden, nil
}

result := &settingsGetRequest{
Commands: c.Commands,
StaticGen: []option{},
CSS: c.CSS,
}

if c.StaticGen != nil {
t := reflect.TypeOf(c.StaticGen).Elem()

for i := 0; i < t.NumField(); i++ {
if t.Field(i).Name[0] == bytes.ToLower([]byte{t.Field(i).Name[0]})[0] {
continue
}

result.StaticGen = append(result.StaticGen, option{
Variable: t.Field(i).Name,
Name: t.Field(i).Tag.Get("name"),
Value: reflect.ValueOf(c.StaticGen).Elem().FieldByName(t.Field(i).Name).Interface(),
})
}
}

return renderJSON(w, result)
}

func settingsPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.Admin {
return http.StatusForbidden, nil
}

mod, err := parsePutSettingsRequest(r)
if err != nil {
return http.StatusBadRequest, err
}

// Update the commands.
if mod.Which == "commands" {
if err := c.Store.Config.Save("commands", mod.Data.Commands); err != nil {
return http.StatusInternalServerError, err
}

c.Commands = mod.Data.Commands
return http.StatusOK, nil
}

// Update the global CSS.
if mod.Which == "css" {
if err := c.Store.Config.Save("css", mod.Data.CSS); err != nil {
return http.StatusInternalServerError, err
}

c.CSS = mod.Data.CSS
return http.StatusOK, nil
}

// Update the static generator options.
if mod.Which == "staticGen" {
err = mapstructure.Decode(mod.Data.StaticGen, c.StaticGen)
if err != nil {
return http.StatusInternalServerError, err
}

err = c.Store.Config.Save("staticgen_"+c.StaticGen.Name(), c.StaticGen)
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

return http.StatusMethodNotAllowed, nil
}
127 changes: 127 additions & 0 deletions http/share.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package http

import (
"encoding/base64"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"

fb "github.com/filebrowser/filebrowser"
)

func shareHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = sanitizeURL(r.URL.Path)

switch r.Method {
case http.MethodGet:
return shareGetHandler(c, w, r)
case http.MethodDelete:
return shareDeleteHandler(c, w, r)
case http.MethodPost:
return sharePostHandler(c, w, r)
}

return http.StatusNotImplemented, nil
}

func shareGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)
s, err := c.Store.Share.GetByPath(path)
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}

if err != nil {
return http.StatusInternalServerError, err
}

for i, link := range s {
if link.Expires && link.ExpireDate.Before(time.Now()) {
c.Store.Share.Delete(link.Hash)
s = append(s[:i], s[i+1:]...)
}
}

if len(s) == 0 {
return http.StatusNotFound, nil
}

return renderJSON(w, s)
}

func sharePostHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)

var s *fb.ShareLink
expire := r.URL.Query().Get("expires")
unit := r.URL.Query().Get("unit")

if expire == "" {
var err error
s, err = c.Store.Share.GetPermanent(path)
if err == nil {
w.Write([]byte(c.RootURL() + "/share/" + s.Hash))
return 0, nil
}
}

bytes, err := fb.GenerateRandomBytes(6)
if err != nil {
return http.StatusInternalServerError, err
}

str := base64.URLEncoding.EncodeToString(bytes)

s = &fb.ShareLink{
Path: path,
Hash: str,
Expires: expire != "",
}

if expire != "" {
num, err := strconv.Atoi(expire)
if err != nil {
return http.StatusInternalServerError, err
}

var add time.Duration
switch unit {
case "seconds":
add = time.Second * time.Duration(num)
case "minutes":
add = time.Minute * time.Duration(num)
case "days":
add = time.Hour * 24 * time.Duration(num)
default:
add = time.Hour * time.Duration(num)
}

s.ExpireDate = time.Now().Add(add)
}

if err := c.Store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}

return renderJSON(w, s)
}

func shareDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
s, err := c.Store.Share.Get(strings.TrimPrefix(r.URL.Path, "/"))
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}

if err != nil {
return http.StatusInternalServerError, err
}

err = c.Store.Share.Delete(s.Hash)
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}
383 changes: 383 additions & 0 deletions http/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,383 @@
package http

import (
"encoding/json"
"errors"
"net/http"
"os"
"sort"
"strconv"
"strings"

fb "github.com/filebrowser/filebrowser"
)

type modifyRequest struct {
What string `json:"what"` // Answer to: what data type?
Which string `json:"which"` // Answer to: which field?
}

type modifyUserRequest struct {
modifyRequest
Data *fb.User `json:"data"`
}

// usersHandler is the entry point of the users API. It's just a router
// to send the request to its
func usersHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If the user isn't admin and isn't making a PUT
// request, then return forbidden.
if !c.User.Admin && r.Method != http.MethodPut {
return http.StatusForbidden, nil
}

switch r.Method {
case http.MethodGet:
return usersGetHandler(c, w, r)
case http.MethodPost:
return usersPostHandler(c, w, r)
case http.MethodDelete:
return usersDeleteHandler(c, w, r)
case http.MethodPut:
return usersPutHandler(c, w, r)
}

return http.StatusNotImplemented, nil
}

// getUserID returns the id from the user which is present
// in the request url. If the url is invalid and doesn't
// contain a valid ID, it returns an fb.Error.
func getUserID(r *http.Request) (int, error) {
// Obtains the ID in string from the URL and converts
// it into an integer.
sid := strings.TrimPrefix(r.URL.Path, "/")
sid = strings.TrimSuffix(sid, "/")
id, err := strconv.Atoi(sid)
if err != nil {
return http.StatusBadRequest, err
}

return id, nil
}

// getUser returns the user which is present in the request
// body. If the body is empty or the JSON is invalid, it
// returns an fb.Error.
func getUser(c *fb.Context, r *http.Request) (*fb.User, string, error) {
// Checks if the request body is empty.
if r.Body == nil {
return nil, "", fb.ErrEmptyRequest
}

// Parses the request body and checks if it's well formed.
mod := &modifyUserRequest{}
err := json.NewDecoder(r.Body).Decode(mod)
if err != nil {
return nil, "", err
}

// Checks if the request type is right.
if mod.What != "user" {
return nil, "", fb.ErrWrongDataType
}

mod.Data.FileSystem = c.NewFS(mod.Data.Scope)
return mod.Data, mod.Which, nil
}

func usersGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Request for the default user data.
if r.URL.Path == "/base" {
return renderJSON(w, c.DefaultUser)
}

// Request for the listing of users.
if r.URL.Path == "/" {
users, err := c.Store.Users.Gets(c.NewFS)
if err != nil {
return http.StatusInternalServerError, err
}

for _, u := range users {
// Removes the user password so it won't
// be sent to the front-end.
u.Password = ""
}

sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
})

return renderJSON(w, users)
}

id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}

u, err := c.Store.Users.Get(id, c.NewFS)
if err == fb.ErrExist {
return http.StatusNotFound, err
}

if err != nil {
return http.StatusInternalServerError, err
}

u.Password = ""
return renderJSON(w, u)
}

func usersPostHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "/" {
return http.StatusMethodNotAllowed, nil
}

u, _, err := getUser(c, r)
if err != nil {
return http.StatusBadRequest, err
}

// Checks if username isn't empty.
if u.Username == "" {
return http.StatusBadRequest, fb.ErrEmptyUsername
}

// Checks if scope isn't empty.
if u.Scope == "" {
return http.StatusBadRequest, fb.ErrEmptyScope
}

// Checks if password isn't empty.
if u.Password == "" {
return http.StatusBadRequest, fb.ErrEmptyPassword
}

// Initialize rules if they're not initialized.
if u.Rules == nil {
u.Rules = []*fb.Rule{}
}

// If the view mode is empty, initialize with the default one.
if u.ViewMode == "" {
u.ViewMode = c.DefaultUser.ViewMode
}

// Initialize commands if not initialized.
if u.Commands == nil {
u.Commands = []string{}
}

// It's a new user so the ID will be auto created.
if u.ID != 0 {
u.ID = 0
}

// Checks if the scope exists.
if code, err := checkFS(u.Scope); err != nil {
return code, err
}

// Hashes the password.
pw, err := fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}

u.Password = pw
u.ViewMode = fb.MosaicViewMode

// Saves the user to the database.
err = c.Store.Users.Save(u)
if err == fb.ErrExist {
return http.StatusConflict, err
}

if err != nil {
return http.StatusInternalServerError, err
}

// Set the Location header and return.
w.Header().Set("Location", "/settings/users/"+strconv.Itoa(u.ID))
w.WriteHeader(http.StatusCreated)
return 0, nil
}

func checkFS(path string) (int, error) {
info, err := os.Stat(path)

if err != nil {
if !os.IsNotExist(err) {
return http.StatusInternalServerError, err
}

err = os.MkdirAll(path, 0666)
if err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}

if !info.IsDir() {
return http.StatusBadRequest, errors.New("Scope is not a dir")
}

return 0, nil
}

func usersDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path == "/" {
return http.StatusMethodNotAllowed, nil
}

id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}

// Deletes the user from the database.
err = c.Store.Users.Delete(id)
if err == fb.ErrNotExist {
return http.StatusNotFound, fb.ErrNotExist
}

if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

func usersPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// New users should be created on /api/users.
if r.URL.Path == "/" {
return http.StatusMethodNotAllowed, nil
}

// Gets the user ID from the URL and checks if it's valid.
id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}

// Checks if the user has permission to access this page.
if !c.User.Admin && id != c.User.ID {
return http.StatusForbidden, nil
}

// Gets the user from the request body.
u, which, err := getUser(c, r)
if err != nil {
return http.StatusBadRequest, err
}

// If we're updating the default user. Only for NoAuth
// implementations. Used to change the viewMode.
if id == 0 && c.NoAuth {
c.DefaultUser.ViewMode = u.ViewMode
return http.StatusOK, nil
}

// Updates the CSS and locale.
if which == "partial" {
c.User.CSS = u.CSS
c.User.Locale = u.Locale
c.User.ViewMode = u.ViewMode

err = c.Store.Users.Update(c.User, "CSS", "Locale", "ViewMode")
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

// Updates the Password.
if which == "password" {
if u.Password == "" {
return http.StatusBadRequest, fb.ErrEmptyPassword
}

if id == c.User.ID && c.User.LockPassword {
return http.StatusForbidden, nil
}

c.User.Password, err = fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}

err = c.Store.Users.Update(c.User, "Password")
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}

// If can only be all.
if which != "all" {
return http.StatusBadRequest, fb.ErrInvalidUpdateField
}

// Checks if username isn't empty.
if u.Username == "" {
return http.StatusBadRequest, fb.ErrEmptyUsername
}

// Checks if filesystem isn't empty.
if u.Scope == "" {
return http.StatusBadRequest, fb.ErrEmptyScope
}

// Checks if the scope exists.
if code, err := checkFS(u.Scope); err != nil {
return code, err
}

// Initialize rules if they're not initialized.
if u.Rules == nil {
u.Rules = []*fb.Rule{}
}

// Initialize commands if not initialized.
if u.Commands == nil {
u.Commands = []string{}
}

// Gets the current saved user from the in-memory map.
suser, err := c.Store.Users.Get(id, c.NewFS)
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}

if err != nil {
return http.StatusInternalServerError, err
}

u.ID = id

// Changes the password if the request wants it.
if u.Password != "" {
pw, err := fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}

u.Password = pw
} else {
u.Password = suser.Password
}

// Updates the whole User struct because we always are supposed
// to send a new entire object.
err = c.Store.Users.Update(u)
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusOK, nil
}
340 changes: 340 additions & 0 deletions http/websockets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
package http

import (
"bytes"
"encoding/json"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"

fb "github.com/filebrowser/filebrowser"
"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}

var (
cmdNotImplemented = []byte("Command not implemented.")
cmdNotAllowed = []byte("Command not allowed.")
)

// command handles the requests for VCS related commands: git, svn and mercurial
func command(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Upgrades the connection to a websocket and checks for fb.Errors.
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return 0, err
}
defer conn.Close()

var (
message []byte
command []string
)

// Starts an infinite loop until a valid command is captured.
for {
_, message, err = conn.ReadMessage()
if err != nil {
return http.StatusInternalServerError, err
}

command = strings.Split(string(message), " ")
if len(command) != 0 {
break
}
}

// Check if the command is allowed
allowed := false

for _, cmd := range c.User.Commands {
if regexp.MustCompile(cmd).MatchString(command[0]) {
allowed = true
break
}
}

if !allowed {
err = conn.WriteMessage(websocket.TextMessage, cmdNotAllowed)
if err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}

// Check if the program is installed on the computer.
if _, err = exec.LookPath(command[0]); err != nil {
err = conn.WriteMessage(websocket.TextMessage, cmdNotImplemented)
if err != nil {
return http.StatusInternalServerError, err
}

return http.StatusNotImplemented, nil
}

// Gets the path and initializes a buffer.
path := c.User.Scope + "/" + r.URL.Path
path = filepath.Clean(path)
buff := new(bytes.Buffer)

// Sets up the command executation.
cmd := exec.Command(command[0], command[1:]...)
cmd.Dir = path
cmd.Stderr = buff
cmd.Stdout = buff

// Starts the command and checks for fb.Errors.
err = cmd.Start()
if err != nil {
return http.StatusInternalServerError, err
}

// Set a 'done' variable to check whetever the command has already finished
// running or not. This verification is done using a goroutine that uses the
// method .Wait() from the command.
done := false
go func() {
err = cmd.Wait()
done = true
}()

// Function to print the current information on the buffer to the connection.
print := func() error {
by := buff.Bytes()
if len(by) > 0 {
err = conn.WriteMessage(websocket.TextMessage, by)
if err != nil {
return err
}
}

return nil
}

// While the command hasn't finished running, continue sending the output
// to the client in intervals of 100 milliseconds.
for !done {
if err = print(); err != nil {
return http.StatusInternalServerError, err
}

time.Sleep(100 * time.Millisecond)
}

// After the command is done executing, send the output one more time to the
// browser to make sure it gets the latest information.
if err = print(); err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}

var (
typeRegexp = regexp.MustCompile(`type:(\w+)`)
)

type condition func(path string) bool

type searchOptions struct {
CaseInsensitive bool
Conditions []condition
Terms []string
}

func extensionCondition(extension string) condition {
return func(path string) bool {
return filepath.Ext(path) == "."+extension
}
}

func imageCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)

return strings.HasPrefix(mimetype, "image")
}

func audioCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)

return strings.HasPrefix(mimetype, "audio")
}

func videoCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)

return strings.HasPrefix(mimetype, "video")
}

func parseSearch(value string) *searchOptions {
opts := &searchOptions{
CaseInsensitive: strings.Contains(value, "case:insensitive"),
Conditions: []condition{},
Terms: []string{},
}

// removes the options from the value
value = strings.Replace(value, "case:insensitive", "", -1)
value = strings.Replace(value, "case:sensitive", "", -1)
value = strings.TrimSpace(value)

types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, t := range types {
if len(t) == 1 {
continue
}

switch t[1] {
case "image":
opts.Conditions = append(opts.Conditions, imageCondition)
case "audio", "music":
opts.Conditions = append(opts.Conditions, audioCondition)
case "video":
opts.Conditions = append(opts.Conditions, videoCondition)
default:
opts.Conditions = append(opts.Conditions, extensionCondition(t[1]))
}
}

if len(types) > 0 {
// Remove the fields from the search value.
value = typeRegexp.ReplaceAllString(value, "")
}

// If it's canse insensitive, put everything in lowercase.
if opts.CaseInsensitive {
value = strings.ToLower(value)
}

// Remove the spaces from the search value.
value = strings.TrimSpace(value)

if value == "" {
return opts
}

// if the value starts with " and finishes what that character, we will
// only search for that term
if value[0] == '"' && value[len(value)-1] == '"' {
unique := strings.TrimPrefix(value, "\"")
unique = strings.TrimSuffix(unique, "\"")

opts.Terms = []string{unique}
return opts
}

opts.Terms = strings.Split(value, " ")
return opts
}

// search searches for a file or directory.
func search(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Upgrades the connection to a websocket and checks for fb.Errors.
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return 0, err
}
defer conn.Close()

var (
value string
search *searchOptions
message []byte
)

// Starts an infinite loop until a valid command is captured.
for {
_, message, err = conn.ReadMessage()
if err != nil {
return http.StatusInternalServerError, err
}

if len(message) != 0 {
value = string(message)
break
}
}

search = parseSearch(value)
scope := strings.TrimPrefix(r.URL.Path, "/")
scope = "/" + scope
scope = c.User.Scope + scope
scope = strings.Replace(scope, "\\", "/", -1)
scope = filepath.Clean(scope)

err = filepath.Walk(scope, func(path string, f os.FileInfo, err error) error {
if search.CaseInsensitive {
path = strings.ToLower(path)
}

path = strings.TrimPrefix(path, scope)
path = strings.TrimPrefix(path, "/")
path = strings.Replace(path, "\\", "/", -1)

// Only execute if there are conditions to meet.
if len(search.Conditions) > 0 {
match := false

for _, t := range search.Conditions {
if t(path) {
match = true
break
}
}

// If doesn't meet the condition, go to the next.
if !match {
return nil
}
}

if len(search.Terms) > 0 {
is := false

// Checks if matches the terms and if it is allowed.
for _, term := range search.Terms {
if is {
break
}

if strings.Contains(path, term) {
if !c.User.Allowed(path) {
return nil
}

is = true
}
}

if !is {
return nil
}
}

response, _ := json.Marshal(map[string]interface{}{
"dir": f.IsDir(),
"path": path,
})

return conn.WriteMessage(websocket.TextMessage, response)
})

if err != nil {
return http.StatusInternalServerError, err
}

return 0, nil
}
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "filebrowser",
"author": "File Browser contributors",
"private": true,
"dependencies": {
"filebrowser-frontend": "1.4.0"
}
}
21 changes: 21 additions & 0 deletions publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
set -e

echo "Building assets"
./build.sh

echo "Updating version number to $1..."
sed -i "s|(untracked)|$1|g" filebrowser.go
git add -A
git commit -m "chore: version $1"
git tag "v$1"
git push
git push --tags

echo "Commiting untracked version notice..."
sed -i "s|$1|(untracked)|g" filebrowser.go
git add -A
git commit -m "chore: setting untracked version [ci skip]"
git push

echo "Done!"
259 changes: 259 additions & 0 deletions rice-box.go

Large diffs are not rendered by default.

195 changes: 195 additions & 0 deletions staticgen/hugo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package staticgen

import (
"errors"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"

fb "github.com/filebrowser/filebrowser"
"github.com/hacdias/varutils"
)

var (
errUnsupportedFileType = errors.New("The type of the provided file isn't supported for this action")
)

// Hugo is the Hugo static website generator.
type Hugo struct {
// Website root
Root string `name:"Website Root"`
// Public folder
Public string `name:"Public Directory"`
// Hugo executable path
Exe string `name:"Hugo Executable"`
// Hugo arguments
Args []string `name:"Hugo Arguments"`
// Indicates if we should clean public before a new publish.
CleanPublic bool `name:"Clean Public"`
// previewPath is the temporary path for a preview
previewPath string
}

// SettingsPath retrieves the correct settings path.
func (h Hugo) SettingsPath() string {
var frontmatter string
var err error

if _, err = os.Stat(filepath.Join(h.Root, "config.yaml")); err == nil {
frontmatter = "yaml"
}

if _, err = os.Stat(filepath.Join(h.Root, "config.json")); err == nil {
frontmatter = "json"
}

if _, err = os.Stat(filepath.Join(h.Root, "config.toml")); err == nil {
frontmatter = "toml"
}

if frontmatter == "" {
return "/settings"
}

return "/config." + frontmatter
}

// Name is the plugin's name.
func (h Hugo) Name() string {
return "hugo"
}

// Hook is the pre-api handler.
func (h Hugo) Hook(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If we are not using HTTP Post, we shall return Method Not Allowed
// since we are only working with this method.
if r.Method != http.MethodPost {
return 0, nil
}

if c.Router != "resource" {
return 0, nil
}

// We only care about creating new files from archetypes here. So...
if r.Header.Get("Archetype") == "" {
return 0, nil
}

if !c.User.AllowNew {
return http.StatusForbidden, nil
}

filename := filepath.Clean(r.URL.Path)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
archetype := r.Header.Get("archetype")

ext := filepath.Ext(filename)

// If the request isn't for a markdown file, we can't
// handle it.
if ext != ".markdown" && ext != ".md" {
return http.StatusBadRequest, errUnsupportedFileType
}

// Tries to create a new file based on this archetype.
args := []string{"new", filename, "--kind", archetype}
if err := runCommand(h.Exe, args, h.Root); err != nil {
return http.StatusInternalServerError, err
}

// Writes the location of the new file to the Header.
w.Header().Set("Location", "/files/content/"+filename)
return http.StatusCreated, nil
}

// Publish publishes a post.
func (h Hugo) Publish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
filename := filepath.Join(c.User.Scope, r.URL.Path)

// We only run undraft command if it is a file.
if strings.HasSuffix(filename, ".md") && strings.HasSuffix(filename, ".markdown") {
if err := h.undraft(filename); err != nil {
return http.StatusInternalServerError, err
}
}

// Regenerates the file
h.run(false)

return 0, nil
}

// Preview handles the preview path.
func (h *Hugo) Preview(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Get a new temporary path if there is none.
if h.previewPath == "" {
path, err := ioutil.TempDir("", "")
if err != nil {
return http.StatusInternalServerError, err
}

h.previewPath = path
}

// Build the arguments to execute Hugo: change the base URL,
// build the drafts and update the destination.
args := h.Args
args = append(args, "--baseURL", c.RootURL()+"/preview/")
args = append(args, "--buildDrafts")
args = append(args, "--destination", h.previewPath)

// Builds the preview.
if err := runCommand(h.Exe, args, h.Root); err != nil {
return http.StatusInternalServerError, err
}

// Serves the temporary path with the preview.
http.FileServer(http.Dir(h.previewPath)).ServeHTTP(w, r)
return 0, nil
}

func (h Hugo) run(force bool) {
// If the CleanPublic option is enabled, clean it.
if h.CleanPublic {
os.RemoveAll(h.Public)
}

// Prevent running if watching is enabled
if b, pos := varutils.StringInSlice("--watch", h.Args); b && !force {
if len(h.Args) > pos && h.Args[pos+1] != "false" {
return
}

if len(h.Args) == pos+1 {
return
}
}

if err := runCommand(h.Exe, h.Args, h.Root); err != nil {
log.Println(err)
}
}

func (h Hugo) undraft(file string) error {
args := []string{"undraft", file}
if err := runCommand(h.Exe, args, h.Root); err != nil && !strings.Contains(err.Error(), "not a Draft") {
return err
}

return nil
}

// Setup sets up the plugin.
func (h *Hugo) Setup() error {
var err error
if h.Exe, err = exec.LookPath("hugo"); err != nil {
return err
}

return nil
}
125 changes: 125 additions & 0 deletions staticgen/jekyll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package staticgen

import (
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"

fb "github.com/filebrowser/filebrowser"
)

// Jekyll is the Jekyll static website generator.
type Jekyll struct {
// Website root
Root string `name:"Website Root"`
// Public folder
Public string `name:"Public Directory"`
// Jekyll executable path
Exe string `name:"Executable"`
// Jekyll arguments
Args []string `name:"Arguments"`
// Indicates if we should clean public before a new publish.
CleanPublic bool `name:"Clean Public"`
// previewPath is the temporary path for a preview
previewPath string
}

// Name is the plugin's name.
func (j Jekyll) Name() string {
return "jekyll"
}

// SettingsPath retrieves the correct settings path.
func (j Jekyll) SettingsPath() string {
return "/_config.yml"
}

// Hook is the pre-api handler.
func (j Jekyll) Hook(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
return 0, nil
}

// Publish publishes a post.
func (j Jekyll) Publish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
filename := filepath.Join(c.User.Scope, r.URL.Path)

// We only run undraft command if it is a file.
if err := j.undraft(filename); err != nil {
return http.StatusInternalServerError, err
}

// Regenerates the file
j.run()

return 0, nil
}

// Preview handles the preview path.
func (j *Jekyll) Preview(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Get a new temporary path if there is none.
if j.previewPath == "" {
path, err := ioutil.TempDir("", "")
if err != nil {
return http.StatusInternalServerError, err
}

j.previewPath = path
}

// Build the arguments to execute Hugo: change the base URL,
// build the drafts and update the destination.
args := j.Args
args = append(args, "--baseurl", c.RootURL()+"/preview/")
args = append(args, "--drafts")
args = append(args, "--destination", j.previewPath)

// Builds the preview.
if err := runCommand(j.Exe, args, j.Root); err != nil {
return http.StatusInternalServerError, err
}

// Serves the temporary path with the preview.
http.FileServer(http.Dir(j.previewPath)).ServeHTTP(w, r)
return 0, nil
}

func (j Jekyll) run() {
// If the CleanPublic option is enabled, clean it.
if j.CleanPublic {
os.RemoveAll(j.Public)
}

if err := runCommand(j.Exe, j.Args, j.Root); err != nil {
log.Println(err)
}
}

func (j Jekyll) undraft(file string) error {
if !strings.Contains(file, "_drafts") {
return nil
}

return os.Rename(file, strings.Replace(file, "_drafts", "_posts", 1))
}

// Setup sets up the plugin.
func (j *Jekyll) Setup() error {
var err error
if j.Exe, err = exec.LookPath("jekyll"); err != nil {
return err
}

if len(j.Args) == 0 {
j.Args = []string{"build"}
}

if j.Args[0] != "build" {
j.Args = append([]string{"build"}, j.Args...)
}

return nil
}
19 changes: 19 additions & 0 deletions staticgen/staticgen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package staticgen

import (
"errors"
"os/exec"
)

// runCommand executes an external command
func runCommand(command string, args []string, path string) error {
cmd := exec.Command(command, args...)
cmd.Dir = path
out, err := cmd.CombinedOutput()

if err != nil {
return errors.New(string(out))
}

return nil
}