diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..a2a58cff --- /dev/null +++ b/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + ["env", { "modules": false }], + "stage-2" + ], + "plugins": ["transform-runtime"], + "env": { + "test": { + "presets": ["env", "stage-2"], + "plugins": [ "istanbul" ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a34668e5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# 4 space indentation +[*.go] +indent_style = tab +indent_size = 4 \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..34af3774 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +build/*.js +config/*.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..67c085d6 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,27 @@ +// http://eslint.org/docs/user-guide/configuring + +module.exports = { + root: true, + parser: 'babel-eslint', + parserOptions: { + sourceType: 'module' + }, + env: { + browser: true, + }, + // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style + extends: 'standard', + // required to lint *.vue files + plugins: [ + 'html' + ], + // add your custom rules here + 'rules': { + // allow paren-less arrow functions + 'arrow-parens': 0, + // allow async-await + 'generator-star-spacing': 0, + // allow debugger during development + 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 + } +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..ad3cfc9a --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,24 @@ +### Instructions (remove before submitting): + +1. Are you asking for help with using Caddy or File Manager? 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 Manager 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 Manager'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? diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..18dca76a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules/ +*/dist/* +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..9a78774d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: go + +go: + - tip + +install: + - go get -u -v $(go list -f '{{join .Imports "\n"}}' ./... | sort | uniq | grep -v filemanager) + - go get -u -v github.com/mholt/caddy/caddyhttp + - go get github.com/gordonklaus/ineffassign + +script: + - sed -i 's/\_ \"github.com\/mholt\/caddy\/caddyhttp\"/\_ \"github.com\/mholt\/caddy\/caddyhttp\"\n\_ \"github.com\/hacdias\/filemanager\/caddy\/filemanager\"/g' $GOPATH/src/github.com/mholt/caddy/caddy/caddymain/run.go + - go build -o binary github.com/mholt/caddy/caddy + - go build github.com/hacdias/filemanager + - ineffassign . diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f0cc78e8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hacdias@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..5e0fd33c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ +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 {yyyy} {name of copyright owner} + +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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..0fde256c --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# filemanager + +[![Build](https://img.shields.io/travis/hacdias/filemanager.svg?style=flat-square)](https://travis-ci.org/hacdias/filemanager) +[![Go Report Card](https://goreportcard.com/badge/github.com/hacdias/filemanager?style=flat-square)](https://goreportcard.com/report/hacdias/filemanager) +[![Documentation](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/hacdias/filemanager) + + +## About Search + +FileManager 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 +``` \ No newline at end of file diff --git a/assets/build/build.js b/assets/build/build.js new file mode 100644 index 00000000..c7c0fbdb --- /dev/null +++ b/assets/build/build.js @@ -0,0 +1,31 @@ +require('./check-versions')() + +process.env.NODE_ENV = 'production' + +var ora = require('ora') +var rm = require('rimraf') +var path = require('path') +var chalk = require('chalk') +var webpack = require('webpack') +var config = require('./config') +var webpackConfig = require('./webpack.prod.conf') + +var spinner = ora('building for production...') +spinner.start() + +rm(path.join(config.assetsRoot, config.assetsSubDirectory), err => { + if (err) throw err + webpack(webpackConfig, function (err, stats) { + spinner.stop() + if (err) throw err + process.stdout.write(stats.toString({ + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false + }) + '\n\n') + + console.log(chalk.cyan(' Build complete.\n')) + }) +}) diff --git a/assets/build/check-versions.js b/assets/build/check-versions.js new file mode 100644 index 00000000..f9133e24 --- /dev/null +++ b/assets/build/check-versions.js @@ -0,0 +1,48 @@ +var chalk = require('chalk') +var semver = require('semver') +var packageConfig = require('../../package.json') +var shell = require('shelljs') +function exec (cmd) { + return require('child_process').execSync(cmd).toString().trim() +} + +var versionRequirements = [ + { + name: 'node', + currentVersion: semver.clean(process.version), + versionRequirement: packageConfig.engines.node + } +] + +if (shell.which('npm')) { + versionRequirements.push({ + name: 'npm', + currentVersion: exec('npm --version'), + versionRequirement: packageConfig.engines.npm + }) +} + +module.exports = function () { + var warnings = [] + for (var i = 0; i < versionRequirements.length; i++) { + var mod = versionRequirements[i] + if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { + warnings.push(mod.name + ': ' + + chalk.red(mod.currentVersion) + ' should be ' + + chalk.green(mod.versionRequirement) + ) + } + } + + if (warnings.length) { + console.log('') + console.log(chalk.yellow('To use this template, you must update following to modules:')) + console.log() + for (var i = 0; i < warnings.length; i++) { + var warning = warnings[i] + console.log(' ' + warning) + } + console.log() + process.exit(1) + } +} diff --git a/assets/build/config.js b/assets/build/config.js new file mode 100644 index 00000000..885ac29d --- /dev/null +++ b/assets/build/config.js @@ -0,0 +1,26 @@ +// see http://vuejs-templates.github.io/webpack for documentation. +var path = require('path') + +module.exports = { + index: path.resolve(__dirname, '../dist/index.html'), + assetsRoot: path.resolve(__dirname, '../dist'), + assetsSubDirectory: 'static', + assetsPublicPath: '{{ .BaseURL }}/', + build: { + env: { + NODE_ENV: '"production"' + }, + productionSourceMap: true, + // Run the build command with an extra argument to + // View the bundle analyzer report after build finishes: + // `npm run build --report` + // Set to `true` or `false` to always turn it on or off + bundleAnalyzerReport: process.env.npm_config_report + }, + dev: { + env: { + NODE_ENV: '"development"' + }, + produceSourceMap: true + } +} diff --git a/assets/build/dev.js b/assets/build/dev.js new file mode 100644 index 00000000..e00be9e7 --- /dev/null +++ b/assets/build/dev.js @@ -0,0 +1,26 @@ +require('./check-versions')() + +process.env.NODE_ENV = 'development' + +var rm = require('rimraf') +var path = require('path') +var chalk = require('chalk') +var webpack = require('webpack') +var config = require('./config') +var webpackConfig = require('./webpack.dev.conf') + +rm(path.join(config.assetsRoot, config.assetsSubDirectory), err => { + if (err) throw err + webpack(webpackConfig, function (err, stats) { + if (err) throw err + process.stdout.write(stats.toString({ + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false + }) + '\n\n') + + console.log(chalk.cyan(' Build complete.\n')) + }) +}) diff --git a/assets/build/service-worker-dev.js b/assets/build/service-worker-dev.js new file mode 100644 index 00000000..979e1962 --- /dev/null +++ b/assets/build/service-worker-dev.js @@ -0,0 +1,17 @@ +// This service worker file is effectively a 'no-op' that will reset any +// previous service worker registered for the same host:port combination. +// In the production build, this file is replaced with an actual service worker +// file that will precache your site's local assets. +// See https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432 + +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', () => { + self.clients.matchAll({ type: 'window' }).then(windowClients => { + for (let windowClient of windowClients) { + // Force open pages to refresh, so that they have a chance to load the + // fresh navigation response from the local dev server. + windowClient.navigate(windowClient.url); + } + }); +}); \ No newline at end of file diff --git a/assets/build/service-worker-prod.js b/assets/build/service-worker-prod.js new file mode 100644 index 00000000..1179ec20 --- /dev/null +++ b/assets/build/service-worker-prod.js @@ -0,0 +1,55 @@ +(function() { + 'use strict'; + + // Check to make sure service workers are supported in the current browser, + // and that the current page is accessed from a secure origin. Using a + // service worker from an insecure origin will trigger JS console errors. + const isLocalhost = Boolean(window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) + ); + + window.addEventListener('load', function() { + if ('serviceWorker' in navigator && + (window.location.protocol === 'https:' || isLocalhost)) { + navigator.serviceWorker.register('{{ .BaseURL }}/sw.js') + .then(function(registration) { + // updatefound is fired if service-worker.js changes. + registration.onupdatefound = function() { + // updatefound is also fired the very first time the SW is installed, + // and there's no need to prompt for a reload at that point. + // So check here to see if the page is already controlled, + // i.e. whether there's an existing service worker. + if (navigator.serviceWorker.controller) { + // The updatefound event implies that registration.installing is set + const installingWorker = registration.installing; + + installingWorker.onstatechange = function() { + switch (installingWorker.state) { + case 'installed': + // At this point, the old content will have been purged and the + // fresh content will have been added to the cache. + // It's the perfect time to display a "New content is + // available; please refresh." message in the page's interface. + break; + + case 'redundant': + throw new Error('The installing ' + + 'service worker became redundant.'); + + default: + // Ignore + } + }; + } + }; + }).catch(function(e) { + console.error('Error during service worker registration:', e); + }); + } + }); +})(); diff --git a/assets/build/utils.js b/assets/build/utils.js new file mode 100644 index 00000000..616ceb3a --- /dev/null +++ b/assets/build/utils.js @@ -0,0 +1,70 @@ +var path = require('path') +var config = require('./config') +var ExtractTextPlugin = require('extract-text-webpack-plugin') + +exports.assetsPath = function (_path) { + var assetsSubDirectory = config.assetsSubDirectory + + return path.posix.join(assetsSubDirectory, _path) +} + +exports.cssLoaders = function (options) { + options = options || {} + + var cssLoader = { + loader: 'css-loader', + options: { + minimize: process.env.NODE_ENV === 'production', + sourceMap: options.sourceMap + } + } + + // generate loader string to be used with extract text plugin + function generateLoaders (loader, loaderOptions) { + var loaders = [cssLoader] + if (loader) { + loaders.push({ + loader: loader + '-loader', + options: Object.assign({}, loaderOptions, { + sourceMap: options.sourceMap + }) + }) + } + + // Extract CSS when that option is specified + // (which is the case during production build) + if (options.extract) { + return ExtractTextPlugin.extract({ + use: loaders, + fallback: 'vue-style-loader' + }) + } else { + return ['vue-style-loader'].concat(loaders) + } + } + + // https://vue-loader.vuejs.org/en/configurations/extract-css.html + return { + css: generateLoaders(), + postcss: generateLoaders(), + less: generateLoaders('less'), + sass: generateLoaders('sass', { indentedSyntax: true }), + scss: generateLoaders('sass'), + stylus: generateLoaders('stylus'), + styl: generateLoaders('stylus') + } +} + +// Generate loaders for standalone style files (outside of .vue) +exports.styleLoaders = function (options) { + var output = [] + var loaders = exports.cssLoaders(options) + for (var extension in loaders) { + var loader = loaders[extension] + output.push({ + test: new RegExp('\\.' + extension + '$'), + use: loader + }) + } + return output +} diff --git a/assets/build/vue-loader.conf.js b/assets/build/vue-loader.conf.js new file mode 100644 index 00000000..a17529fc --- /dev/null +++ b/assets/build/vue-loader.conf.js @@ -0,0 +1,12 @@ +var utils = require('./utils') +var config = require('./config') +var isProduction = process.env.NODE_ENV === 'production' + +module.exports = { + loaders: utils.cssLoaders({ + sourceMap: isProduction + ? config.build.productionSourceMap + : config.dev.produceSourceMap, + extract: isProduction + }) +} diff --git a/assets/build/webpack.base.conf.js b/assets/build/webpack.base.conf.js new file mode 100644 index 00000000..5016698b --- /dev/null +++ b/assets/build/webpack.base.conf.js @@ -0,0 +1,65 @@ +var path = require('path') +var utils = require('./utils') +var config = require('./config') +var vueLoaderConfig = require('./vue-loader.conf') + +function resolve (dir) { + return path.join(__dirname, '..', dir) +} + +module.exports = { + entry: { + app: './assets/src/main.js' + }, + output: { + path: config.assetsRoot, + filename: '[name].js', + publicPath: config.assetsPublicPath + }, + resolve: { + extensions: ['.js', '.vue', '.json'], + alias: { + 'vue$': 'vue/dist/vue.esm.js', + '@': resolve('src') + } + }, + module: { + rules: [ + { + test: /\.(js|vue)$/, + loader: 'eslint-loader', + enforce: 'pre', + include: [resolve('src'), resolve('test')], + options: { + formatter: require('eslint-friendly-formatter') + } + }, + { + test: /\.vue$/, + loader: 'vue-loader', + options: vueLoaderConfig + }, + { + test: /\.js$/, + loader: 'babel-loader', + include: [resolve('src'), resolve('test')] + }, + { + test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, + loader: 'url-loader', + options: { + limit: 10000, + name: utils.assetsPath('img/[name].[hash:7].[ext]') + } + }, + { + test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, + loader: 'url-loader', + options: { + // limit: 10000, + name: utils.assetsPath('fonts/[name].[hash:7].[ext]') + } + } + ] + } +} diff --git a/assets/build/webpack.dev.conf.js b/assets/build/webpack.dev.conf.js new file mode 100644 index 00000000..dd75ac4e --- /dev/null +++ b/assets/build/webpack.dev.conf.js @@ -0,0 +1,81 @@ +var fs = require('fs') +var path = require('path') +var utils = require('./utils') +var webpack = require('webpack') +var config = require('./config') +var merge = require('webpack-merge') +var baseWebpackConfig = require('./webpack.base.conf') +var HtmlWebpackPlugin = require('html-webpack-plugin') +var ExtractTextPlugin = require('extract-text-webpack-plugin') +var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') +var CopyWebpackPlugin = require('copy-webpack-plugin') + +module.exports = merge(baseWebpackConfig, { + watch: true, + module: { + rules: utils.styleLoaders({ + sourceMap: config.dev.produceSourceMap, + extract: true + }) + }, + devtool: '#cheap-module-eval-source-map', + output: { + path: config.assetsRoot, + filename: utils.assetsPath('js/[name].[chunkhash].js'), + chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') + }, + plugins: [ + new webpack.NoEmitOnErrorsPlugin(), + new FriendlyErrorsPlugin(), + new webpack.DefinePlugin({ + 'process.env': config.dev.env + }), + // extract css into its own file + new ExtractTextPlugin({ + filename: utils.assetsPath('css/[name].[contenthash].css') + }), + // generate dist index.html with correct asset hash for caching. + // you can customize output by editing /index.html + // see https://github.com/ampedandwired/html-webpack-plugin + new HtmlWebpackPlugin({ + filename: config.index, + template: 'assets/index.html', + inject: true, + // necessary to consistently work with multiple chunks via CommonsChunkPlugin + chunksSortMode: 'dependency', + serviceWorkerLoader: `` + }), + // split vendor js into its own file + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + minChunks: function (module, count) { + // any required modules inside node_modules are extracted to vendor + return ( + module.resource && + /\.js$/.test(module.resource) && + module.resource.indexOf( + path.join(__dirname, '../../node_modules') + ) === 0 + ) + } + }), + // extract webpack runtime and module manifest to its own file in order to + // prevent vendor hash from being updated whenever app bundle is updated + new webpack.optimize.CommonsChunkPlugin({ + name: 'manifest', + chunks: ['vendor'] + }), + new CopyWebpackPlugin([ + { + from: path.resolve(__dirname, '../static'), + to: config.assetsSubDirectory, + ignore: ['.*'] + }, + { + from: path.resolve(__dirname, '../../node_modules/codemirror/mode/*/*'), + to: path.join(config.assetsSubDirectory, 'js/codemirror/mode/[name]/[name].js') + } + ]) + ] +}) diff --git a/assets/build/webpack.prod.conf.js b/assets/build/webpack.prod.conf.js new file mode 100644 index 00000000..7912ad3f --- /dev/null +++ b/assets/build/webpack.prod.conf.js @@ -0,0 +1,127 @@ +var fs = require('fs') +var path = require('path') +var utils = require('./utils') +var webpack = require('webpack') +var config = require('./config') +var merge = require('webpack-merge') +var baseWebpackConfig = require('./webpack.base.conf') +var CopyWebpackPlugin = require('copy-webpack-plugin') +var HtmlWebpackPlugin = require('html-webpack-plugin') +var ExtractTextPlugin = require('extract-text-webpack-plugin') +var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') +var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin') +var UglifyJS = require('uglify-js') + +var env = config.build.env + +var webpackConfig = merge(baseWebpackConfig, { + module: { + rules: utils.styleLoaders({ + sourceMap: config.build.productionSourceMap, + extract: true + }) + }, + devtool: config.build.productionSourceMap ? '#source-map' : false, + output: { + path: config.assetsRoot, + filename: utils.assetsPath('js/[name].[chunkhash].js'), + chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') + }, + plugins: [ + new CopyWebpackPlugin([ + { + from: path.resolve(__dirname, '../static'), + to: config.assetsSubDirectory, + ignore: ['.*'] + }, + { + from: path.resolve(__dirname, '../../node_modules/codemirror/mode/*/*'), + to: path.join(config.assetsSubDirectory, 'js/codemirror/mode/[name]/[name].js'), + transform: function (source, path) { + let result = UglifyJS.minify(source.toString('utf8')) + if (result.error !== undefined) { + return source + } + return result.code + } + } + ]), + // http://vuejs.github.io/vue-loader/en/workflow/production.html + new webpack.DefinePlugin({ + 'process.env': env + }), + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false + }, + sourceMap: true + }), + // extract css into its own file + new ExtractTextPlugin({ + filename: utils.assetsPath('css/[name].[contenthash].css') + }), + // Compress extracted CSS. We are using this plugin so that possible + // duplicated CSS from different components can be deduped. + new OptimizeCSSPlugin({ + cssProcessorOptions: { + safe: true + } + }), + // generate dist index.html with correct asset hash for caching. + // you can customize output by editing /index.html + // see https://github.com/ampedandwired/html-webpack-plugin + new HtmlWebpackPlugin({ + filename: config.index, + template: 'assets/index.html', + inject: true, + minify: { + removeComments: true, + collapseWhitespace: true, + removeAttributeQuotes: true, + minifyCSS: true + // more options: + // https://github.com/kangax/html-minifier#options-quick-reference + }, + // necessary to consistently work with multiple chunks via CommonsChunkPlugin + chunksSortMode: 'dependency', + serviceWorkerLoader: `` + }), + // split vendor js into its own file + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + minChunks: function (module, count) { + // any required modules inside node_modules are extracted to vendor + return ( + module.resource && + /\.js$/.test(module.resource) && + module.resource.indexOf( + path.join(__dirname, '../../node_modules') + ) === 0 + ) + } + }), + // extract webpack runtime and module manifest to its own file in order to + // prevent vendor hash from being updated whenever app bundle is updated + new webpack.optimize.CommonsChunkPlugin({ + name: 'manifest', + chunks: ['vendor'] + }), + // service worker caching + new SWPrecacheWebpackPlugin({ + cacheId: 'File Manager', + filename: 'sw.js', + replacePrefix: '{{ .BaseURL }}/', + staticFileGlobs: ['dist/**/*.{js,html,css}'], + minify: true, + stripPrefix: 'dist/' + }) + ] +}) + +if (config.build.bundleAnalyzerReport) { + var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin + webpackConfig.plugins.push(new BundleAnalyzerPlugin()) +} + +module.exports = webpackConfig diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 00000000..3306ffc2 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,108 @@ + + + + + + + + File Manager + + + + + + + + + + + + + + + + + <% for (var chunk of webpack.chunks) { + for (var file of chunk.files) { + if (file.match(/\.(js|css)$/)) { %> + <% }}} %> + + + + + + +
+ +
+
+
+
+
+
+
+ + <%= htmlWebpackPlugin.options.serviceWorkerLoader %> + + diff --git a/assets/src/App.vue b/assets/src/App.vue new file mode 100644 index 00000000..8baf0567 --- /dev/null +++ b/assets/src/App.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/assets/src/assets/fonts/material/icons.woff2 b/assets/src/assets/fonts/material/icons.woff2 new file mode 100644 index 00000000..05a8ab5d Binary files /dev/null and b/assets/src/assets/fonts/material/icons.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-cyrillic-ext.woff2 b/assets/src/assets/fonts/roboto/medium-cyrillic-ext.woff2 new file mode 100644 index 00000000..f63bc9a1 Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-cyrillic-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-cyrillic.woff2 b/assets/src/assets/fonts/roboto/medium-cyrillic.woff2 new file mode 100644 index 00000000..b3ca824d Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-cyrillic.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-greek-ext.woff2 b/assets/src/assets/fonts/roboto/medium-greek-ext.woff2 new file mode 100644 index 00000000..7e1a8078 Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-greek-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-greek.woff2 b/assets/src/assets/fonts/roboto/medium-greek.woff2 new file mode 100644 index 00000000..314cf3f8 Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-greek.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-latin-ext.woff2 b/assets/src/assets/fonts/roboto/medium-latin-ext.woff2 new file mode 100644 index 00000000..604b8935 Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-latin-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-latin.woff2 b/assets/src/assets/fonts/roboto/medium-latin.woff2 new file mode 100644 index 00000000..5f96609d Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-latin.woff2 differ diff --git a/assets/src/assets/fonts/roboto/medium-vietnamese.woff2 b/assets/src/assets/fonts/roboto/medium-vietnamese.woff2 new file mode 100644 index 00000000..d92b7125 Binary files /dev/null and b/assets/src/assets/fonts/roboto/medium-vietnamese.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-cyrillic-ext.woff2 b/assets/src/assets/fonts/roboto/normal-cyrillic-ext.woff2 new file mode 100644 index 00000000..e4546e49 Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-cyrillic-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-cyrillic.woff2 b/assets/src/assets/fonts/roboto/normal-cyrillic.woff2 new file mode 100644 index 00000000..d08397f7 Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-cyrillic.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-greek-ext.woff2 b/assets/src/assets/fonts/roboto/normal-greek-ext.woff2 new file mode 100644 index 00000000..ed0b13ca Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-greek-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-greek.woff2 b/assets/src/assets/fonts/roboto/normal-greek.woff2 new file mode 100644 index 00000000..f630772d Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-greek.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-latin-ext.woff2 b/assets/src/assets/fonts/roboto/normal-latin-ext.woff2 new file mode 100644 index 00000000..0c7aec28 Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-latin-ext.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-latin.woff2 b/assets/src/assets/fonts/roboto/normal-latin.woff2 new file mode 100644 index 00000000..120796bb Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-latin.woff2 differ diff --git a/assets/src/assets/fonts/roboto/normal-vietnamese.woff2 b/assets/src/assets/fonts/roboto/normal-vietnamese.woff2 new file mode 100644 index 00000000..7936b665 Binary files /dev/null and b/assets/src/assets/fonts/roboto/normal-vietnamese.woff2 differ diff --git a/assets/src/assets/logo.svg b/assets/src/assets/logo.svg new file mode 100644 index 00000000..62e6798a --- /dev/null +++ b/assets/src/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/src/components/Editor.vue b/assets/src/components/Editor.vue new file mode 100644 index 00000000..af789b1d --- /dev/null +++ b/assets/src/components/Editor.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/assets/src/components/Files.vue b/assets/src/components/Files.vue new file mode 100644 index 00000000..65309c65 --- /dev/null +++ b/assets/src/components/Files.vue @@ -0,0 +1,233 @@ + + + diff --git a/assets/src/components/GlobalSettings.vue b/assets/src/components/GlobalSettings.vue new file mode 100644 index 00000000..3ee1c82e --- /dev/null +++ b/assets/src/components/GlobalSettings.vue @@ -0,0 +1,179 @@ + + + diff --git a/assets/src/components/Header.vue b/assets/src/components/Header.vue new file mode 100644 index 00000000..aa30a5d5 --- /dev/null +++ b/assets/src/components/Header.vue @@ -0,0 +1,225 @@ + + + diff --git a/assets/src/components/Listing.vue b/assets/src/components/Listing.vue new file mode 100644 index 00000000..f6dfbab0 --- /dev/null +++ b/assets/src/components/Listing.vue @@ -0,0 +1,223 @@ + + + diff --git a/assets/src/components/ListingItem.vue b/assets/src/components/ListingItem.vue new file mode 100644 index 00000000..52f05444 --- /dev/null +++ b/assets/src/components/ListingItem.vue @@ -0,0 +1,139 @@ + + + diff --git a/assets/src/components/Login.vue b/assets/src/components/Login.vue new file mode 100644 index 00000000..62a642f8 --- /dev/null +++ b/assets/src/components/Login.vue @@ -0,0 +1,118 @@ + + + + + + diff --git a/assets/src/components/Main.vue b/assets/src/components/Main.vue new file mode 100644 index 00000000..b09a5598 --- /dev/null +++ b/assets/src/components/Main.vue @@ -0,0 +1,53 @@ + + + diff --git a/assets/src/components/Preview.vue b/assets/src/components/Preview.vue new file mode 100644 index 00000000..6ea68650 --- /dev/null +++ b/assets/src/components/Preview.vue @@ -0,0 +1,68 @@ + + + diff --git a/assets/src/components/ProfileSettings.vue b/assets/src/components/ProfileSettings.vue new file mode 100644 index 00000000..8be15fa5 --- /dev/null +++ b/assets/src/components/ProfileSettings.vue @@ -0,0 +1,82 @@ + + + diff --git a/assets/src/components/Search.vue b/assets/src/components/Search.vue new file mode 100644 index 00000000..ef214703 --- /dev/null +++ b/assets/src/components/Search.vue @@ -0,0 +1,179 @@ + + + diff --git a/assets/src/components/Sidebar.vue b/assets/src/components/Sidebar.vue new file mode 100644 index 00000000..1bf590d4 --- /dev/null +++ b/assets/src/components/Sidebar.vue @@ -0,0 +1,78 @@ + + + diff --git a/assets/src/components/User.vue b/assets/src/components/User.vue new file mode 100644 index 00000000..a885f07a --- /dev/null +++ b/assets/src/components/User.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/assets/src/components/Users.vue b/assets/src/components/Users.vue new file mode 100644 index 00000000..71ce0c53 --- /dev/null +++ b/assets/src/components/Users.vue @@ -0,0 +1,42 @@ + + + diff --git a/assets/src/components/buttons/Delete.vue b/assets/src/components/buttons/Delete.vue new file mode 100644 index 00000000..69c43c6e --- /dev/null +++ b/assets/src/components/buttons/Delete.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/src/components/buttons/Download.vue b/assets/src/components/buttons/Download.vue new file mode 100644 index 00000000..b356f97e --- /dev/null +++ b/assets/src/components/buttons/Download.vue @@ -0,0 +1,39 @@ + + + diff --git a/assets/src/components/buttons/Info.vue b/assets/src/components/buttons/Info.vue new file mode 100644 index 00000000..6e2ef91f --- /dev/null +++ b/assets/src/components/buttons/Info.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/src/components/buttons/Move.vue b/assets/src/components/buttons/Move.vue new file mode 100644 index 00000000..a7b5834d --- /dev/null +++ b/assets/src/components/buttons/Move.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/src/components/buttons/Rename.vue b/assets/src/components/buttons/Rename.vue new file mode 100644 index 00000000..9b2922b0 --- /dev/null +++ b/assets/src/components/buttons/Rename.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/src/components/buttons/SwitchView.vue b/assets/src/components/buttons/SwitchView.vue new file mode 100644 index 00000000..f5353af0 --- /dev/null +++ b/assets/src/components/buttons/SwitchView.vue @@ -0,0 +1,31 @@ + + + diff --git a/assets/src/components/buttons/Upload.vue b/assets/src/components/buttons/Upload.vue new file mode 100644 index 00000000..a2922ea9 --- /dev/null +++ b/assets/src/components/buttons/Upload.vue @@ -0,0 +1,17 @@ + + + diff --git a/assets/src/components/errors/403.vue b/assets/src/components/errors/403.vue new file mode 100644 index 00000000..5c19daf5 --- /dev/null +++ b/assets/src/components/errors/403.vue @@ -0,0 +1,13 @@ + + + + diff --git a/assets/src/components/errors/404.vue b/assets/src/components/errors/404.vue new file mode 100644 index 00000000..d73682e3 --- /dev/null +++ b/assets/src/components/errors/404.vue @@ -0,0 +1,13 @@ + + + + diff --git a/assets/src/components/errors/500.vue b/assets/src/components/errors/500.vue new file mode 100644 index 00000000..681f78a9 --- /dev/null +++ b/assets/src/components/errors/500.vue @@ -0,0 +1,13 @@ + + + + diff --git a/assets/src/components/prompts/Delete.vue b/assets/src/components/prompts/Delete.vue new file mode 100644 index 00000000..e0cba26e --- /dev/null +++ b/assets/src/components/prompts/Delete.vue @@ -0,0 +1,73 @@ + + + diff --git a/assets/src/components/prompts/Download.vue b/assets/src/components/prompts/Download.vue new file mode 100644 index 00000000..18b401ba --- /dev/null +++ b/assets/src/components/prompts/Download.vue @@ -0,0 +1,41 @@ + + + diff --git a/assets/src/components/prompts/Error.vue b/assets/src/components/prompts/Error.vue new file mode 100644 index 00000000..2312eae8 --- /dev/null +++ b/assets/src/components/prompts/Error.vue @@ -0,0 +1,25 @@ + + + diff --git a/assets/src/components/prompts/Help.vue b/assets/src/components/prompts/Help.vue new file mode 100644 index 00000000..454e76bf --- /dev/null +++ b/assets/src/components/prompts/Help.vue @@ -0,0 +1,31 @@ + + + + diff --git a/assets/src/components/prompts/Info.vue b/assets/src/components/prompts/Info.vue new file mode 100644 index 00000000..8f625e43 --- /dev/null +++ b/assets/src/components/prompts/Info.vue @@ -0,0 +1,114 @@ + + + diff --git a/assets/src/components/prompts/Move.vue b/assets/src/components/prompts/Move.vue new file mode 100644 index 00000000..122b0d4e --- /dev/null +++ b/assets/src/components/prompts/Move.vue @@ -0,0 +1,168 @@ + + + diff --git a/assets/src/components/prompts/NewDir.vue b/assets/src/components/prompts/NewDir.vue new file mode 100644 index 00000000..bee602fd --- /dev/null +++ b/assets/src/components/prompts/NewDir.vue @@ -0,0 +1,48 @@ + + + + diff --git a/assets/src/components/prompts/NewFile.vue b/assets/src/components/prompts/NewFile.vue new file mode 100644 index 00000000..c40e2844 --- /dev/null +++ b/assets/src/components/prompts/NewFile.vue @@ -0,0 +1,49 @@ + + + + diff --git a/assets/src/components/prompts/Prompts.vue b/assets/src/components/prompts/Prompts.vue new file mode 100644 index 00000000..f6206eee --- /dev/null +++ b/assets/src/components/prompts/Prompts.vue @@ -0,0 +1,99 @@ + + + diff --git a/assets/src/components/prompts/Rename.vue b/assets/src/components/prompts/Rename.vue new file mode 100644 index 00000000..bfaee700 --- /dev/null +++ b/assets/src/components/prompts/Rename.vue @@ -0,0 +1,71 @@ + + + diff --git a/assets/src/components/prompts/Success.vue b/assets/src/components/prompts/Success.vue new file mode 100644 index 00000000..263a846c --- /dev/null +++ b/assets/src/components/prompts/Success.vue @@ -0,0 +1,20 @@ + + + diff --git a/assets/src/css/base.css b/assets/src/css/base.css new file mode 100644 index 00000000..6f025faf --- /dev/null +++ b/assets/src/css/base.css @@ -0,0 +1,137 @@ +body { + font-family: 'Roboto', sans-serif; + padding-top: 4em; + background-color: #f8f8f8; + user-select: none; + color: #212121; +} + +* { + box-sizing: border-box; +} + +*, +*:hover, +*:active, +*:focus { + outline: 0 +} + +a { + text-decoration: none; +} + +img { + max-width: 100%; +} + +audio, +video { + width: 100%; +} + +pre { + padding: 1em; + border: 1px solid #e6e6e6; + border-radius: 0.5em; + background-color: #f5f5f5; + white-space: pre-wrap; + white-space: -moz-pre-wrap; + white-space: -pre-wrap; + white-space: -o-pre-wrap; + word-wrap: break-word; +} + +input, +button { + outline: 0 !important; +} + +input[type="submit"], +button { + border: 0; + padding: .5em 1em; + margin-left: .5em; + border-radius: .1em; + cursor: pointer; + background: #2196f3; + color: #fff; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.05); + transition: .1s ease all; +} + +input[type="submit"]:hover, +button:hover { + background-color: #1E88E5; +} + +.mobile-only { + display: none !important; +} + +.container { + width: 95%; + max-width: 960px; + margin: 1em auto 0; +} + +i.spin { + animation: 1s spin linear infinite; +} + +#app { + transition: .2s ease padding; +} + +#app.multiple { + padding-bottom: 4em; +} + +nav { + width: 16em; + position: fixed; + top: 4em; + left: 0; +} + +nav .action { + width: 100%; + display: block; + border-radius: 0; + font-size: 1.1em; + padding: .5em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +nav>div { + border-top: 1px solid rgba(0, 0, 0, 0.05); +} + +nav .action>* { + vertical-align: middle; +} + +main { + min-height: 1em; + margin: 0 1em 1em auto; + width: calc(100% - 19em); +} + +#breadcrumbs { + height: 3em; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +#breadcrumbs span, +#breadcrumbs { + display: flex; + align-items: center; + color: #6f6f6f; +} + +#breadcrumbs a { + color: inherit +} diff --git a/assets/src/css/dashboard.css b/assets/src/css/dashboard.css new file mode 100644 index 00000000..0c6703d3 --- /dev/null +++ b/assets/src/css/dashboard.css @@ -0,0 +1,121 @@ +.dashboard { + max-width: 600px; + box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px; + border-radius: .5em; + background: #fff; + padding: 1em; + margin: 1em 0; +} + +.dashboard a { + color: inherit +} + +.dashboard h1 button { + font-size: 0.5em; + float: right; +} + +.dashboard table { + width: 100%; +} + +.dashboard table tr { + +} + +.dashboard table th { + font-weight: 500; + color: #757575; + text-align: left; +} + +.dashboard table th, +.dashboard table td { + padding: .5em 0; +} + +.dashboard table td { + +} + +.dashboard table td:last-child { + width: 1em +} + +.dashboard > *:first-child { + margin-top: 0; +} + +.dashboard > *:last-child { + margin-bottom: 0; +} + +form.dashboard input[type="submit"], +.dashboard form input[type="submit"] { + margin-left: auto; + display: block; +} + +.dashboard textarea, +.dashboard input[type="text"], +.dashboard input[type="password"] { + padding: 0; + line-height: 1.7; + display: block; + border: 0; + border-bottom: 1px solid #dddddd; + transition: .2s ease border; + width: 100%; +} + +.dashboard #username, +.dashboard #password, +.dashboard #scope { + max-width: 18em; +} + +.dashboard textarea:focus, +.dashboard textarea:hover, +.dashboard input[type="text"]:focus, +.dashboard input[type="password"]:focus, +.dashboard input[type="text"]:hover, +.dashboard input[type="password"]:hover { + border-color: #2979ff; +} + +.dashboard input.red { + border-color: red; +} + +.dashboard input.green { + border-color: green; +} + +.dashboard textarea { + line-height: 1.15; + padding: .5em; + border: 1px solid #ddd; + font-family: monospace; + min-height: 10em; + resize: vertical; +} + +.dashboard p label { + margin-bottom: .2em; + display: block; + font-size: .8em; + font-weight: bold; +} + +li code, +p code { + background: rgba(0, 0, 0, 0.05); + padding: .1em; + border-radius: .2em; +} + +.small { + font-size: .8em; + line-height: 1.5; +} diff --git a/assets/src/css/editor.css b/assets/src/css/editor.css new file mode 100644 index 00000000..971bf423 --- /dev/null +++ b/assets/src/css/editor.css @@ -0,0 +1,184 @@ +@import "~codemirror/lib/codemirror.css"; +@import "~codemirror/theme/ttcn.css"; +#editor { + max-width: 800px; + margin: 0 auto; +} + +#editor .CodeMirror { + box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px; + margin: 2em 0; + border-radius: .5em; +} + +#editor h2 { + color: rgba(0, 0, 0, 0.3); + font-weight: 500; +} + +.CodeMirror { + height: auto; +} + +.markdown .CodeMirror { + padding: .75em; +} + +.cm-s-markdown .CodeMirror-gutter { + border-right: 1px solid #eff3f5; + padding-right: 5px; + margin-right: 15px; + min-width: 2.5em; + padding-bottom: 30px; +} + +.cm-s-markdown .CodeMirror-cursor { + border-right: 2px solid #667880; +} + +.cm-s-markdown .CodeMirror-lines { + margin: 0; +} + +.cm-s-markdown { + color: #3D494E; +} + +.cm-s-markdown span.cm-header { + color: #3D494E; + font-weight: bold; +} + +.cm-s-markdown span.cm-variable-2 { + color: #3D494E; +} + +.cm-s-markdown span.cm-meta { + color: #516066; +} + +.cm-s-markdown span.cm-hr { + color: #516066; +} + +.cm-s-markdown span.cm-comment { + color: #868f93; +} + +.cm-s-markdown span.cm-qualifier { + color: #868f93; +} + +.cm-s-markdown span.cm-number { + color: #197987; +} + +.cm-s-markdown span.cm-variable { + color: #197987; +} + +.cm-s-markdown span.cm-builtin { + color: #197987; +} + +.cm-s-markdown span.cm-link { + color: #197987; + text-decoration: underline; +} + +.cm-s-markdown span.cm-tag { + color: #197987; +} + +.cm-s-markdown span.cm-string { + color: #48abb9; +} + +.cm-s-markdown span.cm-string-2 { + color: #48abb9; +} + +.cm-s-markdown span.cm-quote { + color: #48abb9; +} + +.cm-s-markdown span.cm-atom { + color: #48abb9; +} + +.cm-s-markdown span.cm-property { + color: #82a367; +} + +.cm-s-markdown span.cm-operator { + color: #82a367; +} + +.cm-s-markdown span.cm-variable-3 { + color: #82a367; +} + +.cm-s-markdown span.cm-attribute { + color: #90bb74; +} + +.cm-s-markdown span.cm-def { + color: #90bb74; +} + +.cm-s-markdown span.cm-keyword { + color: #ec6c45; +} + +.cm-s-markdown span.cm-bracket { + color: #ec6c45; +} + +.cm-s-markdown span.cm-error { + color: #e45346; +} + +.cm-s-markdown span.cm-em { + font-style: italic; +} + +.cm-s-markdown span.cm-strong { + font-weight: bold; +} + +.cm-s-markdown .cm-header-1 { + font-size: 200%; + line-height: 200%; +} + +.cm-s-markdown .cm-header-2 { + font-size: 160%; + line-height: 160%; +} + +.cm-s-markdown .cm-header-3 { + font-size: 125%; + line-height: 125%; +} + +.cm-s-markdown .cm-header-4 { + font-size: 110%; + line-height: 110%; +} + +.cm-s-markdown .cm-comment { + background: rgba(0, 0, 0, .05); + border-radius: 2px; +} + +.cm-s-markdown .cm-link { + color: #7f8c8d; +} + +.cm-s-markdown .cm-url { + color: #aab2b3; +} + +.cm-s-markdown .cm-strikethrough { + text-decoration: line-through; +} diff --git a/assets/src/css/fonts.css b/assets/src/css/fonts.css new file mode 100644 index 00000000..f355f0e8 --- /dev/null +++ b/assets/src/css/fonts.css @@ -0,0 +1,137 @@ +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-cyrillic.woff2) format('woff2'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-greek-ext.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-greek.woff2) format('woff2'); + unicode-range: U+0370-03FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + src: local('Roboto'), local('Roboto-Regular'), url(../assets/fonts/roboto/normal-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-cyrillic-ext.woff2) format('woff2'); + unicode-range: U+0460-052F, U+20B4, U+2DE0-2DFF, U+A640-A69F; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-cyrillic.woff2) format('woff2'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-greek-ext.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-greek.woff2) format('woff2'); + unicode-range: U+0370-03FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-vietnamese.woff2) format('woff2'); + unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-latin-ext.woff2) format('woff2'); + unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + src: local('Roboto Medium'), local('Roboto-Medium'), url(../assets/fonts/roboto/medium-latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000; +} + +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), local('MaterialIcons-Regular'), url(../assets/fonts/material/icons.woff2) format('woff2'); +} + +.prompt .file-list ul li:before, +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'liga'; +} diff --git a/assets/src/css/header.css b/assets/src/css/header.css new file mode 100644 index 00000000..0fa0e7be --- /dev/null +++ b/assets/src/css/header.css @@ -0,0 +1,201 @@ +header { + z-index: 1000; + background-color: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.075); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + position: fixed; + top: 0; + left: 0; + width: 100%; + padding: 0; + display: flex; +} + +header .overlay { + width: 0; + height: 0; +} + +header a, +header a:hover { + color: inherit; +} + +header>div:first-child>.action, +header img { + margin-right: 1em; +} + +header img { + height: 2.5em; +} + +header>div:first-child>.action { + display: none; +} + +header>div { + display: flex; + width: 100%; + padding: 0.5em 0.5em 0.5em 1em; + align-items: center; +} + +header .action span { + display: none; +} + +header>div div { + vertical-align: middle; + position: relative; +} + +header > div:last-child div { + display: flex; +} + +header>div:first-child { + height: 4em; +} + +header>div:last-child { + justify-content: flex-end; +} + +header .search-button { + display: none; +} + +#more { + display: none; +} + +#search { + position: relative; + height: 100%; + width: 100%; + max-width: 25em; +} + +#search.active { + position: fixed; + top: 0; + right: 0; + width: 100%; + max-width: 100%; + height: 100%; + z-index: 9999; +} + +#search #input { + background-color: #f5f5f5; + display: flex; + padding: 0.75em; + border-radius: 0.3em; + transition: .1s ease all; + align-items: center; + z-index: 2; +} + +#search.active #input { + border-bottom: 1px solid rgba(0, 0, 0, 0.075); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + background-color: #fff; + height: 4em; +} + +#search.active>div { + border-radius: 0 !important; +} + +#search.active i, +#search.active input { + color: #212121; +} + +#search #input>.action, +#search #input>i { + margin-right: 0.3em; + user-select: none; +} + +#search input { + width: 100%; + border: 0; + outline: 0; + background-color: transparent; +} + +#search #result { + visibility: visible; + max-height: none; + background-color: #fff; + text-align: left; + color: #ccc; + padding: 0; + height: 0; + transition: .1s ease height, .1s ease padding; + overflow-x: hidden; + overflow-y: auto; + z-index: 1; +} + +#search.active #result { + padding: .5em; + height: calc(100% - 4em); +} + +#search ul { + padding: 0; + margin: 0; + list-style: none; +} + +#search li { + margin-bottom: .5em; +} + +#search #result div { + white-space: pre-wrap; + white-space: -moz-pre-wrap; + white-space: -pre-wrap; + white-space: -o-pre-wrap; + word-wrap: break-word; +} + +#search #result p { + width: 100%; + text-align: center; + display: none; + margin: 0; + max-width: none; +} + +#search.ongoing #result p { + display: block; +} + +#search.active #result i { + color: #ccc; + text-align: center; + margin: 0 auto; + display: table; +} + +#search::-webkit-input-placeholder { + color: rgba(255, 255, 255, .5); +} + +#search:-moz-placeholder { + opacity: 1; + color: rgba(255, 255, 255, .5); +} + +#search::-moz-placeholder { + opacity: 1; + color: rgba(255, 255, 255, .5); +} + +#search:-ms-input-placeholder { + color: rgba(255, 255, 255, .5); +} diff --git a/assets/src/css/listing.css b/assets/src/css/listing.css new file mode 100644 index 00000000..1d24370e --- /dev/null +++ b/assets/src/css/listing.css @@ -0,0 +1,237 @@ +#listing h2 { + margin: 0 0 0 0.5em; + font-size: .9em; + color: rgba(0, 0, 0, 0.38); + font-weight: 500; +} + +#listing .item div:last-of-type * { + text-overflow: ellipsis; + overflow: hidden; +} + +#listing>div { + display: flex; + padding: 0; + flex-wrap: wrap; + justify-content: flex-start; + position: relative; +} + +#listing .item { + background-color: #fff; + position: relative; + display: flex; + flex-wrap: nowrap; + color: #6f6f6f; + transition: .1s ease background, .1s ease opacity; + align-items: center; + cursor: pointer; +} + +#listing .item div:last-of-type { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +#listing .item p { + margin: 0; +} + +#listing .item .size, +#listing .item .modified { + font-size: 0.9em; +} + +#listing .item .name { + font-weight: bold; +} + +#listing .item i { + font-size: 4em; + margin-right: 0.1em; + vertical-align: bottom; +} + +.message { + text-align: center; + font-size: 2em; + margin: 1em auto; + display: block !important; + width: 95%; + color: rgba(0, 0, 0, 0.3); + font-weight: 500; +} + +.message i { + font-size: 2.5em; + margin-bottom: .2em; + display: block; +} + +#listing.mosaic { + padding-top: 1em; + margin: 0 -0.5em; +} + +#listing.mosaic .item { + width: calc(33% - 1em); + margin: .5em; + padding: 0.5em; + border-radius: 0.2em; + box-shadow: 0 1px 3px rgba(0, 0, 0, .06), 0 1px 2px rgba(0, 0, 0, .12); +} + +#listing.mosaic .item:hover { + box-shadow: 0 1px 3px rgba(0, 0, 0, .12), 0 1px 2px rgba(0, 0, 0, .24) !important; +} + +#listing.mosaic .header { + display: none; +} + +#listing.mosaic .item div:first-of-type { + width: 5em; +} + +#listing.mosaic .item div:last-of-type { + width: calc(100% - 5vw); +} + +#listing.list { + flex-direction: column; + padding-top: 3.25em; + width: 100%; + max-width: 100%; + margin: 0; +} + +#listing.list .item { + width: 100%; + margin: 0; + border: 1px solid rgba(0, 0, 0, 0.1); + padding: 1em; + border-top: 0; +} + +#listing.list h2 { + display: none; +} + +#listing .item[aria-selected=true] { + background: #2196f3 !important; + color: #fff !important; +} + +#listing.list .item div:first-of-type { + width: 3em; +} + +#listing.list .item div:first-of-type i { + font-size: 2em; +} + +#listing.list .item div:last-of-type { + width: calc(100% - 3em); + display: flex; + align-items: center; +} + +#listing.list .item .name { + width: 50%; +} + +#listing.list .item .size { + width: 25%; +} + +#listing .item.header { + display: none !important; + background-color: #ccc; +} + +#listing.list .header i { + font-size: 1.5em; + vertical-align: middle; + margin-left: .2em; +} + +#listing.list .item.header { + display: flex !important; + background: #f8f8f8; + position: fixed; + width: calc(100% - 19em); + top: 7em; + right: 1em; + z-index: 999; + padding: .85em; + border: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +#listing.list .item.header>div:first-child { + width: 0; +} + +#listing.list .item.header .name { + margin-right: 3em; +} + +#listing.list .header a { + color: inherit; +} + +#listing.list .item.header>div:first-child { + width: 0; +} + +#listing.list .name { + font-weight: normal; +} + +#listing.list .item.header .name { + margin-right: 3em; +} + +#listing.list .header span { + vertical-align: middle; +} + +#listing.list .header i { + opacity: 0; + transition: .1s ease all; +} + +#listing.list .header p:hover i, +#listing.list .header .active i { + opacity: 1; +} + +#listing.list .item.header .active { + font-weight: bold; +} + +#listing #multiple-selection { + position: fixed; + bottom: -4em; + left: 0; + z-index: 99999; + width: 100%; + background-color: #2196f3; + height: 4em; + display: flex !important; + padding: 0.5em 0.5em 0.5em 1em; + justify-content: space-between; + align-items: center; + transition: .2s ease bottom; +} + +#listing #multiple-selection.active { + bottom: 0; +} + +#listing #multiple-selection p, +#listing #multiple-selection i { + color: #fff; +} diff --git a/assets/src/css/mobile.css b/assets/src/css/mobile.css new file mode 100644 index 00000000..50748467 --- /dev/null +++ b/assets/src/css/mobile.css @@ -0,0 +1,113 @@ +@media (max-width: 1024px) { + nav { + width: 10em + } +} + +@media (max-width: 1024px) { + #listing.list .item.header, + main { + width: calc(100% - 13em) + } +} + +@media (max-width: 736px) { + #more { + display: inherit + } + header .overlay { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.1); + } + #dropdown { + position: fixed; + top: 1em; + right: 1em; + display: block; + background-color: #fff; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + transform: scale(0); + transition: .1s ease-in-out transform; + transform-origin: top right; + z-index: 99999; + } + #dropdown > div { + display: block; + } + #dropdown.active { + transform: scale(1); + } + #dropdown .action { + display: flex; + align-items: center; + border-radius: 0; + width: 100%; + } + #dropdown .action span:not(.counter) { + display: inline-block; + padding: .4em; + } + #dropdown .counter { + left: 2.25em; + } + #file-selection { + position: fixed; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + background: #fff; + box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px; + width: 95%; + max-width: 16em; + } + #file-selection .action { + border-radius: 50%; + width: auto; + } + #file-selection > span { + display: inline-block; + margin-left: 1em; + color: #6f6f6f; + margin-right: auto; + } + nav { + top: 0; + z-index: 99999; + background: #fff; + height: 100%; + width: 16em; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + transition: .1s ease left; + left: -17em; + } + nav.active { + left: 0; + } + header .search-button, + header>div:first-child>.action { + display: inherit; + } + header img { + display: none; + } + #listing { + margin-bottom: 5em; + } + #listing.list .item.header, + main { + width: calc(100% - 2em); + } + main { + margin: 0 1em; + width: calc(100% - 2em); + } + #search { + display: none; + } + #search.active { + display: block; + } +} diff --git a/assets/src/css/prompts.css b/assets/src/css/prompts.css new file mode 100644 index 00000000..6badde1a --- /dev/null +++ b/assets/src/css/prompts.css @@ -0,0 +1,179 @@ +.prompt { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.075); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + padding: 2em; + max-width: 25em; + width: 90%; + max-height: 95%; + z-index: 99999; + animation: .1s show forwards; +} + +.overlay { + background-color: rgba(0, 0, 0, 0.5); + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 9999; + animation: .1s show forwards; +} + +.prompt h3 { + margin: 0; + font-weight: 500; + font-size: 1.5em; +} + +.prompt p { + font-size: .9em; + color: rgba(0, 0, 0, 0.8); + margin: .5em 0 1em; +} + +.prompt input:not([type="submit"]) { + width: 100%; + border: 1px solid #dadada; + line-height: 1; + padding: .3em; + margin: .3em 0; +} + +.prompt code { + word-wrap: break-word; +} + +.prompt div { + margin-top: 1em; + display: flex; + justify-content: flex-start; + flex-direction: row-reverse; +} + +.prompt .cancel { + background-color: #ECEFF1; + color: #37474F; +} + +.prompt .cancel:hover { + background-color: #e9eaeb; +} + +.prompt.success i, +.prompt.error i { + color: #F44336; + display: block; + margin: 0 auto .15em; + text-align: center; + font-size: 5em; +} + +.prompt.success h3, +.prompt.error h3 { + text-align: center; +} + +.prompt.error button:not(.cancel) { + background-color: #F44336 +} + +.prompt.success i { + color: #8BC34A; +} + +.prompt.success button { + background-color: #8BC34A; +} + + +/* * * * * * * * * * * * * * * * + * PROMPT - MOVE * + * * * * * * * * * * * * * * * */ + +.file-list { + max-height: 50vh; + overflow: auto; + list-style: none; + margin: 0; + padding: 0; + width: 100%; +} + +.file-list li { + width: 100%; + user-select: none; + border-radius: .2em; + padding: .3em; +} + +.file-list li[aria-selected=true] { + background: #2196f3 !important; + color: #fff !important; + transition: .1s ease all; +} + +.file-list li:hover { + background-color: #e9eaeb; + cursor: pointer; +} + +.file-list li:before { + content: "folder"; + color: #6f6f6f; + vertical-align: middle; + line-height: 1.4; + font-family: 'Material Icons'; + font-size: 1.75em; + margin-right: .25em; +} + +.file-list li[aria-selected=true]:before { + color: white; +} + +.prompt#download { + max-width: 15em; +} + +.prompt#download button { + width: 100%; + display: block; + margin: 0 0 1em; + background-color: #ECEFF1; + color: #37474F; +} + +.prompt#download button:last-of-type { + margin-bottom: 0; +} + +.help { + max-width: 24em; +} + +.help ul { + padding: 0; + margin: 1em 0; + list-style: none; +} + +@keyframes show { + 0% { + display: none; + opacity: 0; + } + 1% { + display: block; + opacity: 0; + } + 100% { + display: block; + opacity: 1; + } +} diff --git a/assets/src/css/styles.css b/assets/src/css/styles.css new file mode 100644 index 00000000..23947b0c --- /dev/null +++ b/assets/src/css/styles.css @@ -0,0 +1,201 @@ +@import "~normalize.css/normalize.css"; +@import "./fonts.css"; +@import "./base.css"; +@import "./header.css"; +@import "./prompts.css"; +@import "./listing.css"; +@import "./editor.css"; +@import "./dashboard.css"; + +/* * * * * * * * * * * * * * * * + * ACTION * + * * * * * * * * * * * * * * * */ + +.action { + display: inline-block; + cursor: pointer; + transition: 0.2s ease all; + border: 0; + margin: 0; + color: #546E7A; + border-radius: 50%; + background: transparent; + padding: 0; + box-shadow: 0 0 0 0; + vertical-align: middle; + text-align: left; + position: relative; +} + +.action.disabled { + opacity: 0.2; + cursor: not-allowed; +} + +.action i { + padding: 0.4em; + transition: .1s ease-in-out all; + border-radius: 50%; +} + +.action:hover { + background-color: rgba(0, 0, 0, .1); +} + +.action ul { + position: absolute; + top: 0; + color: #7d7d7d; + list-style: none; + margin: 0; + padding: 0; + flex-direction: column; + display: flex; +} + +.action ul li { + line-height: 1; + padding: .7em; + transition: .1s ease background-color; +} + +.action ul li:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +#click-overlay { + display: none; + position: fixed; + cursor: pointer; + top: 0; + left: 0; + height: 100%; + width: 100%; +} + +#click-overlay.active { + display: block; +} + +.action .counter { + display: block; + position: absolute; + bottom: 0; + right: 0; + background: #2196f3; + color: #fff; + border-radius: 50%; + font-size: .75em; + width: 1.5em; + height: 1.5em; + text-align: center; + line-height: 1.25em; + border: 2px solid white; +} + + +/* PREVIEWER */ + +#previewer { + background-color: rgba(0, 0, 0, 0.9); + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + overflow: hidden; +} + +#previewer .bar { + width: 100%; + text-align: right; + display: flex; + padding: 0.5em 0.5em 0.5em 1em; + height: 3.7em; +} + +#previewer .action:first-of-type { + margin-right: auto; +} + +#previewer .action i { + color: #fff; +} + +#previewer .action:hover { + background-color: rgba(255, 255, 255, 0.3) +} + +#previewer .action span { + display: none; +} + +#previewer .preview { + margin: 2em auto 4em; + max-width: 80%; + text-align: center; + height: calc(100vh - 9.7em); +} + +#previewer .preview pre { + text-align: left; + overflow: auto; +} + +#previewer .preview pre, +#previewer .preview video, +#previewer .preview img { + max-height: 100%; + margin: 0; +} + +#previewer .pdf { + width: 100%; + height: 100%; +} + +#previewer h2.message { + color: rgba(255, 255, 255, 0.5) +} + + +/* * * * * * * * * * * * * * * * + * PROMPT * + * * * * * * * * * * * * * * * */ + + +/* * * * * * * * * * * * * * * * + * FOOTER * + * * * * * * * * * * * * * * * */ + +.credits { + font-size: 0.6em; + margin: 3em 2.5em; + color: #a5a5a5; +} + +.credits span { + display: block; + margin: .3em 0; +} + +.credits a, +.credits a:hover { + color: inherit; + cursor: pointer; +} + + +/* * * * * * * * * * * * * * * * + * ANIMATIONS * + * * * * * * * * * * * * * * * */ + +@keyframes spin { + 100% { + -webkit-transform: rotate(-360deg); + transform: rotate(-360deg); + } +} + +@import './mobile.css'; diff --git a/assets/src/main.js b/assets/src/main.js new file mode 100644 index 00000000..47008899 --- /dev/null +++ b/assets/src/main.js @@ -0,0 +1,15 @@ +import Vue from 'vue' +import App from './App' +import store from './store' +import router from './router' + +Vue.config.productionTip = true + +/* eslint-disable no-new */ +new Vue({ + el: '#app', + store, + router, + template: '', + components: { App } +}) diff --git a/assets/src/router/index.js b/assets/src/router/index.js new file mode 100644 index 00000000..74e24481 --- /dev/null +++ b/assets/src/router/index.js @@ -0,0 +1,159 @@ +import Vue from 'vue' +import Router from 'vue-router' +import Login from '@/components/Login' +import Main from '@/components/Main' +import Files from '@/components/Files' +import Users from '@/components/Users' +import User from '@/components/User' +import GlobalSettings from '@/components/GlobalSettings' +import ProfileSettings from '@/components/ProfileSettings' +import error403 from '@/components/errors/403' +import error404 from '@/components/errors/404' +import error500 from '@/components/errors/500' +import auth from '@/utils/auth.js' +import store from '@/store' + +Vue.use(Router) + +const router = new Router({ + base: document.querySelector('meta[name="base"]').getAttribute('content'), + mode: 'history', + routes: [ + { + path: '/login', + name: 'Login', + component: Login, + beforeEnter: function (to, from, next) { + auth.loggedIn() + .then(() => { + next({ path: '/files' }) + }) + .catch(() => { + document.title = 'Login' + next() + }) + } + }, + { + path: '/', + redirect: { + path: '/files/' + } + }, + { + path: '/*', + component: Main, + meta: { + requiresAuth: true + }, + children: [ + { + path: '/files/*', + name: 'Files', + component: Files + }, + { + path: '/settings', + name: 'Settings', + redirect: { + path: '/settings/profile' + } + }, + { + path: '/settings/profile', + name: 'Profile Settings', + component: ProfileSettings + }, + { + path: '/settings/global', + name: 'Global Settings', + component: GlobalSettings, + meta: { + requiresAdmin: true + } + }, + { + path: '/403', + name: 'Forbidden', + component: error403 + }, + { + path: '/404', + name: 'Not Found', + component: error404 + }, + { + path: '/500', + name: 'Internal Server Error', + component: error500 + }, + { + path: '/users', + name: 'Users', + component: Users, + meta: { + requiresAdmin: true + } + }, + { + path: '/users/', + redirect: { + path: '/users' + } + }, + { + path: '/users/*', + name: 'User', + component: User, + meta: { + requiresAdmin: true + } + }, + { + path: '/*', + redirect: { + name: 'Files' + } + } + ] + } + ] +}) + +router.beforeEach((to, from, next) => { + document.title = to.name + + if (to.matched.some(record => record.meta.requiresAuth)) { + // this route requires auth, check if logged in + // if not, redirect to login page. + auth.loggedIn() + .then(() => { + if (to.matched.some(record => record.meta.requiresAdmin)) { + if (store.state.user.admin) { + next() + return + } + + next({ + path: '/403' + }) + + return + } + + next() + }) + .catch(e => { + next({ + path: '/login', + query: { redirect: to.fullPath } + }) + }) + + return + } + + next() +}) + +export default router diff --git a/assets/src/store/getters.js b/assets/src/store/getters.js new file mode 100644 index 00000000..370bc589 --- /dev/null +++ b/assets/src/store/getters.js @@ -0,0 +1,5 @@ +const getters = { + selectedCount: state => state.selected.length +} + +export default getters diff --git a/assets/src/store/index.js b/assets/src/store/index.js new file mode 100644 index 00000000..18c79717 --- /dev/null +++ b/assets/src/store/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue' +import Vuex from 'vuex' +import mutations from './mutations' +import getters from './getters' + +Vue.use(Vuex) + +const state = { + user: {}, + req: {}, + plugins: window.plugins || [], + baseURL: document.querySelector('meta[name="base"]').getAttribute('content'), + jwt: '', + loading: false, + reload: false, + selected: [], + multiple: false, + show: null, + showMessage: null +} + +export default new Vuex.Store({ + strict: process.env.NODE_ENV !== 'production', + state, + getters, + mutations +}) diff --git a/assets/src/store/mutations.js b/assets/src/store/mutations.js new file mode 100644 index 00000000..505f0806 --- /dev/null +++ b/assets/src/store/mutations.js @@ -0,0 +1,46 @@ +const mutations = { + closeHovers: state => { + state.show = null + state.showMessage = null + }, + showHover: (state, value) => { + if (typeof value !== 'object') { + state.show = value + return + } + + state.show = value.prompt + state.showMessage = value.message + }, + showError: (state, value) => { + state.show = 'error' + state.showMessage = value + }, + showSuccess: (state, value) => { + state.show = 'success' + state.showMessage = value + }, + setLoading: (state, value) => { state.loading = value }, + setReload: (state, value) => { state.reload = value }, + setUser: (state, value) => (state.user = value), + setUserCSS: (state, value) => (state.user.css = value), + setJWT: (state, value) => (state.jwt = value), + multiple: (state, value) => (state.multiple = value), + addSelected: (state, value) => (state.selected.push(value)), + removeSelected: (state, value) => { + let i = state.selected.indexOf(value) + if (i === -1) return + state.selected.splice(i, 1) + }, + resetSelected: (state) => { + state.selected = [] + }, + listingDisplay: (state, value) => { + state.req.display = value + }, + updateRequest: (state, value) => { + state.req = value + } +} + +export default mutations diff --git a/assets/src/utils/api.js b/assets/src/utils/api.js new file mode 100644 index 00000000..b16d30f8 --- /dev/null +++ b/assets/src/utils/api.js @@ -0,0 +1,424 @@ +import store from '@/store' + +const ssl = (window.location.protocol === 'https:') + +function removePrefix (url) { + if (url.startsWith('/files')) { + return url.slice(6) + } + + return url +} + +function fetch (url) { + url = removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/resource${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject({ + message: request.responseText, + status: request.status + }) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function rm (url) { + url = removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('DELETE', `${store.state.baseURL}/api/resource${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + if (request.status === 200) { + resolve(request.responseText) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send() + }) +} + +function post (url, content = '') { + url = removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${store.state.baseURL}/api/resource${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + if (request.status === 200) { + resolve(request.responseText) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send(content) + }) +} + +function put (url, content = '') { + url = removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/resource${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + if (request.status === 200) { + resolve(request.responseText) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send(content) + }) +} + +function move (oldLink, newLink) { + oldLink = removePrefix(oldLink) + newLink = removePrefix(newLink) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PATCH', `${store.state.baseURL}/api/resource${oldLink}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + request.setRequestHeader('Destination', newLink) + + request.onload = () => { + if (request.status === 200) { + resolve(request.responseText) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send() + }) +} + +function checksum (url, algo) { + url = removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/checksum${url}?algo=${algo}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + if (request.status === 200) { + resolve(request.responseText) + } else { + reject(request.responseText) + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function command (url, command, onmessage, onclose) { + let protocol = (ssl ? 'wss:' : 'ws:') + url = removePrefix(url) + url = `${protocol}//${window.location.hostname}${store.state.baseURL}/api/command${url}?token=${store.state.jwt}` + + let conn = new window.WebSocket(url) + conn.onopen = () => conn.send(command) + conn.onmessage = onmessage + conn.onclose = onclose +} + +function search (url, search, onmessage, onclose) { + let protocol = (ssl ? 'wss:' : 'ws:') + url = removePrefix(url) + url = `${protocol}//${window.location.hostname}${store.state.baseURL}/api/search${url}?token=${store.state.jwt}` + + let conn = new window.WebSocket(url) + conn.onopen = () => conn.send(search) + conn.onmessage = onmessage + conn.onclose = onclose +} + +function download (format, ...files) { + let url = `${store.state.baseURL}/api/download` + + if (files.length === 1) { + url += removePrefix(files[0]) + '?' + } else { + let arg = '' + + for (let file of files) { + arg += removePrefix(file) + ',' + } + + arg = arg.substring(0, arg.length - 1) + arg = encodeURIComponent(arg) + url += `/?files=${arg}&` + } + + url += `token=${store.state.jwt}` + + if (format !== null) { + url += `&format=${format}` + } + + window.open(url) +} + +function getUsers () { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/users/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function getUser (id) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/users/${id}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function newUser (user) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${store.state.baseURL}/api/users/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 201: + resolve(request.getResponseHeader('Location')) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify(user)) + }) +} + +function updateUser (user) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/users/${user.ID}`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(request.getResponseHeader('Location')) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify(user)) + }) +} + +function updatePassword (password) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/users/change-password`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve() + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify({ 'password': password })) + }) +} + +function updateCSS (css) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/users/change-css`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve() + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify({ 'css': css })) + }) +} + +function getCommands () { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/commands/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function updateCommands (commands) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/commands/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve() + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify(commands)) + }) +} + +function getPlugins () { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/plugins/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve(JSON.parse(request.responseText)) + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send() + }) +} + +function updatePlugins (data) { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('PUT', `${store.state.baseURL}/api/plugins/`, true) + request.setRequestHeader('Authorization', `Bearer ${store.state.jwt}`) + + request.onload = () => { + switch (request.status) { + case 200: + resolve() + break + default: + reject(request.responseText) + break + } + } + request.onerror = (error) => reject(error) + request.send(JSON.stringify(data)) + }) +} + +export default { + delete: rm, + fetch, + checksum, + move, + put, + post, + command, + search, + download, + getUser, + newUser, + updateUser, + getUsers, + updatePassword, + updateCSS, + getCommands, + updateCommands, + removePrefix, + getPlugins, + updatePlugins +} diff --git a/assets/src/utils/auth.js b/assets/src/utils/auth.js new file mode 100644 index 00000000..3fc6d0b7 --- /dev/null +++ b/assets/src/utils/auth.js @@ -0,0 +1,60 @@ +import cookie from './cookie' +import store from '@/store' +import router from '@/router' + +function parseToken (token) { + document.cookie = `auth=${token}; max-age=86400; path=${store.state.baseURL}` + let res = token.split('.') + let user = JSON.parse(window.atob(res[1])) + store.commit('setJWT', token) + store.commit('setUser', user) +} + +function loggedIn () { + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('GET', `${store.state.baseURL}/api/auth/renew`, true) + request.setRequestHeader('Authorization', `Bearer ${cookie('auth')}`) + + request.onload = () => { + if (request.status === 200) { + parseToken(request.responseText) + resolve() + } else { + reject() + } + } + request.onerror = () => reject() + request.send() + }) +} + +function login (user, password) { + let data = {username: user, password: password} + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${store.state.baseURL}/api/auth/get`, true) + + request.onload = () => { + if (request.status === 200) { + parseToken(request.responseText) + resolve() + } else { + reject(request.responseText) + } + } + request.onerror = () => reject() + request.send(JSON.stringify(data)) + }) +} + +function logout () { + document.cookie = `auth='nothing'; max-age=0; path=${store.state.baseURL}` + router.push({path: '/login'}) +} + +export default { + loggedIn: loggedIn, + login: login, + logout: logout +} diff --git a/assets/src/utils/buttons.js b/assets/src/utils/buttons.js new file mode 100644 index 00000000..bf083ece --- /dev/null +++ b/assets/src/utils/buttons.js @@ -0,0 +1,39 @@ +function loading (button) { + let el = document.querySelector(`#${button}-button > i`) + + if (el === undefined || el === null) { + console.log('Error getting button ' + button) + return + } + + el.dataset.icon = el.innerHTML + el.style.opacity = 0 + + setTimeout(() => { + el.classList.add('spin') + el.innerHTML = 'autorenew' + el.style.opacity = 1 + }, 100) +} + +function done (button, success = true) { + let el = document.querySelector(`#${button}-button > i`) + + if (el === undefined || el === null) { + console.log('Error getting button ' + button) + return + } + + el.style.opacity = 0 + + setTimeout(() => { + el.classList.remove('spin') + el.innerHTML = el.dataset.icon + el.style.opacity = 1 + }, 100) +} + +export default { + loading, + done +} diff --git a/assets/src/utils/codemirror.js b/assets/src/utils/codemirror.js new file mode 100644 index 00000000..e40ba58c --- /dev/null +++ b/assets/src/utils/codemirror.js @@ -0,0 +1,60 @@ +// Most of the code from this file comes from: +// https://github.com/codemirror/CodeMirror/blob/master/addon/mode/loadmode.js +import * as CodeMirror from 'codemirror' +import store from '@/store' + +// Make CodeMirror available globally so the modes' can register themselves. +window.CodeMirror = CodeMirror +CodeMirror.modeURL = store.state.baseURL + '/static/js/codemirror/mode/%N/%N.js' + +var loading = {} + +function splitCallback (cont, n) { + var countDown = n + return function () { + if (--countDown === 0) cont() + } +} + +function ensureDeps (mode, cont) { + var deps = CodeMirror.modes[mode].dependencies + if (!deps) return cont() + var missing = [] + for (var i = 0; i < deps.length; ++i) { + if (!CodeMirror.modes.hasOwnProperty(deps[i])) missing.push(deps[i]) + } + if (!missing.length) return cont() + var split = splitCallback(cont, missing.length) + for (i = 0; i < missing.length; ++i) CodeMirror.requireMode(missing[i], split) +} + +CodeMirror.requireMode = function (mode, cont) { + if (typeof mode !== 'string') mode = mode.name + if (CodeMirror.modes.hasOwnProperty(mode)) return ensureDeps(mode, cont) + if (loading.hasOwnProperty(mode)) return loading[mode].push(cont) + + var file = CodeMirror.modeURL.replace(/%N/g, mode) + + var script = document.createElement('script') + script.src = file + var others = document.getElementsByTagName('script')[0] + var list = loading[mode] = [cont] + + CodeMirror.on(script, 'load', function () { + ensureDeps(mode, function () { + for (var i = 0; i < list.length; ++i) list[i]() + }) + }) + + others.parentNode.insertBefore(script, others) +} + +CodeMirror.autoLoadMode = function (instance, mode) { + if (CodeMirror.modes.hasOwnProperty(mode)) return + + CodeMirror.requireMode(mode, function () { + instance.setOption('mode', mode) + }) +} + +export default CodeMirror diff --git a/assets/src/utils/cookie.js b/assets/src/utils/cookie.js new file mode 100644 index 00000000..5004b602 --- /dev/null +++ b/assets/src/utils/cookie.js @@ -0,0 +1,4 @@ +export default function (name) { + let re = new RegExp('(?:(?:^|.*;\\s*)' + name + '\\s*\\=\\s*([^;]*).*$)|^.*$') + return document.cookie.replace(re, '$1') +} diff --git a/assets/src/utils/css.js b/assets/src/utils/css.js new file mode 100644 index 00000000..15ab99fe --- /dev/null +++ b/assets/src/utils/css.js @@ -0,0 +1,28 @@ +export default function getRule (rules) { + for (let i = 0; i < rules.length; i++) { + rules[i] = rules[i].toLowerCase() + } + + let result = null + let find = Array.prototype.find + + find.call(document.styleSheets, styleSheet => { + result = find.call(styleSheet.cssRules, cssRule => { + let found = false + + if (cssRule instanceof window.CSSStyleRule) { + for (let i = 0; i < rules.length; i++) { + if (cssRule.selectorText.toLowerCase() === rules[i]) { + found = true + } + } + } + + return found + }) + + return result != null + }) + + return result +} diff --git a/assets/src/utils/url.js b/assets/src/utils/url.js new file mode 100644 index 00000000..2649a592 --- /dev/null +++ b/assets/src/utils/url.js @@ -0,0 +1,12 @@ +function removeLastDir (url) { + var arr = url.split('/') + if (arr.pop() === '') { + arr.pop() + } + + return arr.join('/') +} + +export default { + removeLastDir: removeLastDir +} diff --git a/assets/static/img/icons/android-chrome-192x192.png b/assets/static/img/icons/android-chrome-192x192.png new file mode 100644 index 00000000..685cdea9 Binary files /dev/null and b/assets/static/img/icons/android-chrome-192x192.png differ diff --git a/assets/static/img/icons/android-chrome-512x512.png b/assets/static/img/icons/android-chrome-512x512.png new file mode 100644 index 00000000..be4f0526 Binary files /dev/null and b/assets/static/img/icons/android-chrome-512x512.png differ diff --git a/assets/static/img/icons/apple-touch-icon-120x120.png b/assets/static/img/icons/apple-touch-icon-120x120.png new file mode 100644 index 00000000..f204c14b Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon-120x120.png differ diff --git a/assets/static/img/icons/apple-touch-icon-152x152.png b/assets/static/img/icons/apple-touch-icon-152x152.png new file mode 100644 index 00000000..f7f0e00f Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon-152x152.png differ diff --git a/assets/static/img/icons/apple-touch-icon-180x180.png b/assets/static/img/icons/apple-touch-icon-180x180.png new file mode 100644 index 00000000..8c3ee3a9 Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon-180x180.png differ diff --git a/assets/static/img/icons/apple-touch-icon-60x60.png b/assets/static/img/icons/apple-touch-icon-60x60.png new file mode 100644 index 00000000..00f82e94 Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon-60x60.png differ diff --git a/assets/static/img/icons/apple-touch-icon-76x76.png b/assets/static/img/icons/apple-touch-icon-76x76.png new file mode 100644 index 00000000..d9cc0f35 Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon-76x76.png differ diff --git a/assets/static/img/icons/apple-touch-icon.png b/assets/static/img/icons/apple-touch-icon.png new file mode 100644 index 00000000..685cdea9 Binary files /dev/null and b/assets/static/img/icons/apple-touch-icon.png differ diff --git a/assets/static/img/icons/favicon-16x16.png b/assets/static/img/icons/favicon-16x16.png new file mode 100644 index 00000000..8de7c05e Binary files /dev/null and b/assets/static/img/icons/favicon-16x16.png differ diff --git a/assets/static/img/icons/favicon-32x32.png b/assets/static/img/icons/favicon-32x32.png new file mode 100644 index 00000000..9b74a094 Binary files /dev/null and b/assets/static/img/icons/favicon-32x32.png differ diff --git a/assets/static/img/icons/favicon.ico b/assets/static/img/icons/favicon.ico new file mode 100644 index 00000000..cc2b4690 Binary files /dev/null and b/assets/static/img/icons/favicon.ico differ diff --git a/assets/static/img/icons/msapplication-icon-144x144.png b/assets/static/img/icons/msapplication-icon-144x144.png new file mode 100644 index 00000000..a7db463f Binary files /dev/null and b/assets/static/img/icons/msapplication-icon-144x144.png differ diff --git a/assets/static/img/icons/mstile-150x150.png b/assets/static/img/icons/mstile-150x150.png new file mode 100644 index 00000000..8515f250 Binary files /dev/null and b/assets/static/img/icons/mstile-150x150.png differ diff --git a/assets/static/manifest.json b/assets/static/manifest.json new file mode 100644 index 00000000..25bd2d98 --- /dev/null +++ b/assets/static/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "File Manager", + "short_name": "File Manager", + "icons": [ + { + "src": "{{ .BaseURL }}/static/img/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "{{ .BaseURL }}/static/img/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "{{ .BaseURL }}/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#2979ff" +} diff --git a/auth.go b/auth.go new file mode 100644 index 00000000..6124cdcd --- /dev/null +++ b/auth.go @@ -0,0 +1,156 @@ +package filemanager + +import ( + "encoding/json" + "math/rand" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/dgrijalva/jwt-go/request" +) + +// authHandler proccesses the authentication for the user. +func authHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // Receive the credentials from the request and unmarshal them. + var cred User + if r.Body == nil { + return http.StatusForbidden, nil + } + + err := json.NewDecoder(r.Body).Decode(&cred) + if err != nil { + return http.StatusForbidden, nil + } + + // Checks if the user exists. + u, ok := c.FM.Users[cred.Username] + if !ok { + return http.StatusForbidden, nil + } + + // Checks if the password is correct. + if !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 *RequestContext, 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 { + User + jwt.StandardClaims +} + +// printToken prints the final JWT token to the user. +func printToken(c *RequestContext, w http.ResponseWriter) (int, error) { + // Creates a copy of the user and removes it password + // hash so it never arrives to the user. + u := User{} + u = *c.User + u.Password = "" + + // Builds the claims. + claims := claims{ + u, + jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Hour * 24).Unix(), + Issuer: "File Manager", + }, + } + + // Creates the token and signs it. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + string, err := token.SignedString(c.FM.key) + + if err != nil { + return http.StatusInternalServerError, err + } + + // Writes the token. + w.Write([]byte(string)) + return 0, nil +} + +type extractor []string + +func (e extractor) ExtractToken(r *http.Request) (string, error) { + token, _ := request.AuthorizationHeaderExtractor.ExtractToken(r) + if token != "" { + return token, nil + } + + token, _ = request.ArgumentExtractor{"token"}.ExtractToken(r) + if token != "" { + return token, nil + } + + return "", request.ErrNoTokenInRequest +} + +// validateAuth is used to validate the authentication and returns the +// User if it is valid. +func validateAuth(c *RequestContext, r *http.Request) (bool, *User) { + keyFunc := func(token *jwt.Token) (interface{}, error) { + return c.FM.key, nil + } + var claims claims + token, err := request.ParseFromRequestWithClaims(r, + extractor{}, + &claims, + keyFunc, + ) + + if err != nil || !token.Valid { + return false, nil + } + + u, ok := c.FM.Users[claims.User.Username] + if !ok { + return false, nil + } + + c.User = u + return true, u +} + +// hashPassword generates an hash from a password using bcrypt. +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// checkPasswordHash compares a password with an hash to check if they match. +func checkPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// randomString creates a string with a defined length using the above charset. +func randomString(length int) string { + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..4203a381 --- /dev/null +++ b/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +rm -rf assets/dist +npm run build +rice embed-go +cd ./caddy/hugo +rice embed-go diff --git a/caddy/filemanager/README.md b/caddy/filemanager/README.md new file mode 100644 index 00000000..f1c03d09 --- /dev/null +++ b/caddy/filemanager/README.md @@ -0,0 +1,80 @@ +# filemanager - a caddy plugin + +filemanager 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 is an implementation of [hacdias/filemanager][1] library. + +## Get Started + +To start using this plugin you just need to go to the [download Caddy page][3] and choose `http.filemanager` in the directives section. For further information on how Caddy works refer to [its documentation][4]. + +The default credentials are `admin` for both the user and the password. It is highy recommended to change them after logging in for the first time and to use HTTPS. You can create more users and define their own permissions using the web interface. + +For information about the working of filemanager itself, go to the [main repository](https://github.com/hacdias/filemanager). + +## Syntax + +``` +filemanager [baseurl] [scope] { + database path +} +``` + ++ `baseurl` is the URL path where you will access File Manager. Defaults to `/`. ++ `scope` is the path, relative or absolute, to the directory you want to browse in. This value will be used for the creation of the first user. Defaults to `./`. ++ `path` is the database path where the settings will be stored. By default, the settings will be stored on [`.caddy`][5] folder. + +## Database + +By default the database will be stored on [`.caddy`][5] directory, in a sub-directory called `filemanager`. Each file name is an hash of the combination of the host and the base URL. + +If you don't set a database path, you will receive a warning like this: + +> [WARNING] A database is going to be created for your File Manager instace at ~/.caddy/filemanager/xxx.db. It is highly recommended that you set the 'database' option to 'xxx.db' + +Why? If you don't set a database path and you change the host or the base URL, your settings will be reseted. So it is *highly* recommended to set this option. + +When you set a relative path, such as `xxxxxxxxxx.db`, it will always be relative to `.caddy/filemanager` directory. Although, you may also use an absolute path if you wish to store the database in other place. + +## Examples + +Show the directory where Caddy is being executed at the root of the domain: + +``` +filemanager { + database myinstance.db +} +``` + + +Show the content of `foo` at the root of the domain: + +``` +filemanager / ./foo { + database myinstance.db +} +``` + +Show the directory where Caddy is being executed at `/filemanager`: + +``` +filemanager /filemanager { + database myinstance.db +} +``` + +Show the content of `foo` at `/bar`: + +``` +filemanager /bar /show { + database myinstance.db +} +``` + +## Known Issues + +If you are having troubles **handling large files** you might need to check out the [`timeouts`][2] plugin, which can be used to change the default HTTP Timeouts. + +[1]:https://github.com/hacdias/filemanager +[2]:https://caddyserver.com/docs/timeouts +[3]:https://caddyserver.com/download +[4]:https://caddyserver.com/docs +[5]:https://caddyserver.com/docs/automatic-https#dot-caddy diff --git a/caddy/filemanager/filemanager.go b/caddy/filemanager/filemanager.go new file mode 100644 index 00000000..c7b686e4 --- /dev/null +++ b/caddy/filemanager/filemanager.go @@ -0,0 +1,157 @@ +// Package filemanager provides middleware for managing files in a directory +// when directory path is requested instead of a specific file. Based on browse +// middleware. +package filemanager + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "golang.org/x/net/webdav" + + . "github.com/hacdias/filemanager" + "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 []*config +} + +type config struct { + *FileManager + baseURL string +} + +// 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 + } + + return f.Configs[i].ServeHTTP(w, r) + } + + return f.Next.ServeHTTP(w, r) +} + +// setup configures a new FileManager middleware instance. +func setup(c *caddy.Controller) error { + configs, err := 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 +} + +func parse(c *caddy.Controller) ([]*config, error) { + var ( + configs []*config + ) + + for c.Next() { + baseURL := "/" + baseScope := "." + database := "" + + // Get the baseURL and baseScope + args := c.RemainingArgs() + + if len(args) == 1 { + baseURL = args[0] + } + + if len(args) > 1 { + baseScope = args[1] + } + + for c.NextBlock() { + switch c.Val() { + case "database": + if !c.NextArg() { + return nil, c.ArgErr() + } + + database = c.Val() + } + } + + 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 instace at " + database + + ". It is highly recommended that you set the 'database' option to '" + sha + ".db'\n") + } + + fm, err := New(database, User{ + Username: "admin", + Password: "admin", + AllowCommands: true, + AllowEdit: true, + AllowNew: true, + Commands: []string{"git", "svn", "hg"}, + Rules: []*Rule{{ + Regex: true, + Allow: false, + Regexp: &Regexp{Raw: "\\/\\..+"}, + }}, + CSS: "", + FileSystem: webdav.Dir(baseScope), + }) + + if err != nil { + return nil, err + } + + m := &config{FileManager: fm} + m.SetBaseURL(baseURL) + m.SetPrefixURL(strings.TrimSuffix(caddyConf.Addr.Path, "/")) + m.baseURL = strings.TrimSuffix(baseURL, "/") + + configs = append(configs, m) + } + + return configs, nil +} diff --git a/caddy/hugo/README.md b/caddy/hugo/README.md new file mode 100644 index 00000000..7f055085 --- /dev/null +++ b/caddy/hugo/README.md @@ -0,0 +1,76 @@ +# hugo - a caddy plugin + +[![community](https://img.shields.io/badge/community-forum-ff69b4.svg?style=flat-square)](https://caddy.community) + +hugo fills the gap between Hugo and the browser. [Hugo][6] is an easy and fast static website generator. This plugin fills the gap between Hugo and the end-user, providing you a web interface to manage the whole website. + +Using this plugin, you won't need to have your own computer to edit posts, neither regenerate your static website, because you can do all of that just through your browser. It is an implementation of [hacdias/filemanager][1] library. + +**Requirements:** you need to have the hugo executable in your PATH. You can download it from its [official page][6]. + +## Get Started + +To start using this plugin you just need to go to the [download Caddy page][3] and choose `http.hugo` in the directives section. For further information on how Caddy works refer to [its documentation][4]. + +The default credentials are `admin` for both the user and the password. It is highy recommended to change them after logging in for the first time and to use HTTPS. You can create more users and define their own permissions using the web interface. + +## Syntax + +``` +hugo [directory] [admin] { + database path +} +``` + ++ `directory` is the path, relative or absolute to the directory of your Hugo files. Defaults to `./`. ++ `admin` is the URL path where you will access the admin interface. Defaults to `/admin`. ++ `path` is the database path where the settings will be stored. By default, the settings will be stored on [`.caddy`][5] folder. + +## Database + +By default the database will be stored on [`.caddy`][5] directory, in a sub-directory called `hugo`. Each file name is an hash of the combination of the host and the base URL. + +If you don't set a database path, you will receive a warning like this: + +> [WARNING] A database is going to be created for your File Manager instace at ~/.caddy/hugo/xxx.db. It is highly recommended that you set the 'database' option to 'xxx.db' + +Why? If you don't set a database path and you change the host or the base URL, your settings will be reseted. So it is *highly* recommended to set this option. + +When you set a relative path, such as `xxxxxxxxxx.db`, it will always be relative to `.caddy/hugo` directory. Although, you may also use an absolute path if you wish to store the database in other place. + +## Examples + +Manage the current working directory's Hugo website at `/admin`. + +``` +hugo { + database myinstance.db +} +``` + +Manage the Hugo website located at `/var/www/mysite` at `/admin`. + +``` +hugo /var/www/mysite { + database myinstance.db +} +``` + +Manage the Hugo website located at `/var/www/mysite` at `/private`. + +``` +hugo /var/www/mysite /private { + database myinstance.db +} +``` + +## Known Issues + +If you are having troubles **handling large files** you might need to check out the [`timeouts`][2] plugin, which can be used to change the default HTTP Timeouts. + +[1]:https://github.com/hacdias/filemanager +[2]:https://caddyserver.com/docs/timeouts +[3]:https://caddyserver.com/download +[4]:https://caddyserver.com/docs +[5]:https://caddyserver.com/docs/automatic-https#dot-caddy +[6]:http://gohugo.io diff --git a/caddy/hugo/hugo.go b/caddy/hugo/hugo.go new file mode 100644 index 00000000..b20852cd --- /dev/null +++ b/caddy/hugo/hugo.go @@ -0,0 +1,194 @@ +package hugo + +import ( + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + rice "github.com/GeertJohan/go.rice" + "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/variables" + "github.com/robfig/cron" +) + +type hugo struct { + // Website root + Root string `description:"The relative or absolute path to the place where your website is located."` + // Public folder + Public string `description:"The relative or absolute path to the public folder."` + // Hugo executable path + Exe string `description:"The absolute path to the Hugo executable or the command to execute."` + // Hugo arguments + Args []string `description:"The arguments to run when running Hugo"` + // Indicates if we should clean public before a new publish. + CleanPublic bool `description:"Indicates if the public folder should be cleaned before publishing the website."` + + // TODO: admin interface to cgange options +} + +func (h hugo) BeforeAPI(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // 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 == "/settings" { + 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" + } + + r.URL.Path = "/config." + frontmatter + return 0, nil + } + + // From here on, we only care about 'hugo' router so we can bypass + // the others. + if c.Router != "hugo" { + return 0, nil + } + + // 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 http.StatusMethodNotAllowed, nil + } + + // If we are creating a file built from an archetype. + if r.Header.Get("Archetype") != "" { + if !c.User.AllowNew { + return http.StatusForbidden, nil + } + + filename := filepath.Join(string(c.User.FileSystem), r.URL.Path) + filename = strings.TrimPrefix(filename, "/") + 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 := Run(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 + } + + // If we are trying to regenerate the website. + if r.Header.Get("Regenerate") == "true" { + if !c.User.Permissions["allowPublish"] { + return http.StatusForbidden, nil + } + + filename := filepath.Join(string(c.User.FileSystem), r.URL.Path) + filename = strings.TrimPrefix(filename, "/") + + // Before save command handler. + if err := c.FM.Runner("before_publish", filename); err != nil { + return http.StatusInternalServerError, err + } + + // We only run undraft command if it is a file. + if !strings.HasSuffix(filename, "/") { + args := []string{"undraft", filename} + if err := Run(h.Exe, args, h.Root); err != nil { + return http.StatusInternalServerError, err + } + } + + // Regenerates the file + h.run(false) + + // Executed the before publish command. + if err := c.FM.Runner("before_publish", filename); err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil + } + + if r.Header.Get("Schedule") != "" { + if !c.User.Permissions["allowPublish"] { + return http.StatusForbidden, nil + } + + return h.schedule(c, w, r) + } + + return http.StatusNotFound, nil +} + +func (h hugo) AfterAPI(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + return 0, nil +} + +func (h hugo) JavaScript() string { + return rice.MustFindBox("./").MustString("hugo.js") +} + +// run runs Hugo with the define arguments. +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 := variables.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 := Run(h.Exe, h.Args, h.Root); err != nil { + log.Println(err) + } +} + +// schedule schedules a post to be published later. +func (h hugo) schedule(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + t, err := time.Parse("2006-01-02T15:04", r.Header.Get("Schedule")) + path := filepath.Join(string(c.User.FileSystem), r.URL.Path) + path = filepath.Clean(path) + + if err != nil { + return http.StatusInternalServerError, err + } + + scheduler := cron.New() + scheduler.AddFunc(t.Format("05 04 15 02 01 *"), func() { + args := []string{"undraft", path} + if err := Run(h.Exe, args, h.Root); err != nil { + log.Printf(err.Error()) + return + } + + h.run(false) + }) + + scheduler.Start() + return http.StatusOK, nil +} diff --git a/caddy/hugo/hugo.js b/caddy/hugo/hugo.js new file mode 100644 index 00000000..c86a762e --- /dev/null +++ b/caddy/hugo/hugo.js @@ -0,0 +1,227 @@ +'use strict'; + +(function () { + if (window.plugins === undefined || window.plugins === null) { + window.plugins = [] + } + + let regenerate = function (data, url) { + url = data.api.removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${data.store.state.baseURL}/api/hugo${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`) + request.setRequestHeader('Regenerate', 'true') + + request.onload = () => { + if (request.status === 200) { + resolve() + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send() + }) + } + + let newArchetype = function (data, url, type) { + url = data.api.removePrefix(url) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${data.store.state.baseURL}/api/hugo${url}`, true) + request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`) + request.setRequestHeader('Archetype', encodeURIComponent(type)) + + request.onload = () => { + if (request.status === 200) { + resolve(request.getResponseHeader('Location')) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send() + }) + } + + let schedule = function (data, file, date) { + file = data.api.removePrefix(file) + + return new Promise((resolve, reject) => { + let request = new window.XMLHttpRequest() + request.open('POST', `${data.store.state.baseURL}/api/hugo${file}`, true) + request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`) + request.setRequestHeader('Schedule', date) + + request.onload = () => { + if (request.status === 200) { + resolve(request.getResponseHeader('Location')) + } else { + reject(request.responseText) + } + } + + request.onerror = (error) => reject(error) + request.send() + }) + } + + window.plugins.push({ + name: 'hugo', + credits: 'With a flavour of Hugo.', + header: { + visible: [ + { + if: function (data, route) { + return (data.store.state.req.kind === 'editor' && + !data.store.state.loading && + data.store.state.req.metadata !== undefined && + data.store.state.req.metadata !== null && + data.store.state.user.allowEdit & + data.store.state.user.permissions.allowPublish) + }, + click: function (event, data, route) { + event.preventDefault() + document.getElementById('save-button').click() + // TODO: wait for save to finish? + data.buttons.loading('publish') + + regenerate(data, route.path) + .then(() => { + data.buttons.done('publish') + data.store.commit('showSuccess', 'Post published!') + data.store.commit('setReload', true) + }) + .catch((error) => { + data.buttons.done('publish') + data.store.commit('showError', error) + }) + }, + id: 'publish-button', + icon: 'send', + name: 'Publish' + } + ], + hidden: [ + { + if: function (data, route) { + return (data.store.state.req.kind === 'editor' && + !data.store.state.loading && + data.store.state.req.metadata !== undefined && + data.store.state.req.metadata !== null && + data.store.state.user.permissions.allowPublish) + }, + click: function (event, data, route) { + document.getElementById('save-button').click() + data.store.commit('showHover', 'schedule') + }, + id: 'schedule-button', + icon: 'alarm', + name: 'Schedule' + } + ] + }, + sidebar: [ + { + click: function (event, data, route) { + data.router.push({ path: '/files/settings' }) + }, + icon: 'settings', + name: 'Hugo Settings' + }, + { + click: function (event, data, route) { + data.store.commit('showHover', 'new-archetype') + }, + if: function (data, route) { + return data.store.state.user.allowNew + }, + icon: 'merge_type', + name: 'Hugo New' + } /* , + { + click: function (event, data, route) { + console.log('evt') + }, + icon: 'remove_red_eye', + name: 'Preview' + } */ + ], + prompts: [ + { + name: 'new-archetype', + title: 'New file', + description: 'Create a new post based on an archetype. Your file will be created on content folder.', + inputs: [ + { + type: 'text', + name: 'file', + placeholder: 'File name' + }, + { + type: 'text', + name: 'archetype', + placeholder: 'Archetype' + } + ], + ok: 'Create', + submit: function (event, data, route) { + event.preventDefault() + + let file = event.currentTarget.querySelector('[name="file"]').value + let type = event.currentTarget.querySelector('[name="archetype"]').value + if (type === '') type = 'default' + + data.store.commit('closeHovers') + + newArchetype(data, '/' + file, type) + .then((url) => { + data.router.push({ path: url }) + }) + .catch(error => { + data.store.commit('showError', error) + }) + } + }, + { + name: 'schedule', + title: 'Schedule', + description: 'Pick a date and time to schedule the publication of this post.', + inputs: [ + { + type: 'datetime-local', + name: 'date', + placeholder: 'Date' + } + ], + ok: 'Schedule', + submit: function (event, data, route) { + event.preventDefault() + data.buttons.loading('schedule') + + let date = event.currentTarget.querySelector('[name="date"]').value + if (date === '') { + data.buttons.done('schedule') + data.store.commit('showError', 'The date must not be empty.') + return + } + + schedule(data, route.path, date) + .then(() => { + data.buttons.done('schedule') + data.store.commit('showSuccess', 'Post scheduled!') + }) + .catch((error) => { + data.buttons.done('schedule') + data.store.commit('showError', error) + }) + } + } + ] + }) +})() diff --git a/caddy/hugo/rice-box.go b/caddy/hugo/rice-box.go new file mode 100644 index 00000000..4bd515d4 --- /dev/null +++ b/caddy/hugo/rice-box.go @@ -0,0 +1,69 @@ +package hugo + +import ( + "github.com/GeertJohan/go.rice/embedded" + "time" +) + +func init() { + + // define files + file2 := &embedded.EmbeddedFile{ + Filename: `README.md`, + FileModTime: time.Unix(1500364498, 0), + Content: string("# hugo - a caddy plugin\n\n[![community](https://img.shields.io/badge/community-forum-ff69b4.svg?style=flat-square)](https://caddy.community)\n\nhugo fills the gap between Hugo and the browser. [Hugo][6] is an easy and fast static website generator. This plugin fills the gap between Hugo and the end-user, providing you a web interface to manage the whole website.\n\nUsing this plugin, you won't need to have your own computer to edit posts, neither regenerate your static website, because you can do all of that just through your browser. It is an implementation of [hacdias/filemanager][1] library.\n\n**Requirements:** you need to have the hugo executable in your PATH. You can download it from its [official page][6].\n\n## Get Started\n\nTo start using this plugin you just need to go to the [download Caddy page][3] and choose `http.hugo` in the directives section. For further information on how Caddy works refer to [its documentation][4].\n\nThe default credentials are `admin` for both the user and the password. It is highy recommended to change them after logging in for the first time and to use HTTPS. You can create more users and define their own permissions using the web interface.\n\n## Syntax\n\n```\nhugo [directory] [admin] {\n database path\n}\n```\n\n+ `directory` is the path, relative or absolute to the directory of your Hugo files. Defaults to `./`.\n+ `admin` is the URL path where you will access the admin interface. Defaults to `/admin`.\n+ `path` is the database path where the settings will be stored. By default, the settings will be stored on [`.caddy`][5] folder.\n\n## Database\n\nBy default the database will be stored on [`.caddy`][5] directory, in a sub-directory called `hugo`. Each file name is an hash of the combination of the host and the base URL.\n\nIf you don't set a database path, you will receive a warning like this:\n\n> [WARNING] A database is going to be created for your File Manager instace at ~/.caddy/hugo/xxx.db. It is highly recommended that you set the 'database' option to 'xxx.db'\n\nWhy? If you don't set a database path and you change the host or the base URL, your settings will be reseted. So it is *highly* recommended to set this option.\n\nWhen you set a relative path, such as `xxxxxxxxxx.db`, it will always be relative to `.caddy/hugo` directory. Although, you may also use an absolute path if you wish to store the database in other place.\n\n## Examples\n\nManage the current working directory's Hugo website at `/admin`.\n\n```\nhugo {\n database myinstance.db\n}\n```\n\nManage the Hugo website located at `/var/www/mysite` at `/admin`.\n\n```\nhugo /var/www/mysite {\n database myinstance.db\n}\n```\n\nManage the Hugo website located at `/var/www/mysite` at `/private`.\n\n```\nhugo /var/www/mysite /private {\n database myinstance.db\n}\n```\n\n## Known Issues\n\nIf you are having troubles **handling large files** you might need to check out the [`timeouts`][2] plugin, which can be used to change the default HTTP Timeouts.\n\n[1]:https://github.com/hacdias/filemanager\n[2]:https://caddyserver.com/docs/timeouts\n[3]:https://caddyserver.com/download\n[4]:https://caddyserver.com/docs\n[5]:https://caddyserver.com/docs/automatic-https#dot-caddy\n[6]:http://gohugo.io\n"), + } + file3 := &embedded.EmbeddedFile{ + Filename: `hugo.go`, + FileModTime: time.Unix(1500017442, 0), + Content: string("package hugo\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\trice \"github.com/GeertJohan/go.rice\"\n\t\"github.com/hacdias/filemanager\"\n\t\"github.com/hacdias/filemanager/variables\"\n\t\"github.com/robfig/cron\"\n)\n\ntype hugo struct {\n\t// Website root\n\tRoot string `description:\"The relative or absolute path to the place where your website is located.\"`\n\t// Public folder\n\tPublic string `description:\"The relative or absolute path to the public folder.\"`\n\t// Hugo executable path\n\tExe string `description:\"The absolute path to the Hugo executable or the command to execute.\"`\n\t// Hugo arguments\n\tArgs []string `description:\"The arguments to run when running Hugo\"`\n\t// Indicates if we should clean public before a new publish.\n\tCleanPublic bool `description:\"Indicates if the public folder should be cleaned before publishing the website.\"`\n\n\t// TODO: admin interface to cgange options\n}\n\nfunc (h hugo) BeforeAPI(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) {\n\t// If we are using the 'magic url' for the settings, we should redirect the\n\t// request for the acutual path.\n\tif r.URL.Path == \"/settings/\" || r.URL.Path == \"/settings\" {\n\t\tvar frontmatter string\n\t\tvar err error\n\n\t\tif _, err = os.Stat(filepath.Join(h.Root, \"config.yaml\")); err == nil {\n\t\t\tfrontmatter = \"yaml\"\n\t\t}\n\n\t\tif _, err = os.Stat(filepath.Join(h.Root, \"config.json\")); err == nil {\n\t\t\tfrontmatter = \"json\"\n\t\t}\n\n\t\tif _, err = os.Stat(filepath.Join(h.Root, \"config.toml\")); err == nil {\n\t\t\tfrontmatter = \"toml\"\n\t\t}\n\n\t\tr.URL.Path = \"/config.\" + frontmatter\n\t\treturn 0, nil\n\t}\n\n\t// From here on, we only care about 'hugo' router so we can bypass\n\t// the others.\n\tif c.Router != \"hugo\" {\n\t\treturn 0, nil\n\t}\n\n\t// If we are not using HTTP Post, we shall return Method Not Allowed\n\t// since we are only working with this method.\n\tif r.Method != http.MethodPost {\n\t\treturn http.StatusMethodNotAllowed, nil\n\t}\n\n\t// If we are creating a file built from an archetype.\n\tif r.Header.Get(\"Archetype\") != \"\" {\n\t\tif !c.User.AllowNew {\n\t\t\treturn http.StatusForbidden, nil\n\t\t}\n\n\t\tfilename := filepath.Join(string(c.User.FileSystem), r.URL.Path)\n\t\tfilename = strings.TrimPrefix(filename, \"/\")\n\t\tarchetype := r.Header.Get(\"archetype\")\n\n\t\text := filepath.Ext(filename)\n\n\t\t// If the request isn't for a markdown file, we can't\n\t\t// handle it.\n\t\tif ext != \".markdown\" && ext != \".md\" {\n\t\t\treturn http.StatusBadRequest, errUnsupportedFileType\n\t\t}\n\n\t\t// Tries to create a new file based on this archetype.\n\t\targs := []string{\"new\", filename, \"--kind\", archetype}\n\t\tif err := Run(h.Exe, args, h.Root); err != nil {\n\t\t\treturn http.StatusInternalServerError, err\n\t\t}\n\n\t\t// Writes the location of the new file to the Header.\n\t\tw.Header().Set(\"Location\", \"/files/content/\"+filename)\n\t\treturn http.StatusCreated, nil\n\t}\n\n\t// If we are trying to regenerate the website.\n\tif r.Header.Get(\"Regenerate\") == \"true\" {\n\t\tif !c.User.Permissions[\"allowPublish\"] {\n\t\t\treturn http.StatusForbidden, nil\n\t\t}\n\n\t\tfilename := filepath.Join(string(c.User.FileSystem), r.URL.Path)\n\t\tfilename = strings.TrimPrefix(filename, \"/\")\n\n\t\t// Before save command handler.\n\t\tif err := c.FM.Runner(\"before_publish\", filename); err != nil {\n\t\t\treturn http.StatusInternalServerError, err\n\t\t}\n\n\t\t// We only run undraft command if it is a file.\n\t\tif !strings.HasSuffix(filename, \"/\") {\n\t\t\targs := []string{\"undraft\", filename}\n\t\t\tif err := Run(h.Exe, args, h.Root); err != nil {\n\t\t\t\treturn http.StatusInternalServerError, err\n\t\t\t}\n\t\t}\n\n\t\t// Regenerates the file\n\t\th.run(false)\n\n\t\t// Executed the before publish command.\n\t\tif err := c.FM.Runner(\"before_publish\", filename); err != nil {\n\t\t\treturn http.StatusInternalServerError, err\n\t\t}\n\n\t\treturn http.StatusOK, nil\n\t}\n\n\tif r.Header.Get(\"Schedule\") != \"\" {\n\t\tif !c.User.Permissions[\"allowPublish\"] {\n\t\t\treturn http.StatusForbidden, nil\n\t\t}\n\n\t\treturn h.schedule(c, w, r)\n\t}\n\n\treturn http.StatusNotFound, nil\n}\n\nfunc (h hugo) AfterAPI(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) {\n\treturn 0, nil\n}\n\nfunc (h hugo) JavaScript() string {\n\treturn rice.MustFindBox(\"./\").MustString(\"hugo.js\")\n}\n\n// run runs Hugo with the define arguments.\nfunc (h hugo) run(force bool) {\n\t// If the CleanPublic option is enabled, clean it.\n\tif h.CleanPublic {\n\t\tos.RemoveAll(h.Public)\n\t}\n\n\t// Prevent running if watching is enabled\n\tif b, pos := variables.StringInSlice(\"--watch\", h.Args); b && !force {\n\t\tif len(h.Args) > pos && h.Args[pos+1] != \"false\" {\n\t\t\treturn\n\t\t}\n\n\t\tif len(h.Args) == pos+1 {\n\t\t\treturn\n\t\t}\n\t}\n\n\tif err := Run(h.Exe, h.Args, h.Root); err != nil {\n\t\tlog.Println(err)\n\t}\n}\n\n// schedule schedules a post to be published later.\nfunc (h hugo) schedule(c *filemanager.RequestContext, w http.ResponseWriter, r *http.Request) (int, error) {\n\tt, err := time.Parse(\"2006-01-02T15:04\", r.Header.Get(\"Schedule\"))\n\tpath := filepath.Join(string(c.User.FileSystem), r.URL.Path)\n\tpath = filepath.Clean(path)\n\n\tif err != nil {\n\t\treturn http.StatusInternalServerError, err\n\t}\n\n\tscheduler := cron.New()\n\tscheduler.AddFunc(t.Format(\"05 04 15 02 01 *\"), func() {\n\t\targs := []string{\"undraft\", path}\n\t\tif err := Run(h.Exe, args, h.Root); err != nil {\n\t\t\tlog.Printf(err.Error())\n\t\t\treturn\n\t\t}\n\n\t\th.run(false)\n\t})\n\n\tscheduler.Start()\n\treturn http.StatusOK, nil\n}\n"), + } + file4 := &embedded.EmbeddedFile{ + Filename: `hugo.js`, + FileModTime: time.Unix(1500385310, 0), + Content: string("'use strict';\n\n(function () {\n if (window.plugins === undefined || window.plugins === null) {\n window.plugins = []\n }\n\n let regenerate = function (data, url) {\n url = data.api.removePrefix(url)\n\n return new Promise((resolve, reject) => {\n let request = new window.XMLHttpRequest()\n request.open('POST', `${data.store.state.baseURL}/api/hugo${url}`, true)\n request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`)\n request.setRequestHeader('Regenerate', 'true')\n\n request.onload = () => {\n if (request.status === 200) {\n resolve()\n } else {\n reject(request.responseText)\n }\n }\n\n request.onerror = (error) => reject(error)\n request.send()\n })\n }\n\n let newArchetype = function (data, url, type) {\n url = data.api.removePrefix(url)\n\n return new Promise((resolve, reject) => {\n let request = new window.XMLHttpRequest()\n request.open('POST', `${data.store.state.baseURL}/api/hugo${url}`, true)\n request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`)\n request.setRequestHeader('Archetype', encodeURIComponent(type))\n\n request.onload = () => {\n if (request.status === 200) {\n resolve(request.getResponseHeader('Location'))\n } else {\n reject(request.responseText)\n }\n }\n\n request.onerror = (error) => reject(error)\n request.send()\n })\n }\n\n let schedule = function (data, file, date) {\n file = data.api.removePrefix(file)\n\n return new Promise((resolve, reject) => {\n let request = new window.XMLHttpRequest()\n request.open('POST', `${data.store.state.baseURL}/api/hugo${file}`, true)\n request.setRequestHeader('Authorization', `Bearer ${data.store.state.jwt}`)\n request.setRequestHeader('Schedule', date)\n\n request.onload = () => {\n if (request.status === 200) {\n resolve(request.getResponseHeader('Location'))\n } else {\n reject(request.responseText)\n }\n }\n\n request.onerror = (error) => reject(error)\n request.send()\n })\n }\n\n window.plugins.push({\n name: 'hugo',\n credits: 'With a flavour of Hugo.',\n header: {\n visible: [\n {\n if: function (data, route) {\n return (data.store.state.req.kind === 'editor' &&\n !data.store.state.loading &&\n data.store.state.req.metadata !== undefined &&\n data.store.state.req.metadata !== null &&\n data.store.state.user.allowEdit &\n data.store.state.user.permissions.allowPublish)\n },\n click: function (event, data, route) {\n event.preventDefault()\n document.getElementById('save-button').click()\n // TODO: wait for save to finish?\n data.buttons.loading('publish')\n\n regenerate(data, route.path)\n .then(() => {\n data.buttons.done('publish')\n data.store.commit('showSuccess', 'Post published!')\n data.store.commit('setReload', true)\n })\n .catch((error) => {\n data.buttons.done('publish')\n data.store.commit('showError', error)\n })\n },\n id: 'publish-button',\n icon: 'send',\n name: 'Publish'\n }\n ],\n hidden: [\n {\n if: function (data, route) {\n return (data.store.state.req.kind === 'editor' &&\n !data.store.state.loading &&\n data.store.state.req.metadata !== undefined &&\n data.store.state.req.metadata !== null &&\n data.store.state.user.permissions.allowPublish)\n },\n click: function (event, data, route) {\n document.getElementById('save-button').click()\n data.store.commit('showHover', 'schedule')\n },\n id: 'schedule-button',\n icon: 'alarm',\n name: 'Schedule'\n }\n ]\n },\n sidebar: [\n {\n click: function (event, data, route) {\n data.router.push({ path: '/files/settings' })\n },\n icon: 'settings',\n name: 'Hugo Settings'\n },\n {\n click: function (event, data, route) {\n data.store.commit('showHover', 'new-archetype')\n },\n if: function (data, route) {\n return data.store.state.user.allowNew\n },\n icon: 'merge_type',\n name: 'Hugo New'\n } /* ,\n {\n click: function (event, data, route) {\n console.log('evt')\n },\n icon: 'remove_red_eye',\n name: 'Preview'\n } */\n ],\n prompts: [\n {\n name: 'new-archetype',\n title: 'New file',\n description: 'Create a new post based on an archetype. Your file will be created on content folder.',\n inputs: [\n {\n type: 'text',\n name: 'file',\n placeholder: 'File name'\n },\n {\n type: 'text',\n name: 'archetype',\n placeholder: 'Archetype'\n }\n ],\n ok: 'Create',\n submit: function (event, data, route) {\n event.preventDefault()\n\n let file = event.currentTarget.querySelector('[name=\"file\"]').value\n let type = event.currentTarget.querySelector('[name=\"archetype\"]').value\n if (type === '') type = 'default'\n\n data.store.commit('closeHovers')\n\n newArchetype(data, '/' + file, type)\n .then((url) => {\n data.router.push({ path: url })\n })\n .catch(error => {\n data.store.commit('showError', error)\n })\n }\n },\n {\n name: 'schedule',\n title: 'Schedule',\n description: 'Pick a date and time to schedule the publication of this post.',\n inputs: [\n {\n type: 'datetime-local',\n name: 'date',\n placeholder: 'Date'\n }\n ],\n ok: 'Schedule',\n submit: function (event, data, route) {\n event.preventDefault()\n data.buttons.loading('schedule')\n\n let date = event.currentTarget.querySelector('[name=\"date\"]').value\n if (date === '') {\n data.buttons.done('schedule')\n data.store.commit('showError', 'The date must not be empty.')\n return\n }\n\n schedule(data, route.path, date)\n .then(() => {\n data.buttons.done('schedule')\n data.store.commit('showSuccess', 'Post scheduled!')\n })\n .catch((error) => {\n data.buttons.done('schedule')\n data.store.commit('showError', error)\n })\n }\n }\n ]\n })\n})()\n"), + } + file5 := &embedded.EmbeddedFile{ + Filename: `setup.go`, + FileModTime: time.Unix(1500361773, 0), + Content: string("package hugo\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/hacdias/filemanager\"\n\t\"github.com/mholt/caddy\"\n\t\"github.com/mholt/caddy/caddyhttp/httpserver\"\n\t\"golang.org/x/net/webdav\"\n)\n\nvar (\n\terrHugoNotFound = errors.New(\"It seems that tou don't have 'hugo' on your PATH\")\n\terrUnsupportedFileType = errors.New(\"The type of the provided file isn't supported for this action\")\n)\n\n// setup configures a new FileManager middleware instance.\nfunc setup(c *caddy.Controller) error {\n\tconfigs, err := parse(c)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thttpserver.GetConfig(c).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {\n\t\treturn plugin{Configs: configs, Next: next}\n\t})\n\n\treturn nil\n}\n\nfunc parse(c *caddy.Controller) ([]*filemanager.FileManager, error) {\n\tvar (\n\t\tconfigs []*filemanager.FileManager\n\t)\n\n\tfor c.Next() {\n\t\t// hugo [directory] [admin] {\n\t\t// \t\tdatabase path\n\t\t// }\n\t\tdirectory := \".\"\n\t\tadmin := \"/admin\"\n\t\tdatabase := \"\"\n\n\t\t// Get the baseURL and baseScope\n\t\targs := c.RemainingArgs()\n\n\t\tif len(args) == 1 {\n\t\t\tdirectory = args[0]\n\t\t}\n\n\t\tif len(args) > 1 {\n\t\t\tadmin = args[1]\n\t\t}\n\n\t\tfor c.NextBlock() {\n\t\t\tswitch c.Val() {\n\t\t\tcase \"database\":\n\t\t\t\tif !c.NextArg() {\n\t\t\t\t\treturn nil, c.ArgErr()\n\t\t\t\t}\n\n\t\t\t\tdatabase = c.Val()\n\t\t\t}\n\t\t}\n\n\t\tcaddyConf := httpserver.GetConfig(c)\n\n\t\tpath := filepath.Join(caddy.AssetsPath(), \"hugo\")\n\t\terr := os.MkdirAll(path, 0700)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// if there is a database path and it is not absolute,\n\t\t// it will be relative to \".caddy\" folder.\n\t\tif !filepath.IsAbs(database) && database != \"\" {\n\t\t\tdatabase = filepath.Join(path, database)\n\t\t}\n\n\t\t// If there is no database path on the settings,\n\t\t// store one in .caddy/hugo/{name}.db.\n\t\tif database == \"\" {\n\t\t\t// The name of the database is the hashed value of a string composed\n\t\t\t// by the host, address path and the baseurl of this File Manager\n\t\t\t// instance.\n\t\t\thasher := md5.New()\n\t\t\thasher.Write([]byte(caddyConf.Addr.Host + caddyConf.Addr.Path + admin))\n\t\t\tsha := hex.EncodeToString(hasher.Sum(nil))\n\t\t\tdatabase = filepath.Join(path, sha+\".db\")\n\n\t\t\tfmt.Println(\"[WARNING] A database is going to be created for your Hugo instace at \" + database +\n\t\t\t\t\". It is highly recommended that you set the 'database' option to '\" + sha + \".db'\\n\")\n\t\t}\n\n\t\tm, err := filemanager.New(database, filemanager.User{\n\t\t\tUsername: \"admin\",\n\t\t\tPassword: \"admin\",\n\t\t\tAllowCommands: true,\n\t\t\tAllowEdit: true,\n\t\t\tAllowNew: true,\n\t\t\tPermissions: map[string]bool{},\n\t\t\tCommands: []string{\"git\", \"svn\", \"hg\"},\n\t\t\tRules: []*filemanager.Rule{{\n\t\t\t\tRegex: true,\n\t\t\t\tAllow: false,\n\t\t\t\tRegexp: &filemanager.Regexp{Raw: \"\\\\/\\\\..+\"},\n\t\t\t}},\n\t\t\tCSS: \"\",\n\t\t\tFileSystem: webdav.Dir(directory),\n\t\t})\n\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// Initialize the default settings for Hugo.\n\t\thugo := &hugo{\n\t\t\tRoot: directory,\n\t\t\tPublic: filepath.Join(directory, \"public\"),\n\t\t\tArgs: []string{},\n\t\t\tCleanPublic: true,\n\t\t}\n\n\t\t// Try to find the Hugo executable path.\n\t\tif hugo.Exe, err = exec.LookPath(\"hugo\"); err != nil {\n\t\t\treturn nil, errHugoNotFound\n\t\t}\n\n\t\terr = m.RegisterPlugin(\"hugo\", hugo)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\terr = m.RegisterEventType(\"before_publish\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\terr = m.RegisterEventType(\"after_publish\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\terr = m.RegisterPermission(\"allowPublish\", true)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tm.SetBaseURL(admin)\n\t\tm.SetPrefixURL(strings.TrimSuffix(caddyConf.Addr.Path, \"/\"))\n\t\tconfigs = append(configs, m)\n\t}\n\n\treturn configs, nil\n}\n\n// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.\nfunc (p plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {\n\tfor i := range p.Configs {\n\t\t// Checks if this Path should be handled by File Manager.\n\t\tif !httpserver.Path(r.URL.Path).Matches(p.Configs[i].BaseURL) {\n\t\t\tcontinue\n\t\t}\n\n\t\treturn p.Configs[i].ServeHTTP(w, r)\n\t}\n\n\treturn p.Next.ServeHTTP(w, r)\n}\n\nfunc init() {\n\tcaddy.RegisterPlugin(\"hugo\", caddy.Plugin{\n\t\tServerType: \"http\",\n\t\tAction: setup,\n\t})\n}\n\ntype plugin struct {\n\tNext httpserver.Handler\n\tConfigs []*filemanager.FileManager\n}\n"), + } + file6 := &embedded.EmbeddedFile{ + Filename: `utils.go`, + FileModTime: time.Unix(1499867704, 0), + Content: string("package hugo\n\nimport (\n\t\"errors\"\n\t\"os/exec\"\n)\n\n// Run executes an external command\nfunc Run(command string, args []string, path string) error {\n\tcmd := exec.Command(command, args...)\n\tcmd.Dir = path\n\tout, err := cmd.CombinedOutput()\n\n\tif err != nil {\n\t\treturn errors.New(string(out))\n\t}\n\n\treturn nil\n}\n"), + } + + // define dirs + dir1 := &embedded.EmbeddedDir{ + Filename: ``, + DirModTime: time.Unix(1499788789, 0), + ChildFiles: []*embedded.EmbeddedFile{ + file2, // README.md + file3, // hugo.go + file4, // hugo.js + file5, // setup.go + file6, // utils.go + + }, + } + + // link ChildDirs + dir1.ChildDirs = []*embedded.EmbeddedDir{} + + // register embeddedBox + embedded.RegisterEmbeddedBox(`./`, &embedded.EmbeddedBox{ + Name: `./`, + Time: time.Unix(1499788789, 0), + Dirs: map[string]*embedded.EmbeddedDir{ + "": dir1, + }, + Files: map[string]*embedded.EmbeddedFile{ + "README.md": file2, + "hugo.go": file3, + "hugo.js": file4, + "setup.go": file5, + "utils.go": file6, + }, + }) +} diff --git a/caddy/hugo/setup.go b/caddy/hugo/setup.go new file mode 100644 index 00000000..aca848ff --- /dev/null +++ b/caddy/hugo/setup.go @@ -0,0 +1,189 @@ +package hugo + +import ( + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/hacdias/filemanager" + "github.com/mholt/caddy" + "github.com/mholt/caddy/caddyhttp/httpserver" + "golang.org/x/net/webdav" +) + +var ( + errHugoNotFound = errors.New("It seems that tou don't have 'hugo' on your PATH") + errUnsupportedFileType = errors.New("The type of the provided file isn't supported for this action") +) + +// setup configures a new FileManager middleware instance. +func setup(c *caddy.Controller) error { + configs, err := 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 +} + +func parse(c *caddy.Controller) ([]*filemanager.FileManager, error) { + var ( + configs []*filemanager.FileManager + ) + + for c.Next() { + // hugo [directory] [admin] { + // database path + // } + directory := "." + admin := "/admin" + database := "" + + // Get the baseURL and baseScope + args := c.RemainingArgs() + + if len(args) == 1 { + directory = args[0] + } + + if len(args) > 1 { + admin = args[1] + } + + for c.NextBlock() { + switch c.Val() { + case "database": + if !c.NextArg() { + return nil, c.ArgErr() + } + + database = c.Val() + } + } + + caddyConf := httpserver.GetConfig(c) + + path := filepath.Join(caddy.AssetsPath(), "hugo") + 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/hugo/{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 + admin)) + sha := hex.EncodeToString(hasher.Sum(nil)) + database = filepath.Join(path, sha+".db") + + fmt.Println("[WARNING] A database is going to be created for your Hugo instace at " + database + + ". It is highly recommended that you set the 'database' option to '" + sha + ".db'\n") + } + + m, err := filemanager.New(database, filemanager.User{ + Username: "admin", + Password: "admin", + AllowCommands: true, + AllowEdit: true, + AllowNew: true, + Permissions: map[string]bool{}, + Commands: []string{"git", "svn", "hg"}, + Rules: []*filemanager.Rule{{ + Regex: true, + Allow: false, + Regexp: &filemanager.Regexp{Raw: "\\/\\..+"}, + }}, + CSS: "", + FileSystem: webdav.Dir(directory), + }) + + if err != nil { + return nil, err + } + + // Initialize the default settings for Hugo. + hugo := &hugo{ + Root: directory, + Public: filepath.Join(directory, "public"), + Args: []string{}, + CleanPublic: true, + } + + // Try to find the Hugo executable path. + if hugo.Exe, err = exec.LookPath("hugo"); err != nil { + return nil, errHugoNotFound + } + + err = m.RegisterPlugin("hugo", hugo) + if err != nil { + return nil, err + } + + err = m.RegisterEventType("before_publish") + if err != nil { + return nil, err + } + + err = m.RegisterEventType("after_publish") + if err != nil { + return nil, err + } + + err = m.RegisterPermission("allowPublish", true) + if err != nil { + return nil, err + } + + m.SetBaseURL(admin) + m.SetPrefixURL(strings.TrimSuffix(caddyConf.Addr.Path, "/")) + configs = append(configs, m) + } + + return configs, nil +} + +// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. +func (p plugin) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + for i := range p.Configs { + // Checks if this Path should be handled by File Manager. + if !httpserver.Path(r.URL.Path).Matches(p.Configs[i].BaseURL) { + continue + } + + return p.Configs[i].ServeHTTP(w, r) + } + + return p.Next.ServeHTTP(w, r) +} + +func init() { + caddy.RegisterPlugin("hugo", caddy.Plugin{ + ServerType: "http", + Action: setup, + }) +} + +type plugin struct { + Next httpserver.Handler + Configs []*filemanager.FileManager +} diff --git a/caddy/hugo/utils.go b/caddy/hugo/utils.go new file mode 100644 index 00000000..a8bb900b --- /dev/null +++ b/caddy/hugo/utils.go @@ -0,0 +1,19 @@ +package hugo + +import ( + "errors" + "os/exec" +) + +// Run executes an external command +func Run(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 +} diff --git a/command.go b/command.go new file mode 100644 index 00000000..98cb7323 --- /dev/null +++ b/command.go @@ -0,0 +1,135 @@ +package filemanager + +import ( + "bytes" + "net/http" + "os/exec" + "path/filepath" + "strings" + "time" + + "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 *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // Upgrades the connection to a websocket and checks for 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 cmd == command[0] { + allowed = true + } + } + + if !allowed { + err = conn.WriteMessage(websocket.BinaryMessage, cmdNotAllowed) + if err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil + } + + // Check if the program is talled is installed on the computer. + if _, err = exec.LookPath(command[0]); err != nil { + err = conn.WriteMessage(websocket.BinaryMessage, cmdNotImplemented) + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusNotImplemented, nil + } + + // Gets the path and initializes a buffer. + path := string(c.User.FileSystem) + "/" + 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 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 +} diff --git a/download.go b/download.go new file mode 100644 index 00000000..88cb735c --- /dev/null +++ b/download.go @@ -0,0 +1,100 @@ +package filemanager + +import ( + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "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 *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + query := r.URL.Query().Get("format") + + if !c.FI.IsDir { + if r.URL.Query().Get("inline") == "true" { + w.Header().Set("Content-Disposition", "inline") + } else { + w.Header().Set("Content-Disposition", "attachment; filename="+c.FI.Name) + } + + http.ServeFile(w, r, c.FI.Path) + return 0, nil + } + + files := []string{} + names := strings.Split(r.URL.Query().Get("files"), ",") + + if len(names) != 0 { + for _, name := range names { + name, err := url.QueryUnescape(name) + + if err != nil { + return http.StatusInternalServerError, err + } + + files = append(files, filepath.Join(c.FI.Path, name)) + } + + } else { + files = append(files, c.FI.Path) + } + + if query == "true" { + query = "zip" + } + + var ( + extension string + temp string + err error + tempfile string + ) + + temp, err = ioutil.TempDir("", "") + if err != nil { + return http.StatusInternalServerError, err + } + + defer os.RemoveAll(temp) + tempfile = filepath.Join(temp, "temp") + + switch query { + case "zip": + extension, err = ".zip", archiver.Zip.Make(tempfile, files) + case "tar": + extension, err = ".tar", archiver.Tar.Make(tempfile, files) + case "targz": + extension, err = ".tar.gz", archiver.TarGz.Make(tempfile, files) + case "tarbz2": + extension, err = ".tar.bz2", archiver.TarBz2.Make(tempfile, files) + case "tarxz": + extension, err = ".tar.xz", archiver.TarXZ.Make(tempfile, files) + default: + return http.StatusNotImplemented, nil + } + + if err != nil { + return http.StatusInternalServerError, err + } + + file, err := os.Open(temp + "/temp") + if err != nil { + return http.StatusInternalServerError, err + } + + name := c.FI.Name + if name == "." || name == "" { + name = "download" + } + + w.Header().Set("Content-Disposition", "attachment; filename="+name+extension) + io.Copy(w, file) + return 0, nil +} diff --git a/file.go b/file.go new file mode 100644 index 00000000..60e29a89 --- /dev/null +++ b/file.go @@ -0,0 +1,447 @@ +package filemanager + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "errors" + "hash" + "io" + "io/ioutil" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/hugo/parser" +) + +var ( + errInvalidOption = errors.New("Invalid option") +) + +// file contains the information about a particular file or directory. +type file struct { + // Indicates the Kind of view on the front-end (listing, editor or preview). + Kind string `json:"kind"` + // The name of the file. + Name string `json:"name"` + // The Size of the file. + Size int64 `json:"size"` + // The absolute URL. + URL string `json:"url"` + // The extension of the file. + Extension string `json:"extension"` + // The last modified time. + ModTime time.Time `json:"modified"` + // The File Mode. + Mode os.FileMode `json:"mode"` + // Indicates if this file is a directory. + IsDir bool `json:"isDir"` + // Absolute path. + Path string `json:"path"` + // Relative path to user's virtual File System. + VirtualPath string `json:"virtualPath"` + // Indicates the file content type: video, text, image, music or blob. + Type string `json:"type"` + // Stores the content of a text file. + Content string `json:"content,omitempty"` + + *listing `json:",omitempty"` + + Metadata string `json:"metadata,omitempty"` + Language string `json:"language,omitempty"` +} + +// A listing is the context used to fill out a template. +type listing struct { + // The items (files and folders) in the path. + Items []*file `json:"items"` + // The number of directories in the listing. + NumDirs int `json:"numDirs"` + // The number of files (items that aren't directories) in the listing. + NumFiles int `json:"numFiles"` + // Which sorting order is used. + Sort string `json:"sort"` + // And which order. + Order string `json:"order"` + // Displays in mosaic or list. + Display string `json:"display"` +} + +// getInfo gets the file information and, in case of error, returns the +// respective HTTP error code +func getInfo(url *url.URL, c *FileManager, u *User) (*file, error) { + var err error + + i := &file{ + URL: "/files" + url.Path, + VirtualPath: url.Path, + Path: filepath.Join(string(u.FileSystem), url.Path), + } + + info, err := u.FileSystem.Stat(context.TODO(), url.Path) + if err != nil { + return i, err + } + + i.Name = info.Name() + i.ModTime = info.ModTime() + i.Mode = info.Mode() + i.IsDir = info.IsDir() + i.Size = info.Size() + i.Extension = filepath.Ext(i.Name) + + if i.IsDir && !strings.HasSuffix(i.URL, "/") { + i.URL += "/" + } + + return i, nil +} + +// getListing gets the information about a specific directory and its files. +func (i *file) getListing(c *RequestContext, r *http.Request) error { + // Gets the directory information using the Virtual File System of + // the user configuration. + f, err := c.User.FileSystem.OpenFile(context.TODO(), c.FI.VirtualPath, os.O_RDONLY, 0) + if err != nil { + return err + } + defer f.Close() + + // Reads the directory and gets the information about the files. + files, err := f.Readdir(-1) + if err != nil { + return err + } + + var ( + fileinfos []*file + dirCount, fileCount int + ) + + for _, f := range files { + name := f.Name() + allowed := c.User.Allowed("/" + name) + + if !allowed { + continue + } + + if f.IsDir() { + name += "/" + dirCount++ + } else { + fileCount++ + } + + // Absolute URL + url := url.URL{Path: i.URL + name} + + i := &file{ + Name: f.Name(), + Size: f.Size(), + ModTime: f.ModTime(), + Mode: f.Mode(), + IsDir: f.IsDir(), + URL: url.String(), + Extension: filepath.Ext(name), + VirtualPath: filepath.Join(i.VirtualPath, name), + Path: filepath.Join(i.Path, name), + } + + i.RetrieveFileType(false) + fileinfos = append(fileinfos, i) + } + + i.listing = &listing{ + Items: fileinfos, + NumDirs: dirCount, + NumFiles: fileCount, + } + + return nil +} + +// getEditor gets the editor based on a Info struct +func (i *file) getEditor() error { + i.Language = editorLanguage(i.Extension) + // If the editor will hold only content, leave now. + if editorMode(i.Language) == "content" { + return nil + } + + // If the file doesn't have any kind of metadata, leave now. + if !hasRune(i.Content) { + return nil + } + + buffer := bytes.NewBuffer([]byte(i.Content)) + page, err := parser.ReadFrom(buffer) + + // If there is an error, just ignore it and return nil. + // This way, the file can be served for editing. + if err != nil { + + return nil + } + + i.Content = strings.TrimSpace(string(page.Content())) + i.Metadata = strings.TrimSpace(string(page.FrontMatter())) + return nil +} + +// RetrieveFileType obtains the mimetype and converts it to a simple +// type nomenclature. +func (i *file) RetrieveFileType(checkContent bool) error { + var content []byte + var err error + + // Tries to get the file mimetype using its extension. + mimetype := mime.TypeByExtension(i.Extension) + + if mimetype == "" && checkContent { + file, err := os.Open(i.Path) + if err != nil { + return err + } + defer file.Close() + + // Only the first 512 bytes are used to sniff the content type. + buffer := make([]byte, 512) + _, err = file.Read(buffer) + if err != nil && err != io.EOF { + return err + } + + // Tries to get the file mimetype using its first + // 512 bytes. + mimetype = http.DetectContentType(buffer) + } + + if strings.HasPrefix(mimetype, "video") { + i.Type = "video" + return nil + } + + if strings.HasPrefix(mimetype, "audio") { + i.Type = "audio" + return nil + } + + if strings.HasPrefix(mimetype, "image") { + i.Type = "image" + return nil + } + + if strings.HasPrefix(mimetype, "text") { + i.Type = "text" + goto End + } + + if strings.HasPrefix(mimetype, "application/javascript") { + i.Type = "text" + goto End + } + + // If the type isn't text (and is blob for example), it will check some + // common types that are mistaken not to be text. + for _, extension := range textExtensions { + if strings.HasSuffix(i.Name, extension) { + i.Type = "text" + goto End + } + } + + i.Type = "blob" + +End: + // If the file type is text, save its content. + if i.Type == "text" { + if len(content) == 0 { + content, err = ioutil.ReadFile(i.Path) + if err != nil { + return err + } + } + + i.Content = string(content) + } + + return nil +} + +func (i file) Checksum(kind string) (string, error) { + file, err := os.Open(i.Path) + if err != nil { + return "", err + } + + defer file.Close() + + var h hash.Hash + + switch kind { + case "md5": + h = md5.New() + case "sha1": + h = sha1.New() + case "sha256": + h = sha256.New() + case "sha512": + h = sha512.New() + default: + return "", errInvalidOption + } + + _, err = io.Copy(h, file) + if err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// CanBeEdited checks if the extension of a file is supported by the editor +func (i file) CanBeEdited() bool { + return i.Type == "text" +} + +// ApplySort applies the sort order using .Order and .Sort +func (l listing) ApplySort() { + // Check '.Order' to know how to sort + if l.Order == "desc" { + switch l.Sort { + case "name": + sort.Sort(sort.Reverse(byName(l))) + case "size": + sort.Sort(sort.Reverse(bySize(l))) + default: + // If not one of the above, do nothing + return + } + } else { // If we had more Orderings we could add them here + switch l.Sort { + case "name": + sort.Sort(byName(l)) + case "size": + sort.Sort(bySize(l)) + default: + sort.Sort(byName(l)) + return + } + } +} + +// Implement sorting for listing +type byName listing +type bySize listing + +// By Name +func (l byName) Len() int { + return len(l.Items) +} + +func (l byName) Swap(i, j int) { + l.Items[i], l.Items[j] = l.Items[j], l.Items[i] +} + +// Treat upper and lower case equally +func (l byName) Less(i, j int) bool { + if l.Items[i].IsDir && !l.Items[j].IsDir { + return true + } + + if !l.Items[i].IsDir && l.Items[j].IsDir { + return false + } + + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) +} + +// By Size +func (l bySize) Len() int { + return len(l.Items) +} + +func (l bySize) Swap(i, j int) { + l.Items[i], l.Items[j] = l.Items[j], l.Items[i] +} + +const directoryOffset = -1 << 31 // = math.MinInt32 +func (l bySize) Less(i, j int) bool { + iSize, jSize := l.Items[i].Size, l.Items[j].Size + if l.Items[i].IsDir { + iSize = directoryOffset + iSize + } + if l.Items[j].IsDir { + jSize = directoryOffset + jSize + } + return iSize < jSize +} + +var textExtensions = [...]string{ + ".md", ".markdown", ".mdown", ".mmark", + ".asciidoc", ".adoc", ".ad", + ".rst", + ".json", ".toml", ".yaml", ".csv", ".xml", ".rss", ".conf", ".ini", + ".tex", ".sty", + ".css", ".sass", ".scss", + ".js", + ".html", + ".txt", ".rtf", + ".sh", ".bash", ".ps1", ".bat", ".cmd", + ".php", ".pl", ".py", + "Caddyfile", + ".c", ".cc", ".h", ".hh", ".cpp", ".hpp", ".f90", + ".f", ".bas", ".d", ".ada", ".nim", ".cr", ".java", ".cs", ".vala", ".vapi", +} + +// hasRune checks if the file has the frontmatter rune +func hasRune(file string) bool { + return strings.HasPrefix(file, "---") || + strings.HasPrefix(file, "+++") || + strings.HasPrefix(file, "{") +} + +func editorMode(language string) string { + switch language { + case "markdown", "asciidoc", "rst": + return "content+metadata" + } + + return "content" +} + +func editorLanguage(mode string) string { + mode = strings.TrimPrefix(mode, ".") + + switch mode { + case "md", "markdown", "mdown", "mmark": + mode = "markdown" + case "yml": + mode = "yaml" + case "asciidoc", "adoc", "ad": + mode = "asciidoc" + case "rst": + mode = "rst" + case "html", "htm", "xml": + mode = "htmlmixed" + case "js": + mode = "javascript" + case "go": + mode = "golang" + case "": + mode = "text" + } + + return mode +} diff --git a/filemanager.go b/filemanager.go new file mode 100644 index 00000000..7dc7cabd --- /dev/null +++ b/filemanager.go @@ -0,0 +1,423 @@ +package filemanager + +import ( + "errors" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + + rice "github.com/GeertJohan/go.rice" + "github.com/asdine/storm" + "github.com/mholt/caddy" + "golang.org/x/net/webdav" +) + +var ( + // ErrDuplicated occurs when you try to create a user that already exists. + ErrDuplicated = errors.New("Duplicated user") +) + +// FileManager is a file manager instance. It should be creating using the +// 'New' function and not directly. +type FileManager struct { + // The BoltDB database for this instance. + db *storm.DB + + // The key used to sign the JWT tokens. + key []byte + + // The static assets. + assets *rice.Box + + // PrefixURL is a part of the URL that is already trimmed from the request URL before it + // arrives to our handlers. It may be useful when using File Manager as a middleware + // such as in caddy-filemanager plugin. It is only useful in certain situations. + PrefixURL string + + // BaseURL is the path where the GUI will be accessible. It musn't end with + // a trailing slash and mustn't contain PrefixURL, if set. It shouldn't be + // edited directly. Use SetBaseURL. + BaseURL string + + // The Default User needed to build the New User page. + DefaultUser *User + + // Users is a map with the different configurations for each user. + Users map[string]*User + + // A map of events to a slice of commands. + Commands map[string][]string + + // The plugins that have been plugged in. + Plugins map[string]Plugin +} + +// Command is a command function. +type Command func(r *http.Request, m *FileManager, u *User) error + +// User contains the configuration for each user. It should be created +// using NewUser on a File Manager instance. +type User struct { + // ID is the required primary key with auto increment0 + ID int `storm:"id,increment"` + + // Username is the user username used to login. + Username string `json:"username" storm:"index,unique"` + + // The hashed password. This never reaches the front-end because it's temporarily + // emptied during JSON marshall. + Password string `json:"password"` + + // Tells if this user is an admin. + Admin bool `json:"admin"` + + // FileSystem is the virtual file system the user has access. + FileSystem webdav.Dir `json:"filesystem"` + + // Rules is an array of access and deny rules. + Rules []*Rule `json:"rules"` + + // Costum styles for this user. + CSS string `json:"css"` + + // These indicate if the user can perform certain actions. + AllowNew bool `json:"allowNew"` // Create files and folders + AllowEdit bool `json:"allowEdit"` // Edit/rename files + AllowCommands bool `json:"allowCommands"` // Execute commands + Permissions map[string]bool `json:"permissions"` // Permissions added by plugins + + // Commands is the list of commands the user can execute. + Commands []string `json:"commands"` +} + +// Rule is a dissalow/allow rule. +type Rule struct { + // Regex indicates if this rule uses Regular Expressions or not. + Regex bool `json:"regex"` + + // Allow indicates if this is an allow rule. Set 'false' to be a disallow rule. + Allow bool `json:"allow"` + + // Path is the corresponding URL path for this rule. + Path string `json:"path"` + + // Regexp is the regular expression. Only use this when 'Regex' was set to true. + Regexp *Regexp `json:"regexp"` +} + +// Regexp is a regular expression wrapper around native regexp. +type Regexp struct { + Raw string `json:"raw"` + regexp *regexp.Regexp +} + +// Plugin is a File Manager plugin. +type Plugin interface { + // The JavaScript that will be injected into the main page. + JavaScript() string + + // If the Plugin returns (0, nil), the executation of File Manager will procced as usual. + // Otherwise it will stop. + BeforeAPI(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) + AfterAPI(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) +} + +// DefaultUser is used on New, when no 'base' user is provided. +var DefaultUser = User{ + Username: "admin", + Password: "admin", + AllowCommands: true, + AllowEdit: true, + AllowNew: true, + Permissions: map[string]bool{}, + Commands: []string{}, + Rules: []*Rule{}, + CSS: "", + Admin: true, + FileSystem: webdav.Dir("."), +} + +// New creates a new File Manager instance. If 'database' file already +// exists, it will load the users from there. Otherwise, a new user +// will be created using the 'base' variable. The 'base' User should +// not have the Password field hashed. +func New(database string, base User) (*FileManager, error) { + // Creates a new File Manager instance with the Users + // map and Assets box. + m := &FileManager{ + Users: map[string]*User{}, + assets: rice.MustFindBox("./assets/dist"), + Plugins: map[string]Plugin{}, + } + + // Tries to open a database on the location provided. This + // function will automatically create a new one if it doesn't + // exist. + db, err := storm.Open(database) + if err != nil { + return nil, err + } + + // Tries to get the encryption key from the database. + // If it doesn't exist, create a new one of 256 bits. + err = db.Get("config", "key", &m.key) + if err != nil && err == storm.ErrNotFound { + m.key = []byte(randomString(64)) + err = db.Set("config", "key", m.key) + } + + if err != nil { + return nil, err + } + + // Tries to get the event commands from the database. + // If they don't exist, initialize them. + err = db.Get("config", "commands", &m.Commands) + if err != nil && err == storm.ErrNotFound { + m.Commands = map[string][]string{ + "before_save": []string{}, + "after_save": []string{}, + } + err = db.Set("config", "commands", m.Commands) + } + + if err != nil { + return nil, err + } + + // Tries to fetch the users from the database and if there are + // any, add them to the current File Manager instance. + var users []User + err = db.All(&users) + if err != nil { + return nil, err + } + + for i := range users { + m.Users[users[i].Username] = &users[i] + } + + // If there are no users in the database, it creates a new one + // based on 'base' User that must be provided by the function caller. + if len(users) == 0 { + // Hashes the password. + pw, err := hashPassword(base.Password) + if err != nil { + return nil, err + } + + // The first user must be an administrator. + base.Admin = true + base.Password = pw + + // Saves the user to the database. + if err := db.Save(&base); err != nil { + return nil, err + } + + m.Users[base.Username] = &base + } + + // Attaches db to this File Manager instance. + m.db = db + base.Username = "" + base.Password = "" + m.DefaultUser = &base + return m, nil +} + +// RootURL returns the actual URL where +// File Manager interface can be accessed. +func (m FileManager) RootURL() string { + return m.PrefixURL + m.BaseURL +} + +// SetPrefixURL updates the prefixURL of a File +// Manager object. +func (m *FileManager) SetPrefixURL(url string) { + url = strings.TrimPrefix(url, "/") + url = strings.TrimSuffix(url, "/") + url = "/" + url + m.PrefixURL = strings.TrimSuffix(url, "/") +} + +// SetBaseURL updates the baseURL of a File Manager +// object. +func (m *FileManager) SetBaseURL(url string) { + url = strings.TrimPrefix(url, "/") + url = strings.TrimSuffix(url, "/") + url = "/" + url + m.BaseURL = strings.TrimSuffix(url, "/") +} + +// RegisterPlugin registers a plugin to a File Manager instance and +// loads its options from the database. +func (m *FileManager) RegisterPlugin(name string, plugin Plugin) error { + if _, ok := m.Plugins[name]; ok { + return errors.New("Plugin already registred") + } + + err := m.db.Get("plugins", name, &plugin) + if err != nil && err == storm.ErrNotFound { + err = m.db.Set("plugins", name, plugin) + } + + if err != nil { + return err + } + + m.Plugins[name] = plugin + return nil +} + +// RegisterEventType registers a new event type which can be triggered using Runner +// function. +func (m *FileManager) RegisterEventType(name string) error { + if _, ok := m.Commands[name]; ok { + return nil + } + + m.Commands[name] = []string{} + return m.db.Set("config", "commands", m.Commands) +} + +// RegisterPermission registers a new user permission and adds it to every +// user with it default's 'value'. If the user is an admin, it will +// be true. +func (m *FileManager) RegisterPermission(name string, value bool) error { + if _, ok := m.DefaultUser.Permissions[name]; ok { + return nil + } + + // Add the default value for this permission on the default user. + m.DefaultUser.Permissions[name] = value + + for _, u := range m.Users { + // Bypass the user if it is already defined. + if _, ok := u.Permissions[name]; ok { + continue + } + + if u.Permissions == nil { + u.Permissions = m.DefaultUser.Permissions + } + + if u.Admin { + u.Permissions[name] = true + } + + err := m.db.Save(u) + if err != nil { + return err + } + } + + return nil +} + +// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. +func (m *FileManager) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + code, err := serveHTTP(&RequestContext{ + FM: m, + User: nil, + FI: nil, + }, w, r) + + if code != 0 && err != nil { + w.WriteHeader(code) + w.Write([]byte(err.Error())) + return 0, nil + } + + return code, err +} + +// Allowed checks if the user has permission to access a directory/file. +func (u User) Allowed(url string) bool { + var rule *Rule + i := len(u.Rules) - 1 + + for i >= 0 { + rule = u.Rules[i] + + if rule.Regex { + if rule.Regexp.MatchString(url) { + return rule.Allow + } + } else if strings.HasPrefix(url, rule.Path) { + return rule.Allow + } + + i-- + } + + return true +} + +// SetScope updates a user scope and its virtual file system. +// If the user string is blank, it will change the base scope. +func (u *User) SetScope(scope string) { + scope = strings.TrimSuffix(scope, "/") + u.FileSystem = webdav.Dir(scope) +} + +// MatchString checks if this string matches the regular expression. +func (r *Regexp) MatchString(s string) bool { + if r.regexp == nil { + r.regexp = regexp.MustCompile(r.Raw) + } + + return r.regexp.MatchString(s) +} + +// Runner runs the commands for a certain event type. +func (m FileManager) Runner(event string, path string) error { + commands := []string{} + + // Get the commands from the File Manager instance itself. + if val, ok := m.Commands[event]; ok { + commands = append(commands, val...) + } + + // Execute the commands. + for _, command := range commands { + args := strings.Split(command, " ") + nonblock := false + + if len(args) > 1 && args[len(args)-1] == "&" { + // Run command in background; non-blocking + nonblock = true + args = args[:len(args)-1] + } + + command, args, err := caddy.SplitCommandAndArgs(strings.Join(args, " ")) + if err != nil { + return err + } + + cmd := exec.Command(command, args...) + cmd.Env = append(os.Environ(), "file="+path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if nonblock { + log.Printf("[INFO] Nonblocking Command:\"%s %s\"", command, strings.Join(args, " ")) + if err := cmd.Start(); err != nil { + return err + } + + continue + } + + log.Printf("[INFO] Blocking Command:\"%s %s\"", command, strings.Join(args, " ")) + if err := cmd.Run(); err != nil { + return err + } + } + + return nil +} diff --git a/http.go b/http.go new file mode 100644 index 00000000..4e41d245 --- /dev/null +++ b/http.go @@ -0,0 +1,256 @@ +package filemanager + +import ( + "encoding/json" + "html/template" + "net/http" + "os" + "strings" +) + +// RequestContext contains the needed information to make handlers work. +type RequestContext struct { + User *User + FM *FileManager + FI *file + // On API handlers, Router is the APi handler we want. TODO: review this + Router string +} + +// serveHTTP is the main entry point of this HTML application. +func serveHTTP(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // Checks if the URL contains the baseURL and strips it. Otherwise, it just + // returns a 404 error because we're not supposed to be here! + p := strings.TrimPrefix(r.URL.Path, c.FM.BaseURL) + + if len(p) >= len(r.URL.Path) && c.FM.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( + w, + c.FM.assets.MustString(r.URL.Path), + "application/javascript", + c, + ) + } + + // 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) + } + + // Any other request should show the index.html file. + w.Header().Set("x-frame-options", "SAMEORIGIN") + w.Header().Set("x-content-type", "nosniff") + w.Header().Set("x-xss-protection", "1; mode=block") + + return renderFile( + w, + c.FM.assets.MustString("index.html"), + "text/html", + c, + ) +} + +// staticHandler handles the static assets path. +func staticHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if r.URL.Path != "/static/manifest.json" { + http.FileServer(c.FM.assets.HTTPBox()).ServeHTTP(w, r) + return 0, nil + } + + return renderFile( + w, + c.FM.assets.MustString(r.URL.Path), + "application/json", + c, + ) +} + +// apiHandler is the main entry point for the /api endpoint. +func apiHandler(c *RequestContext, 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 = cleanURL(r.URL.Path) + + if !c.User.Allowed(r.URL.Path) { + return http.StatusForbidden, nil + } + + for _, p := range c.FM.Plugins { + code, err := p.BeforeAPI(c, w, r) + if code != 0 || err != nil { + return code, err + } + } + + if c.Router == "checksum" || c.Router == "download" { + var err error + c.FI, err = getInfo(r.URL, c.FM, 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 "commands": + code, err = commandsHandler(c, w, r) + case "plugins": + code, err = pluginsHandler(c, w, r) + } + + if code >= 300 || err != nil { + return code, err + } + + for _, p := range c.FM.Plugins { + code, err := p.AfterAPI(c, w, r) + if code != 0 || err != nil { + return code, err + } + } + + return code, err +} + +// serveChecksum calculates the hash of a file. Supports MD5, SHA1, SHA256 and SHA512. +func checksumHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + query := r.URL.Query().Get("algo") + + val, err := c.FI.Checksum(query) + if err == errInvalidOption { + return http.StatusBadRequest, err + } else if err != nil { + return http.StatusInternalServerError, err + } + + w.Write([]byte(val)) + return 0, nil +} + +// cleanURL splits the path and returns everything that stands +// before the first slash and everything that goes after. +func cleanURL(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:len(path)] +} + +// renderFile renders a file using a template with some needed variables. +func renderFile(w http.ResponseWriter, file string, contentType string, c *RequestContext) (int, error) { + functions := template.FuncMap{ + "JS": func(s string) template.JS { + return template.JS(s) + }, + } + + tpl := template.Must(template.New("file").Funcs(functions).Parse(file)) + w.Header().Set("Content-Type", contentType+"; charset=utf-8") + + err := tpl.Execute(w, map[string]interface{}{ + "BaseURL": c.FM.RootURL(), + "Plugins": c.FM.Plugins, + }) + if err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil +} + +// 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 + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..3ed7d997 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "filemanager", + "version": "1.0.0", + "author": "Henrique Dias ", + "private": true, + "scripts": { + "dev": "node ./assets/build/dev.js", + "build": "node ./assets/build/build.js", + "lint": "eslint --ext .js,.vue assets/src" + }, + "dependencies": { + "codemirror": "^5.27.4", + "filesize": "^3.5.10", + "moment": "^2.18.1", + "normalize.css": "^7.0.0", + "vue": "^2.3.3", + "vue-router": "^2.7.0", + "vuex": "^2.3.1" + }, + "devDependencies": { + "autoprefixer": "^6.7.2", + "babel-core": "^6.22.1", + "babel-eslint": "^7.1.1", + "babel-loader": "^6.2.10", + "babel-plugin-transform-runtime": "^6.22.0", + "babel-preset-env": "^1.3.2", + "babel-preset-stage-2": "^6.22.0", + "babel-register": "^6.22.0", + "chalk": "^1.1.3", + "connect-history-api-fallback": "^1.3.0", + "copy-webpack-plugin": "^4.0.1", + "css-loader": "^0.28.0", + "eslint": "^3.19.0", + "eslint-config-standard": "^6.2.1", + "eslint-friendly-formatter": "^2.0.7", + "eslint-loader": "^1.7.1", + "eslint-plugin-html": "^2.0.0", + "eslint-plugin-promise": "^3.4.0", + "eslint-plugin-standard": "^2.0.1", + "eventsource-polyfill": "^0.9.6", + "express": "^4.14.1", + "extract-text-webpack-plugin": "^2.0.0", + "file-loader": "^0.11.1", + "friendly-errors-webpack-plugin": "^1.1.3", + "html-webpack-plugin": "^2.28.0", + "http-proxy-middleware": "^0.17.3", + "opn": "^4.0.2", + "optimize-css-assets-webpack-plugin": "^1.3.0", + "ora": "^1.2.0", + "rimraf": "^2.6.0", + "semver": "^5.3.0", + "shelljs": "^0.7.6", + "sw-precache-webpack-plugin": "^0.9.1", + "uglify-js": "^3.0.23", + "url-loader": "^0.5.8", + "vue-loader": "^12.1.0", + "vue-style-loader": "^3.0.1", + "vue-template-compiler": "^2.3.3", + "webpack": "^2.6.1", + "webpack-bundle-analyzer": "^2.2.1", + "webpack-dev-middleware": "^1.10.0", + "webpack-hot-middleware": "^2.18.0", + "webpack-merge": "^4.1.0" + }, + "engines": { + "node": ">= 4.0.0", + "npm": ">= 3.0.0" + }, + "postcss": { + "plugins": { + "autoprefixer": {} + } + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not ie <= 8" + ] +} diff --git a/resource.go b/resource.go new file mode 100644 index 00000000..d852024a --- /dev/null +++ b/resource.go @@ -0,0 +1,277 @@ +package filemanager + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +func resourceHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + 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(string(c.User.FileSystem), r.URL.Path) + if err := c.FM.Runner("before_save", path); 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.FM.Runner("after_save", path); 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 *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // Obtains the information of the directory/file. + f, err := getInfo(r.URL, c.FM, 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, + // redirect the user. + 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.FI = f + return listingHandler(c, w, r) + } + + // Tries to get the file type. + if err = f.RetrieveFileType(true); err != nil { + return errorToHTTP(err, true), err + } + + // If it can't be edited or the user isn't allowed to, + // serve it as a listing, with a preview of the file. + if !f.CanBeEdited() || !c.User.AllowEdit { + f.Kind = "preview" + } else { + // Otherwise, we just bring the editor in! + f.Kind = "editor" + + err = f.getEditor() + if err != nil { + return http.StatusInternalServerError, err + } + } + + return renderJSON(w, f) +} + +func listingHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + f := c.FI + f.Kind = "listing" + + err := f.getListing(c, r) + if err != nil { + return errorToHTTP(err, true), err + } + + listing := f.listing + + cookieScope := c.FM.RootURL() + if cookieScope == "" { + cookieScope = "/" + } + + // Copy the query values into the Listing struct + listing.Sort, listing.Order, err = handleSortOrder(w, r, cookieScope) + if err != nil { + return http.StatusBadRequest, err + } + + listing.ApplySort() + listing.Display = displayMode(w, r, cookieScope) + + return renderJSON(w, f) +} + +func resourceDeleteHandler(c *RequestContext, 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 + } + + // Remove the file or folder. + err := c.User.FileSystem.RemoveAll(context.TODO(), r.URL.Path) + if err != nil { + return errorToHTTP(err, true), err + } + + return http.StatusOK, nil +} + +func resourcePostPutHandler(c *RequestContext, 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 + } + + // 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(context.TODO(), r.URL.Path, 0666) + 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 { + if _, err := c.User.FileSystem.Stat(context.TODO(), r.URL.Path); err == nil { + return http.StatusConflict, errors.New("There is already a file on that path") + } + } + + // Create/Open the file. + f, err := c.User.FileSystem.OpenFile(context.TODO(), r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) + defer f.Close() + + if err != nil { + return errorToHTTP(err, false), err + } + + // 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 + } + + // Writes the ETag Header. + etag := fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()) + w.Header().Set("ETag", etag) + return http.StatusOK, nil +} + +func resourcePatchHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.AllowEdit { + return http.StatusForbidden, nil + } + + dst := r.Header.Get("Destination") + dst, err := url.QueryUnescape(dst) + if err != nil { + return errorToHTTP(err, true), err + } + + src := r.URL.Path + + if dst == "/" || src == "/" { + return http.StatusForbidden, nil + } + + err = c.User.FileSystem.Rename(context.TODO(), src, dst) + return errorToHTTP(err, true), err +} + +// displayMode obtaisn the display mode from URL, or from the +// cookie. +func displayMode(w http.ResponseWriter, r *http.Request, scope string) string { + displayMode := r.URL.Query().Get("display") + + if displayMode == "" { + if displayCookie, err := r.Cookie("display"); err == nil { + displayMode = displayCookie.Value + } + } + + if displayMode == "" || (displayMode != "mosaic" && displayMode != "list") { + displayMode = "mosaic" + } + + http.SetCookie(w, &http.Cookie{ + Name: "display", + Value: displayMode, + MaxAge: 31536000, + Path: scope, + Secure: r.TLS != nil, + }) + + return displayMode +} + +// 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 +} diff --git a/rice-box.go.REMOVED.git-id b/rice-box.go.REMOVED.git-id new file mode 100644 index 00000000..4ef4495f --- /dev/null +++ b/rice-box.go.REMOVED.git-id @@ -0,0 +1 @@ +3ced7afedd1119bca314ed59dcd13b35c72f352b \ No newline at end of file diff --git a/search.go b/search.go new file mode 100644 index 00000000..ad626342 --- /dev/null +++ b/search.go @@ -0,0 +1,116 @@ +package filemanager + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gorilla/websocket" +) + +type searchOptions struct { + CaseInsensitive bool + Terms []string +} + +func parseSearch(value string) *searchOptions { + opts := &searchOptions{ + CaseInsensitive: strings.Contains(value, "case:insensitive"), + } + + // removes the options from the value + value = strings.Replace(value, "case:insensitive", "", -1) + value = strings.Replace(value, "case:sensitive", "", -1) + value = strings.TrimSpace(value) + + if opts.CaseInsensitive { + value = strings.ToLower(value) + } + + // 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 *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + // Upgrades the connection to a websocket and checks for 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 = string(c.User.FileSystem) + 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) + is := false + + 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 + } + + return conn.WriteMessage(websocket.TextMessage, []byte(path)) + }) + + if err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil +} diff --git a/settings.go b/settings.go new file mode 100644 index 00000000..542ff2d3 --- /dev/null +++ b/settings.go @@ -0,0 +1,104 @@ +package filemanager + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/mitchellh/mapstructure" +) + +func commandsHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + switch r.Method { + case http.MethodGet: + return commandsGetHandler(c, w, r) + case http.MethodPut: + return commandsPutHandler(c, w, r) + } + + return http.StatusMethodNotAllowed, nil +} + +func commandsGetHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + return renderJSON(w, c.FM.Commands) +} + +func commandsPutHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + if r.Body == nil { + return http.StatusBadGateway, errors.New("Empty request body") + } + + var commands map[string][]string + + // Parses the user and checks for error. + err := json.NewDecoder(r.Body).Decode(&commands) + if err != nil { + return http.StatusBadRequest, errors.New("Invalid JSON") + } + + if err := c.FM.db.Set("config", "commands", commands); err != nil { + return http.StatusInternalServerError, err + } + + c.FM.Commands = commands + return http.StatusOK, nil +} + +func pluginsHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + switch r.Method { + case http.MethodGet: + return pluginsGetHandler(c, w, r) + case http.MethodPut: + return pluginsPutHandler(c, w, r) + } + + return http.StatusMethodNotAllowed, nil +} + +func pluginsGetHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + return renderJSON(w, c.FM.Plugins) +} + +func pluginsPutHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + if r.Body == nil { + return http.StatusBadGateway, errors.New("Empty request body") + } + + var raw map[string]map[string]interface{} + + // Parses the user and checks for error. + err := json.NewDecoder(r.Body).Decode(&raw) + if err != nil { + return http.StatusBadRequest, err + } + + for name, plugin := range raw { + err = mapstructure.Decode(plugin, c.FM.Plugins[name]) + if err != nil { + return http.StatusInternalServerError, err + } + + err = c.FM.db.Set("plugins", name, c.FM.Plugins[name]) + if err != nil { + return http.StatusInternalServerError, err + } + } + + return http.StatusOK, nil +} diff --git a/users.go b/users.go new file mode 100644 index 00000000..a5982f3a --- /dev/null +++ b/users.go @@ -0,0 +1,297 @@ +package filemanager + +import ( + "encoding/json" + "errors" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/asdine/storm" +) + +func usersHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + 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 +} + +// usersGetHandler is used to handle the GET requests for /api/users. It can print a list +// of users or a specific user. The password hash is always removed before being sent to the +// client. +func usersGetHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + // If the request is a list of users. + if r.URL.Path == "/" { + users := []User{} + + for _, user := range c.FM.Users { + // Copies the user and removes the password. + u := *user + u.Password = "" + users = append(users, u) + } + + sort.Slice(users, func(i, j int) bool { + return users[i].ID < users[j].ID + }) + + return renderJSON(w, users) + } + + if r.URL.Path == "/base" { + return renderJSON(w, c.FM.DefaultUser) + } + + // Otherwise we just want one, specific, user. + sid := strings.TrimPrefix(r.URL.Path, "/") + sid = strings.TrimSuffix(sid, "/") + + id, err := strconv.Atoi(sid) + if err != nil { + return http.StatusNotFound, err + } + + // Searches for the user and prints the one who matches. + for _, user := range c.FM.Users { + if user.ID != id { + continue + } + + u := *user + u.Password = "" + return renderJSON(w, u) + } + + // If there aren't any matches, return Not Found. + return http.StatusNotFound, nil +} + +func usersPostHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + // New users should be created on /api/users. + if r.URL.Path != "/" { + return http.StatusMethodNotAllowed, nil + } + + // If the request body is empty, send a Bad Request status. + if r.Body == nil { + return http.StatusBadRequest, nil + } + + var u User + + // Parses the user and checks for error. + err := json.NewDecoder(r.Body).Decode(&u) + if err != nil { + return http.StatusBadRequest, nil + } + + // The username and the password cannot be empty. + if u.Username == "" || u.Password == "" || u.FileSystem == "" { + return http.StatusBadRequest, errors.New("Username, password or scope are empty") + } + + // Initialize rules if they're not initialized. + if u.Rules == nil { + u.Rules = []*Rule{} + } + + // 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 + } + + // Hashes the password. + pw, err := hashPassword(u.Password) + if err != nil { + return http.StatusInternalServerError, err + } + + u.Password = pw + + // Saves the user to the database. + err = c.FM.db.Save(&u) + if err == storm.ErrAlreadyExists { + return http.StatusConflict, err + } + + if err != nil { + return http.StatusInternalServerError, err + } + + // Saves the user to the memory. + c.FM.Users[u.Username] = &u + + // Set the Location header and return. + w.Header().Set("Location", "/users/"+strconv.Itoa(u.ID)) + w.WriteHeader(http.StatusCreated) + return 0, nil +} + +func usersDeleteHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin { + return http.StatusForbidden, nil + } + + // New users should be created on /api/users. + if r.URL.Path == "/" { + return http.StatusMethodNotAllowed, nil + } + + // Otherwise we just want one, specific, user. + sid := strings.TrimPrefix(r.URL.Path, "/") + sid = strings.TrimSuffix(sid, "/") + + id, err := strconv.Atoi(sid) + if err != nil { + return http.StatusNotFound, err + } + + err = c.FM.db.DeleteStruct(&User{ID: id}) + if err == storm.ErrNotFound { + return http.StatusNotFound, err + } + + if err != nil { + return http.StatusInternalServerError, err + } + + for _, user := range c.FM.Users { + if user.ID == id { + delete(c.FM.Users, user.Username) + } + } + + return http.StatusOK, nil +} + +func usersPutHandler(c *RequestContext, w http.ResponseWriter, r *http.Request) (int, error) { + if !c.User.Admin && !(r.URL.Path == "/change-password" || r.URL.Path == "/change-css") { + return http.StatusForbidden, nil + } + + // New users should be created on /api/users. + if r.URL.Path == "/" { + return http.StatusMethodNotAllowed, nil + } + + // Otherwise we just want one, specific, user. + sid := strings.TrimPrefix(r.URL.Path, "/") + sid = strings.TrimSuffix(sid, "/") + + id, err := strconv.Atoi(sid) + if err != nil && sid != "change-password" && sid != "change-css" { + return http.StatusNotFound, err + } + + // If the request body is empty, send a Bad Request status. + if r.Body == nil { + return http.StatusBadRequest, errors.New("The request has an empty body") + } + + var u User + + // Parses the user and checks for error. + err = json.NewDecoder(r.Body).Decode(&u) + if err != nil { + return http.StatusBadRequest, errors.New("Invalid JSON") + } + + if sid == "change-password" { + if u.Password == "" { + return http.StatusBadRequest, errors.New("Password cannot be empty") + } + + pw, err := hashPassword(u.Password) + if err != nil { + return http.StatusInternalServerError, err + } + + c.User.Password = pw + err = c.FM.db.UpdateField(&User{ID: c.User.ID}, "Password", pw) + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil + } + + if sid == "change-css" { + c.User.CSS = u.CSS + err = c.FM.db.UpdateField(&User{ID: c.User.ID}, "CSS", u.CSS) + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil + } + + // The username and the filesystem cannot be empty. + if u.Username == "" || u.FileSystem == "" { + return http.StatusBadRequest, errors.New("Username, password or scope are empty") + } + + // Initialize rules if they're not initialized. + if u.Rules == nil { + u.Rules = []*Rule{} + } + + // Initialize commands if not initialized. + if u.Commands == nil { + u.Commands = []string{} + } + + ouser, ok := c.FM.Users[u.Username] + if !ok { + return http.StatusNotFound, nil + } + + u.ID = id + + if u.Password == "" { + u.Password = ouser.Password + } else { + pw, err := hashPassword(u.Password) + if err != nil { + return http.StatusInternalServerError, err + } + + u.Password = pw + } + + if u.Permissions == nil { + u.Permissions = c.FM.DefaultUser.Permissions + } + + // Updates the whole User struct because we always are supposed + // to send a new entire object. + err = c.FM.db.Save(&u) + if err != nil { + return http.StatusInternalServerError, err + } + + c.FM.Users[u.Username] = &u + return http.StatusOK, nil +} diff --git a/variables/types.go b/variables/types.go new file mode 100644 index 00000000..ee43dad3 --- /dev/null +++ b/variables/types.go @@ -0,0 +1,13 @@ +package variables + +import "reflect" + +// IsMap checks if some variable is a map +func IsMap(sth interface{}) bool { + return reflect.ValueOf(sth).Kind() == reflect.Map +} + +// IsSlice checks if some variable is a slice +func IsSlice(sth interface{}) bool { + return reflect.ValueOf(sth).Kind() == reflect.Slice +} diff --git a/variables/types_test.go b/variables/types_test.go new file mode 100644 index 00000000..d2d5e37b --- /dev/null +++ b/variables/types_test.go @@ -0,0 +1,49 @@ +package variables + +import "testing" + +type interfaceToBool struct { + Value interface{} + Result bool +} + +var testIsMap = []*interfaceToBool{ + {"teste", false}, + {453478, false}, + {-984512, false}, + {true, false}, + {map[string]bool{}, true}, + {map[int]bool{}, true}, + {map[interface{}]bool{}, true}, + {[]string{}, false}, +} + +func TestIsMap(t *testing.T) { + for _, test := range testIsMap { + if IsMap(test.Value) != test.Result { + t.Errorf("Incorrect value on IsMap for %v; want: %v; got: %v", test.Value, test.Result, !test.Result) + } + } +} + +var testIsSlice = []*interfaceToBool{ + {"teste", false}, + {453478, false}, + {-984512, false}, + {true, false}, + {map[string]bool{}, false}, + {map[int]bool{}, false}, + {map[interface{}]bool{}, false}, + {[]string{}, true}, + {[]int{}, true}, + {[]bool{}, true}, + {[]interface{}{}, true}, +} + +func TestIsSlice(t *testing.T) { + for _, test := range testIsSlice { + if IsSlice(test.Value) != test.Result { + t.Errorf("Incorrect value on IsSlice for %v; want: %v; got: %v", test.Value, test.Result, !test.Result) + } + } +} diff --git a/variables/variables.go b/variables/variables.go new file mode 100644 index 00000000..37782c74 --- /dev/null +++ b/variables/variables.go @@ -0,0 +1,47 @@ +package variables + +import ( + "errors" + "log" + "reflect" +) + +// Dict allows to send more than one variable into a template. +func Dict(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + + return dict, nil +} + +// FieldInStruct checks if variable is defined in a struct. +func FieldInStruct(data interface{}, field string) bool { + t := reflect.Indirect(reflect.ValueOf(data)).Type() + + if t.Kind() != reflect.Struct { + log.Print("Non-struct type not allowed.") + return false + } + + _, b := t.FieldByName(field) + return b +} + +// StringInSlice checks if a slice contains a string. +func StringInSlice(a string, list []string) (bool, int) { + for i, b := range list { + if b == a { + return true, i + } + } + return false, 0 +} diff --git a/variables/variables_test.go b/variables/variables_test.go new file mode 100644 index 00000000..95dcd5ab --- /dev/null +++ b/variables/variables_test.go @@ -0,0 +1,41 @@ +package variables + +import "testing" + +type testFieldInStructData struct { + f1 string + f2 bool + f3 int + f4 func() +} + +type testFieldInStruct struct { + data interface{} + field string + result bool +} + +var testFieldInStructCases = []testFieldInStruct{ + {testFieldInStructData{}, "f1", true}, + {testFieldInStructData{}, "f2", true}, + {testFieldInStructData{}, "f3", true}, + {testFieldInStructData{}, "f4", true}, + {testFieldInStructData{}, "f5", false}, + {[]string{}, "", false}, + {map[string]int{"oi": 4}, "", false}, + {"asa", "", false}, + {"int", "", false}, +} + +func TestFieldInStruct(t *testing.T) { + for _, pair := range testFieldInStructCases { + v := FieldInStruct(pair.data, pair.field) + if v != pair.result { + t.Error( + "For", pair.data, + "expected", pair.result, + "got", v, + ) + } + } +}