├── .gitignore ├── Dockerfile ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.md └── requirements.txt ├── etc ├── conf_dev.json └── docker-compose.yml ├── fileshelf.sh ├── fileshelf ├── __init__.py ├── access.py ├── app.py ├── config.py ├── content │ ├── Mimetypes.py │ ├── Plugins.py │ ├── __init__.py │ ├── dir │ │ ├── __init__.py │ │ └── tmpl │ │ │ └── index.htm │ ├── edit │ │ ├── __init__.py │ │ ├── res │ │ │ └── edit.js │ │ └── tmpl │ │ │ └── index.htm │ ├── epub │ │ ├── __init__.py │ │ └── tmpl │ │ │ └── index.htm │ ├── img │ │ ├── __init__.py │ │ └── tmpl │ │ │ └── index.htm │ └── markdown │ │ ├── __init__.py │ │ └── tmpl │ │ └── index.htm ├── response.py ├── rproxy.py ├── storage │ ├── __init__.py │ └── local.py └── url.py ├── index.py ├── requirements.txt ├── static ├── dir.js ├── dir.png ├── dl.svg ├── file.png ├── nerdy.png ├── rename.png └── settings.svg ├── storage ├── fs.png └── test │ ├── filenames │ ├── Стус.txt │ ├── שלום.txt │ └── 你好.txt │ └── unicode │ └── faces.txt └── tmpl ├── 404.htm ├── 500.htm ├── frame.htm ├── media.htm ├── res ├── dir.png.base64 ├── dl.svg.base64 ├── file.gif.base64 └── fs.css └── tmpl.htm /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | .*.swp 4 | .ropeproject 5 | tmpl/* 6 | storage/* 7 | data/* 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | LABEL maintainer="dmytrish@gmail.com" 3 | 4 | ADD fileshelf /usr/app/fileshelf/ 5 | ADD static /usr/app/static 6 | ADD tmpl /usr/app/tmpl/ 7 | ADD index.py /usr/app/ 8 | ADD requirements.txt /usr/app/ 9 | 10 | WORKDIR /usr/app 11 | RUN pip install -r requirements.txt 12 | RUN ln -s /storage 13 | 14 | ENTRYPOINT python index.py -c /etc/fileshelf.conf 15 | VOLUME /storage 16 | VOLUME /etc/fileshelf.conf 17 | EXPOSE 8021:8021/tcp 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FileShelf 2 | 3 | FileShelf is a simple web-based file manager: 4 | 5 | ![fs.png](storage/fs.png?raw=true) 6 | 7 | ## Features 8 | 9 | - fast directory browsing using lightweight pages; 10 | - core functionality works without JavaScript, progressive enhancement; noscript/w3m/elinks compatible; 11 | - file uploading/downloading; 12 | - creating new files/directories, rename/delete, copy/cut/paste files; 13 | 14 | File content plugins out of the box: 15 | - viewing *pdf* files using your browser; 16 | - editing text files using [CodeMirror](https://codemirror.net/) (with vim mode); 17 | - reading *epub* files using [epub.js](https://github.com/futurepress/epub.js); 18 | - playing html5-compatible audio files from a directory; 19 | - playing html5-compatible video files (`mp4`/`ogv`; not `avi`, unfortunately); 20 | 21 | FileShelf is *extensible*: write any file plugin you like! 22 | 23 | Optional features: 24 | - offloading large static files to Nginx; 25 | - multiuser setup; 26 | - basic HTTP authentication; 27 | 28 | 29 | ## Install and run 30 | 31 | To serve a directory (`./storage` by default) in a single-user mode: 32 | 33 | ```sh 34 | $ /path/to/fileshelf/fileshelf.sh $DIRECTORY 35 | ``` 36 | 37 | Now check [http://localhost:8021](http://localhost:8021). 38 | 39 | ## Docker 40 | 41 | Inside this repository directory (or just use the supplied [docker-compose.yml](docker-compose.yml)): 42 | 43 | ```sh 44 | $ docker-compose up 45 | ``` 46 | 47 | and check [http://localhost:8021](http://localhost:8021) 48 | 49 | ## Configuration 50 | 51 | FileShelf can take a configuration file as a parameter: 52 | 53 | ``` 54 | $ ./fileshelf.sh -c conf.json 55 | ``` 56 | 57 | Some values in the configuration file may be overriden from command line: 58 | ``` 59 | $ ./fileshelf.sh --port=8021 --debug 60 | ``` 61 | 62 | Configuration options are listed here: [fileshelf/config.py](https://github.com/EarlGray/fileshelf/blob/master/fileshelf/config.py#L5) 63 | 64 | An example of a simple configuration file: 65 | 66 | ```json 67 | { 68 | "host": "0.0.0.0", 69 | "port": 8021, 70 | "debug": true, 71 | "storage_dir": "~/fileshelf" 72 | } 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = FileShelf 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # FileShelf documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Jan 27 23:14:58 2018. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | from recommonmark.parser import CommonMarkParser 24 | 25 | source_parsers = { 26 | '.md': CommonMarkParser 27 | } 28 | 29 | source_suffix = ['.md', '.rst'] 30 | 31 | 32 | # -- General configuration ------------------------------------------------ 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = '.md' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'FileShelf' 57 | copyright = '2018, Dmytro Sirenko' 58 | author = 'Dmytro Sirenko' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = '' 66 | # The full version, including alpha/beta/rc tags. 67 | release = '' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | # This patterns also effect to html_static_path and html_extra_path 79 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'sphinx' 83 | 84 | # If true, `todo` and `todoList` produce output, else they produce nothing. 85 | todo_include_todos = False 86 | 87 | 88 | # -- Options for HTML output ---------------------------------------------- 89 | 90 | # The theme to use for HTML and HTML Help pages. See the documentation for 91 | # a list of builtin themes. 92 | # 93 | html_theme = 'sphinx_rtd_theme' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | # html_theme_options = {} 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ['_static'] 105 | 106 | # Custom sidebar templates, must be a dictionary that maps document names 107 | # to template names. 108 | # 109 | # This is required for the alabaster theme 110 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 111 | html_sidebars = { 112 | '**': [ 113 | 'relations.html', # needs 'show_related': True theme option to display 114 | 'searchbox.html', 115 | ] 116 | } 117 | 118 | 119 | # -- Options for HTMLHelp output ------------------------------------------ 120 | 121 | # Output file base name for HTML help builder. 122 | htmlhelp_basename = 'FileShelfdoc' 123 | 124 | 125 | # -- Options for LaTeX output --------------------------------------------- 126 | 127 | latex_elements = { 128 | # The paper size ('letterpaper' or 'a4paper'). 129 | # 130 | # 'papersize': 'letterpaper', 131 | 132 | # The font size ('10pt', '11pt' or '12pt'). 133 | # 134 | # 'pointsize': '10pt', 135 | 136 | # Additional stuff for the LaTeX preamble. 137 | # 138 | # 'preamble': '', 139 | 140 | # Latex figure (float) alignment 141 | # 142 | # 'figure_align': 'htbp', 143 | } 144 | 145 | # Grouping the document tree into LaTeX files. List of tuples 146 | # (source start file, target name, title, 147 | # author, documentclass [howto, manual, or own class]). 148 | latex_documents = [ 149 | (master_doc, 'FileShelf.tex', 'FileShelf Documentation', 150 | 'Dmytro Sirenko', 'manual'), 151 | ] 152 | 153 | 154 | # -- Options for manual page output --------------------------------------- 155 | 156 | # One entry per manual page. List of tuples 157 | # (source start file, name, description, authors, manual section). 158 | man_pages = [ 159 | (master_doc, 'fileshelf', 'FileShelf Documentation', 160 | [author], 1) 161 | ] 162 | 163 | 164 | # -- Options for Texinfo output ------------------------------------------- 165 | 166 | # Grouping the document tree into Texinfo files. List of tuples 167 | # (source start file, target name, title, author, 168 | # dir menu entry, description, category) 169 | texinfo_documents = [ 170 | (master_doc, 'FileShelf', 'FileShelf Documentation', 171 | author, 'FileShelf', 'One line description of project.', 172 | 'Miscellaneous'), 173 | ] 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # FileShelf 2 | 3 | FileShelf is a simple web-based file manager: 4 | 5 | ![fs.png](../storage/fs.png) 6 | 7 | ## Features 8 | 9 | - fast directory browsing using lightweight pages; 10 | - core functionality works without JavaScript, progressive enhancement; noscript/w3m/elinks compatible; 11 | - file uploading/downloading; 12 | - creating new files/directories, rename/delete, copy/cut/paste files; 13 | 14 | File content plugins out of the box: 15 | - viewing *pdf* files using your browser; 16 | - editing text files using [CodeMirror](https://codemirror.net/) (with vim mode); 17 | - reading *epub* files using [epub.js](https://github.com/futurepress/epub.js); 18 | - playing html5-compatible audio files from a directory; 19 | - playing html5-compatible video files (`mp4`/`ogv`; not `avi`, unfortunately); 20 | 21 | FileShelf is *extensible*: write any file plugin you like! 22 | 23 | Optional features: 24 | - offloading large static files to Nginx; 25 | - multiuser setup; 26 | - basic HTTP authentication; 27 | 28 | 29 | ## Install and run 30 | 31 | You must have `python > 3.3` installed; `virtualenv` is recommended, but you can install 32 | the packages from `requirements.txt` manually system-wide. 33 | 34 | Clone/download the [Github repository](https://github.com/EarlGray/FileShelf). 35 | 36 | To serve a directory (`./storage` by default) in a single-user mode, run: 37 | ```sh 38 | $ /path/to/fileshelf/fileshelf.sh $DIRECTORY 39 | ``` 40 | 41 | Now check [http://localhost:8021](http://localhost:8021). 42 | 43 | ## Docker 44 | 45 | If you want to use a Docker container, there is [the official FileShelf image at Dockerhub](https://hub.docker.com/r/dmytrish/fileshelf/): 46 | ```sh 47 | $ docker pull dmytrish/fileshelf 48 | ``` 49 | 50 | Alternatively, you can build it yourself: 51 | 52 | ```sh 53 | $ cd /path/to/fileshelf 54 | $ docker build -t fileshelf:master . 55 | ``` 56 | 57 | To deploy the image, use the supplied [docker-compose.yml](docker-compose.yml)): 58 | ```sh 59 | $ docker-compose up 60 | ``` 61 | 62 | and check [http://localhost:8021](http://localhost:8021) 63 | 64 | ## Configuration 65 | 66 | FileShelf can take a configuration file as a parameter: 67 | 68 | ``` 69 | $ ./fileshelf.sh -c conf.json 70 | ``` 71 | 72 | Some values in the configuration file may be overriden from command line: 73 | ``` 74 | $ ./fileshelf.sh --port=8021 --debug 75 | ``` 76 | 77 | Configuration options are listed here: [fileshelf/config.py](https://github.com/EarlGray/fileshelf/blob/master/fileshelf/config.py#L5) 78 | 79 | An example of a simple configuration file: 80 | 81 | ```json 82 | { 83 | "host": "0.0.0.0", 84 | "port": 8021, 85 | "debug": true, 86 | "storage_dir": "~/fileshelf" 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | Babel==2.8.0 3 | certifi==2020.4.5.1 4 | chardet==3.0.4 5 | commonmark==0.9.1 6 | docutils==0.16 7 | idna==2.9 8 | imagesize==1.2.0 9 | Jinja2==2.11.1 10 | MarkupSafe==1.1.1 11 | packaging==20.3 12 | Pygments==2.6.1 13 | pyparsing==2.4.7 14 | pytz==2019.3 15 | recommonmark==0.6.0 16 | requests==2.23.0 17 | six==1.14.0 18 | snowballstemmer==2.0.0 19 | Sphinx==3.0.1 20 | sphinx-rtd-theme==0.4.3 21 | sphinxcontrib-applehelp==1.0.2 22 | sphinxcontrib-devhelp==1.0.2 23 | sphinxcontrib-htmlhelp==1.0.3 24 | sphinxcontrib-jsmath==1.0.1 25 | sphinxcontrib-qthelp==1.0.3 26 | sphinxcontrib-serializinghtml==1.1.4 27 | urllib3==1.25.8 28 | -------------------------------------------------------------------------------- /etc/conf_dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "0.0.0.0", 3 | "port": 8021, 4 | "debug": true 5 | } 6 | -------------------------------------------------------------------------------- /etc/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | fileshelf: 4 | image: dmytrish/fileshelf:latest 5 | container_name: fileshelf 6 | ports: ['8021:8021/tcp'] 7 | volumes: 8 | - $PWD/storage:/storage 9 | - $PWD/conf_dev.json:/etc/fileshelf.conf 10 | -------------------------------------------------------------------------------- /fileshelf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | APPDIR="$(dirname "$0")" 6 | export APPDIR 7 | 8 | if python3 -c "import virtualenv" >/dev/null ; then 9 | if ! test -d "$APPDIR/venv" ; then 10 | python3 -m virtualenv -p python3 "$APPDIR/venv" 11 | "$APPDIR/venv/bin/pip" install -r "$APPDIR/requirements.txt" 12 | fi 13 | . "$APPDIR/venv/bin/activate" 14 | else 15 | echo "WARN: virtualenv not found, trying to use system libraries..." >&2 16 | fi 17 | 18 | exec "$APPDIR/index.py" "$@" 19 | -------------------------------------------------------------------------------- /fileshelf/__init__.py: -------------------------------------------------------------------------------- 1 | from fileshelf.app import FileShelf 2 | from fileshelf.config import default as default_conf 3 | 4 | __all__ = ['create', 'default_conf', 'FileShelf'] 5 | -------------------------------------------------------------------------------- /fileshelf/access.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from base64 import b64decode 3 | 4 | import os.path 5 | import flask 6 | from passlib.apache import HtpasswdFile 7 | 8 | from fileshelf.storage import LocalStorage 9 | 10 | 11 | class AuthError(Exception): 12 | pass 13 | 14 | 15 | class NoSuchUser(Exception): 16 | pass 17 | 18 | 19 | class AuthChecker: 20 | methods = [None, 'basic'] 21 | 22 | def __init__(self, conf): 23 | auth = conf.get('auth') 24 | if auth not in AuthChecker.methods: 25 | raise ValueError('unknown auth type: ' + auth) 26 | 27 | self.auth = auth 28 | self.https_only = conf.get('auth_https_only') 29 | 30 | if auth == 'basic': 31 | htpasswd = os.path.join(conf['data_dir'], 'htpasswd.db') 32 | htpasswd = conf.get('auth_htpasswd') or htpasswd 33 | self.htpasswd = HtpasswdFile(htpasswd) 34 | self._log('Using htpasswd: ' + str(htpasswd)) 35 | 36 | self.realm = conf.get('auth_realm', 'fileshelf') 37 | 38 | def _log(self, msg): 39 | print('## Auth: ' + msg) 40 | 41 | def check_access(self, handler): 42 | @wraps(handler) 43 | def decorated(*args, **kwargs): 44 | req = flask.request 45 | if self.https_only and req.environ['wsgi.url_scheme'] != 'https': 46 | raise AuthError('HTTPS required') 47 | 48 | path = req.environ['PATH_INFO'] 49 | 50 | user = None 51 | if self.auth is None: 52 | user = UserDb.DEFAULT 53 | elif self.auth == 'basic': 54 | user = self._check_basic_auth(req) 55 | if user is None: 56 | hdrs = { 57 | 'WWW-Authenticate': 'Basic realm="%s"' % self.realm 58 | } 59 | # TODO: a nicer page 60 | return flask.Response('Login Required', 401, hdrs) 61 | else: 62 | raise AuthError('unknown auth type: ' + self.auth) 63 | 64 | path = path.encode('latin1').decode('utf8') 65 | self._log('%s %s://%s@%s%s?%s' % 66 | (req.method, req.environ['wsgi.url_scheme'], 67 | user, req.environ['HTTP_HOST'], path, 68 | req.environ['QUERY_STRING'])) 69 | flask.request.user = user 70 | return handler(*args, **kwargs) 71 | return decorated 72 | 73 | def _check_basic_auth(self, req): 74 | auth = req.headers.get('Authorization') 75 | if not auth: 76 | self._log('basic auth: no Authorization') 77 | return None 78 | if not auth.startswith('Basic '): 79 | self._log('basic auth: not Basic') 80 | return None 81 | 82 | auth = auth.split()[1] 83 | auth = auth.encode('ascii') 84 | user, passwd = b64decode(auth).split(b':') 85 | ret = self.htpasswd.check_password(user, passwd) 86 | user = user.decode('ascii') 87 | passwd = passwd.decode('ascii') 88 | if ret is None: 89 | self._log('basic auth: user %s not found' % user) 90 | return None 91 | if ret is False: 92 | self._log('basic auth: check_password(<%s>, <%s>) failed' 93 | % (user, passwd)) 94 | return None 95 | return user 96 | 97 | def user_exists(self, user): 98 | return (user in self.htpasswd) 99 | 100 | 101 | class UserDb: 102 | DEFAULT = '' 103 | 104 | def __init__(self, conf): 105 | data_dir = conf['data_dir'] 106 | self.data_dir = data_dir 107 | 108 | if conf.get('multiuser'): 109 | self._init() 110 | else: 111 | store = LocalStorage(conf['storage_dir'], data_dir) 112 | default_user = { 113 | 'name': UserDb.DEFAULT, 114 | 'home': conf['storage_dir'], 115 | 'storage': store 116 | } 117 | self.users = {self.DEFAULT: default_user} 118 | 119 | def _init(self): 120 | self.user_dir = os.path.join(self.data_dir, 'user') 121 | if not os.path.exists(self.user_dir): 122 | os.mkdir(self.user_dir) 123 | 124 | users = {} 125 | for u in os.listdir(self.user_dir): 126 | user_dir = os.path.join(self.user_dir, u) 127 | if not os.path.isdir(user_dir): 128 | continue 129 | 130 | home_dir = os.path.join(user_dir, 'home') 131 | if not os.path.exists(home_dir): 132 | os.mkdir(home_dir) 133 | 134 | store = LocalStorage(home_dir, user_dir) 135 | 136 | conf = { 137 | 'name': u, 138 | 'home': home_dir, 139 | 'storage': store 140 | } 141 | users[u] = conf 142 | 143 | self.users = users 144 | 145 | def get_storage(self, user): 146 | try: 147 | user = user or self.DEFAULT 148 | return self.users[user]['storage'] 149 | except KeyError: 150 | raise NoSuchUser('No such user: ' + user) 151 | -------------------------------------------------------------------------------- /fileshelf/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import namedtuple 3 | 4 | from traceback import print_tb 5 | 6 | import flask 7 | from flask import Flask 8 | 9 | import fileshelf.url as url 10 | import fileshelf.content as content 11 | import fileshelf.response as resp 12 | from fileshelf.access import AuthChecker, UserDb 13 | from fileshelf.rproxy import ReverseProxied 14 | 15 | 16 | class FileShelf: 17 | def __init__(self, conf): 18 | self.app_dir = conf['app_dir'] 19 | template_dir = conf['template_dir'] 20 | 21 | app = Flask(__name__, template_folder=template_dir) 22 | app.debug = conf['debug'] 23 | app.static_dir = conf['static_dir'] 24 | app.data_dir = conf['data_dir'] 25 | # app.share_dir = conf['share_dir'] 26 | 27 | app.offload = None 28 | offload_dir = conf.get('offload_dir') 29 | offload_path = conf.get('offload_path') 30 | if offload_dir and offload_path: 31 | Offload = namedtuple('Offload', ['dir', 'path', 'minsize']) 32 | app.offload = Offload(offload_dir, offload_path, 1024 * 1024) 33 | 34 | # monkey-patch the environment to handle 'X-Forwarded-For' 35 | # 'X-Forwarded-Proto', etc: 36 | app.wsgi_app = ReverseProxied(app.wsgi_app) 37 | 38 | # self.shared = self._scan_share(app.share_dir) 39 | 40 | plugin_dir = os.path.join(self.app_dir, 'fileshelf/content') 41 | self.plugins = content.Plugins(conf, plugin_dir) 42 | 43 | self.users = UserDb(conf) 44 | self.auth = AuthChecker(conf) 45 | 46 | self.conf = conf 47 | 48 | @app.errorhandler(404) 49 | def not_found(e): 50 | return self.r404() 51 | 52 | @app.errorhandler(500) 53 | def internal_error(e): 54 | return self.r500(e) 55 | 56 | @app.route(url.join(url._res, '')) 57 | def static_handler(path): 58 | fname = os.path.join(app.static_dir, path) 59 | if not os.path.exists(fname): 60 | return self.r404(path) 61 | 62 | return flask.send_file(fname) 63 | 64 | @app.route(url._my, defaults={'path': ''}, methods=['GET', 'POST']) 65 | @app.route(url.join(url._my, ''), methods=['GET', 'POST']) 66 | @self.auth.check_access 67 | def path_handler(path): 68 | req = flask.request 69 | # don't store it permanently: 70 | self.storage = self.users.get_storage(req.user) 71 | 72 | if req.method == 'GET': 73 | return self._path_get(req, path) 74 | if req.method == 'POST': 75 | return self._path_post(req, path) 76 | return self.r400('Unknown method ' + req.method) 77 | 78 | # @app.route(url.join(url._pub, '')) 79 | # def pub_handler(path): 80 | # fname = secure_filename(path) 81 | # fname = os.path.join(app.share_dir, fname) 82 | # if not os.path.exists(fname): 83 | # return self.r404(path) 84 | 85 | # # print('Serving %s' % fname) 86 | # return flask.send_file(fname) 87 | 88 | self.app = app 89 | 90 | def run(self): 91 | self.app.run(host=self.conf['host'], port=self.conf['port']) 92 | 93 | def _log(self, msg): 94 | print('## App: ', msg) 95 | 96 | def _is_plugin_request(self, req): 97 | args = list(req.args.keys()) 98 | if len(args) == 1: 99 | param = args[0] 100 | plugin = self.plugins.get(param) 101 | return plugin 102 | 103 | def _path_get(self, req, path): 104 | # self._log('args = ' + str(req.args)) 105 | if not self.storage.exists(path): 106 | return self.r404(path) 107 | 108 | is_dl = 'dl' in req.args.values() 109 | if is_dl: 110 | return self._download(path, octetstream=True) 111 | 112 | plugin = self._is_plugin_request(req) 113 | if not plugin: 114 | plugin = self.plugins.dispatch(self.storage, path) 115 | plugin = self.plugins.get(plugin) 116 | 117 | if not plugin: 118 | return self._download(path) 119 | 120 | try: 121 | self._log('%s.render("%s")' % (plugin.name, path)) 122 | r = plugin.render(req, self.storage, path) 123 | return r() 124 | except resp.RequestError as e: 125 | return self.r400(e) 126 | except Exception as e: 127 | return self.r500(e, path) 128 | 129 | def _path_post(self, req, path): 130 | self._log(req.form) 131 | 132 | plugin = self._is_plugin_request(req) 133 | if plugin: 134 | self._log('%s.action("%s", %s)' % (plugin.name, path, str(req.form))) 135 | r = plugin.action(req, self.storage, path) 136 | return r() 137 | 138 | plugin = self.plugins.dispatch(self.storage, path) 139 | plugin = self.plugins.get(plugin) 140 | if not plugin: 141 | return self.r400('unknown POST: %s' % str(req.form)) 142 | 143 | try: 144 | self._log('"%s" opens "%s"' % (plugin.name, path)) 145 | r = plugin.action(req, self.storage, path) 146 | return r() 147 | except resp.RequestError as e: 148 | return self.r400(e) 149 | except Exception as e: 150 | return self.r500(e, path) 151 | 152 | def _prefixes(self, path, tabindex=1): 153 | return url.prefixes(path, self.storage.exists, tabindex) 154 | 155 | def r400(self, why): 156 | args = {'title': 'not accepted', 'e': why} 157 | return flask.render_template('500.htm', **args), 400 158 | 159 | def r404(self, path=None): 160 | if path is None: 161 | return flask.redirect(url.my()) 162 | args = { 163 | 'path': path, 164 | 'path_prefixes': self._prefixes(path), 165 | 'title': 'no ' + path if path else 'not found' 166 | } 167 | mimetype = content.guess_mime(path) 168 | if mimetype is None: 169 | args['maybe_new'] = { 170 | 'path': path, 171 | 'mime': 'fs/dir', 172 | 'desc': 'directory' 173 | } 174 | elif mimetype.startswith('text/'): 175 | args['maybe_new'] = { 176 | 'path': path, 177 | 'mime': mimetype, 178 | 'desc': 'text file' 179 | } 180 | return flask.render_template('404.htm', **args), 404 181 | 182 | def r500(self, e=None, path=None): 183 | if self.conf.get('debug'): 184 | raise e 185 | self._log('r500: e=%s' % str(e)) 186 | if hasattr(e, '__traceback__'): 187 | print_tb(e.__traceback__) 188 | 189 | args = { 190 | 'title': 'server error', 191 | 'e': e, 192 | } 193 | if path: 194 | args['path_prefixes'] = self._prefixes(path) 195 | return flask.render_template('500.htm', **args), 500 196 | 197 | def _download(self, path, octetstream=False): 198 | if self.app.offload: 199 | u, e = self.storage.static_download(path, self.app.offload) 200 | if e: 201 | return self.r500(e) 202 | if u: 203 | self._log('Redirecting to static: %s' % u) 204 | return flask.redirect(u) 205 | 206 | # TODO: hide _fullpath(), figure out a generic way of serving 207 | dlpath = self.storage._fullpath(path) 208 | 209 | headers = None 210 | if octetstream: 211 | headers = {'Content-Type': 'application/octet-stream'} 212 | try: 213 | if headers: 214 | return flask.send_file(dlpath), 200, headers 215 | else: 216 | return flask.send_file(dlpath), 200 217 | except (IOError, OSError) as e: 218 | return self.r500(e, path) 219 | 220 | # def _scan_share(self, share_dir): 221 | # share = {} 222 | # for link in os.listdir(share_dir): 223 | # lpath = os.path.join(share_dir, link) 224 | # if not os.path.islink(lpath): 225 | # continue 226 | 227 | # source = os.readlink(lpath) 228 | # print('share: %s => %s' % (link, source)) 229 | # share[source] = link 230 | # return share 231 | 232 | # def _share(self, path): 233 | # req = flask.request 234 | # try: 235 | # fname = req.form.get('file') 236 | # if not fname: 237 | # return self.r400('no `file` in POST') 238 | 239 | # fname = secure_filename(fname) 240 | # fpath = os.path.join(dpath, fname) 241 | # link = os.path.join(self.app.share_dir, fname) 242 | # # TODO: check if already shared 243 | # # TODO: check for existing links 244 | 245 | # os.symlink(fpath, link) 246 | # self.shared[fpath] = fname 247 | # print('shared:', fpath, ' => ', fname) 248 | # return flask.redirect(url.my(path)) 249 | # except OSError as e: 250 | # return self.r500(e) 251 | -------------------------------------------------------------------------------- /fileshelf/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | 5 | from pathlib import Path 6 | from argparse import ArgumentParser 7 | 8 | 9 | def default(appdir): 10 | return { 11 | # host and port to listen on: 12 | 'host': '127.0.0.1', 13 | 'port': 8021, 14 | 15 | # modes: 16 | 'debug': False, 17 | 18 | # directories: 19 | 'app_dir': appdir, 20 | 'data_dir': os.path.join(appdir, 'data'), # multiple-users storage 21 | 'storage_dir': os.path.join(appdir, 'storage'), # single-user storage 22 | 'static_dir': os.path.join(appdir, 'static'), 23 | 'template_dir': os.path.join(appdir, 'tmpl'), 24 | 25 | # users: 26 | 'multiuser': False, 27 | 'auth': None, # null, 'basic' 28 | 'auth_realm': None, # basic auth realm, e.g. "fs.mydomain.tld" 29 | 'auth_htpasswd': None, # data/htpasswd.db by default 30 | 'auth_https_only': False, 31 | 32 | # used to offload large static files to a static server (nginx): 33 | 'offload_dir': None, 34 | 'offload_path': None, 35 | } 36 | 37 | 38 | def from_arguments(appdir=None): 39 | conf = default(appdir or os.getcwd()) 40 | 41 | ap = ArgumentParser() 42 | ap.add_argument("-c", "--config", 43 | help="path to a configuration file") 44 | ap.add_argument("-p", "--port", type=int, 45 | help="port to listen on") 46 | ap.add_argument("-d", "--debug", action="store_true", 47 | help="debug output") 48 | ap.add_argument("directory", nargs='?', 49 | help="directory to serve") 50 | 51 | args = ap.parse_args() 52 | 53 | if args.config: 54 | with open(args.config) as f: 55 | uconf = json.load(f) 56 | conf.update(uconf) 57 | 58 | # override config if options are specified: 59 | if args.directory: 60 | if Path(args.directory).expanduser().absolute().exists(): 61 | conf['storage_dir'] = args.directory 62 | else: 63 | print("Directory not found: %s" % args.directory, file=sys.stderr) 64 | sys.exit(1) 65 | 66 | if args.port and 0 < args.port and args.port < 65536: 67 | conf['port'] = args.port 68 | 69 | if args.debug: 70 | conf['debug'] = True 71 | 72 | return conf 73 | -------------------------------------------------------------------------------- /fileshelf/content/Mimetypes.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | 3 | 4 | # TODO: make it an external file 5 | mime_by_extension = { 6 | 'Makefile': 'text/x-makefile', 7 | 8 | 'erl': 'text/x-erlang', 9 | 'hs': 'text/x-haskell', 10 | 'ini': 'text/x-ini', 11 | 'lhs': 'text/x-haskell', 12 | 'md': 'text/markdown', 13 | 'org': 'text/x-org', 14 | 'rb': 'text/x-ruby', 15 | 'rs': 'text/x-rust', 16 | 'scala': 'text/x-scala', 17 | 'scm': 'text/x-scheme', 18 | 'sh': 'text/x-shell', 19 | 'tex': 'text/x-latex', 20 | 21 | # non-text: 22 | 'ipynb': 'application/ipynb', 23 | } 24 | 25 | mime_text_prefix = { 26 | 'application/javascript': 'text/javascript', 27 | 'application/xml': 'text/xml', 28 | 'application/json': 'text/json', 29 | 'application/x-sql': 'text/x-sql', 30 | } 31 | 32 | 33 | def guess_mime(path): 34 | if not path: 35 | return None 36 | ext = path.split('.')[-1] 37 | 38 | mime = mime_by_extension.get(ext) 39 | if mime: 40 | return mime 41 | 42 | mime = mimetypes.guess_type(path)[0] 43 | return mime_text_prefix.get(mime, mime) 44 | -------------------------------------------------------------------------------- /fileshelf/content/Plugins.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import json 5 | import re 6 | from pathlib import Path 7 | import inspect 8 | 9 | import fileshelf.url as url 10 | import fileshelf.response as resp 11 | from .Mimetypes import guess_mime 12 | 13 | 14 | class Plugins: 15 | """ Manages plugins, their priorities and dispatches requests """ 16 | 17 | def __init__(self, conf, plugins_dir): 18 | self.conf = conf 19 | self.plugins = {} 20 | 21 | self._add_from_directory(plugins_dir) 22 | 23 | def _add_from_directory(self, plugins_dir): 24 | for name, Plugin, conf in Plugins._scan(plugins_dir): 25 | try: 26 | plugin = Plugin(name, conf) 27 | self._init(plugins_dir, name) 28 | 29 | self.plugins[name] = plugin 30 | self._log('initialized: ' + name + ' with ' + Plugin.__name__) 31 | # self._log('conf:', json.dumps(conf)) 32 | except Exception as e: 33 | self._log('init error: plugin ' + name) 34 | self._log(e) 35 | 36 | def _init(self, plugins_dir, name): 37 | """ initializes directories for the plugin `name` """ 38 | plugin_dir = os.path.join(plugins_dir, name) 39 | 40 | def check_and_link(sub_dir, into_dir): 41 | sub_dir = os.path.join(plugin_dir, sub_dir) 42 | if not os.path.isdir(sub_dir): 43 | return 44 | link_path = os.path.join(into_dir, name) 45 | if os.path.islink(link_path): 46 | os.remove(link_path) 47 | 48 | # self._log('ln "%s" -> "%s"' % (sub_dir, link_path)) 49 | os.symlink(sub_dir, link_path) 50 | 51 | check_and_link('res', into_dir=self.conf['static_dir']) 52 | check_and_link('tmpl', into_dir=self.conf['template_dir']) 53 | 54 | def _log(self, *msgs): 55 | print('## Plugins: ', end='') 56 | print(*msgs) 57 | 58 | @staticmethod 59 | def _scan(plugins_dir): 60 | for entry in os.listdir(plugins_dir): 61 | plugin_dir = os.path.join(plugins_dir, entry) 62 | if not os.path.isdir(plugin_dir): 63 | continue 64 | 65 | # read `plugin.json` if exists 66 | conf = {} 67 | conf_path = os.path.join(plugin_dir, 'plugin.json') 68 | if os.path.exists(conf_path): 69 | conf = json.load(open(conf_path)) 70 | 71 | if Path(plugin_dir).joinpath('__init__.py').exists(): 72 | # import Plugin class from __init__.py 73 | modname = 'fileshelf.content.' + entry 74 | mod = __import__(modname) 75 | mod = mod.content.__dict__[entry] 76 | 77 | Plugin = Plugins._find_plugin_in(mod) 78 | if Plugin: 79 | yield (entry, Plugin, conf) 80 | elif os.path.exists(os.path.join(plugin_dir, 'tmpl/index.htm')): 81 | # no plugin class, but `tmpl/index.htm` is there 82 | yield (entry, Handler, conf) 83 | 84 | @staticmethod 85 | def _find_plugin_in(mod): 86 | for name, Plugin in mod.__dict__.items(): 87 | if inspect.isclass(Plugin) and issubclass(Plugin, Handler): 88 | return Plugin 89 | return None 90 | 91 | def __contains__(self, name): 92 | return name in self.plugins 93 | 94 | def get(self, name, default=None): 95 | return self.plugins.get(name, default) 96 | 97 | def dispatch(self, storage, path): 98 | handlers = { 99 | Priority.SHOULD: [], 100 | Priority.CAN: [] 101 | } 102 | for name, plugin in self.plugins.items(): 103 | prio = plugin.can_handle(storage, path) 104 | # self._log('%s.can_handle(%s) = %d' % (name, path, prio)) 105 | if prio == Priority.DOESNT: 106 | continue 107 | if prio == Priority.MUST: 108 | return name 109 | handlers[prio].append(name) 110 | 111 | if handlers[Priority.SHOULD]: 112 | return handlers[Priority.SHOULD][0] 113 | elif handlers[Priority.CAN]: 114 | return handlers[Priority.CAN][0] 115 | return None 116 | 117 | def render(self, req, storage, path, name=None): 118 | name = name or self.dispatch(storage, path) 119 | if not name: 120 | return 121 | plugin = self.plugins[name] 122 | return plugin.render(req, storage, path) 123 | 124 | 125 | class Priority: 126 | DOESNT = 0 127 | CAN = 1 128 | SHOULD = 2 129 | MUST = 3 130 | 131 | @staticmethod 132 | def val(s): 133 | if isinstance(s, int): 134 | return s 135 | if isinstance(s, str): 136 | try: 137 | return int(s) 138 | except ValueError: 139 | return getattr(Priority, s) 140 | 141 | 142 | class Handler: 143 | conf = {} 144 | 145 | def __init__(self, name, conf): 146 | """ 147 | `name` is the name of this plugin and its directory 148 | `conf` is a config dict (maybe read from `plugin.json`) 149 | """ 150 | self.name = name 151 | self.conf.update(conf) 152 | 153 | def can_handle(self, storage, path): 154 | """ return content handler priority for `path`: DOESNT/CAN/SHOULD/MUST 155 | """ 156 | 157 | extensions = self.conf.get('extensions') 158 | if extensions: 159 | _, ext = os.path.splitext(path) 160 | ext = ext.strip('.') 161 | if ext in extensions: 162 | return Priority.val(extensions[ext]) 163 | 164 | mime_conf = self.conf.get('mime_regex') 165 | if mime_conf: 166 | assert isinstance(mime_conf, dict) 167 | mime = 'fs/dir' if storage.is_dir(path) else guess_mime(path) 168 | if mime: 169 | for regex, prio in mime_conf.items(): 170 | if re.match(regex, mime): 171 | return Priority.val(prio) 172 | 173 | return Priority.DOESNT 174 | 175 | def render(self, req, storage, path): 176 | """ handles GET requests """ 177 | # self._log('Handler.render(%s)' % path) 178 | 179 | tmpl = url.join(self.name, 'index.htm') 180 | args = { 181 | 'file_url': url.my(path), 182 | 'user': getattr(req, 'user'), 183 | 'path_prefixes': url.prefixes(path, storage.exists) 184 | } 185 | return resp.RenderTemplate(tmpl, args) 186 | 187 | def action(self, req, storage, path): 188 | """ handles POST requests, to override """ 189 | msg = '%s.action() is not implemented' % self.name 190 | raise NotImplementedError(msg) 191 | 192 | def _log(self, *msgs): 193 | print('## Plugin[%s]: ' % self.name, end='') 194 | print(*msgs) 195 | -------------------------------------------------------------------------------- /fileshelf/content/__init__.py: -------------------------------------------------------------------------------- 1 | from .Mimetypes import guess_mime 2 | 3 | from .Plugins import Plugins, Priority, Handler 4 | 5 | import time 6 | 7 | 8 | def smart_time(tm): 9 | if isinstance(tm, float): 10 | tm = time.localtime(tm) 11 | if not isinstance(tm, time.struct_time): 12 | raise ValueError('Expected time.struct_time or float') 13 | 14 | t = time.strftime('%H:%M', tm) 15 | 16 | now = time.localtime() 17 | if now.tm_year == tm.tm_year: 18 | if now.tm_yday == tm.tm_yday: 19 | return t 20 | if now.tm_yday == tm.tm_yday + 1: 21 | return t + ', yesterday' 22 | if now.tm_yday - tm.tm_yday < 5: 23 | return t + time.strftime(', %a', tm) # abbr. week day 24 | return t + time.strftime(', %b %d', tm) # abbr. month and date 25 | return t + time.strftime(', %x', tm) 26 | 27 | 28 | __all__ = [ 29 | guess_mime, smart_time, 30 | Handler, Plugins, Priority, 31 | ] 32 | -------------------------------------------------------------------------------- /fileshelf/content/dir/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import fileshelf.url as url 5 | import fileshelf.content as content 6 | import fileshelf.response as resp 7 | 8 | 9 | class DirHandler(content.Handler): 10 | def can_handle(self, storage, path): 11 | if storage.is_dir(path): 12 | return content.Priority.SHOULD 13 | return content.Priority.DOESNT 14 | 15 | def _prefixes(self, path, tabindex=1): 16 | return url.prefixes(path, self.storage.exists, tabindex) 17 | 18 | def _file_info(self, path): 19 | entry = self.storage.file_info(path) 20 | entry.href = url.my(path) 21 | 22 | # shared = self.shared.get(path) 23 | # if shared: 24 | # shared = url.pub(shared) 25 | # entry.shared = shared 26 | 27 | if not entry.can_read: 28 | return entry 29 | 30 | entry.open_url = entry.href 31 | if entry.is_audio(): 32 | dirpath = os.path.dirname(path) 33 | filename = os.path.basename(path) 34 | entry.open_url = url.my(dirpath) + '?play=' + url.quote(filename) 35 | elif entry.is_viewable(): 36 | entry.open_url = entry.href + '?see' 37 | 38 | return entry 39 | 40 | def render(self, req, storage, path): 41 | self.storage = storage 42 | play = req.args.get('play') 43 | if play: play = url.unquote(play) 44 | 45 | tabindex = 2 46 | addressbar = self._prefixes(path, tabindex) 47 | tabindex += len(addressbar) 48 | 49 | lsdir = [] 50 | 51 | for fname in self.storage.list_dir(path): 52 | file_path = url.join(path, fname) 53 | entry = self._file_info(file_path) 54 | 55 | lsfile = { 56 | 'name': fname, 57 | 'mime': content.guess_mime(fname), 58 | 'href': entry.href, 59 | 'size': entry.size, 60 | 'isdir': entry.is_dir, 61 | 'is_hidden': fname.startswith('.'), 62 | 'ctime': entry.ctime, 63 | 'full_ctime': time.ctime(entry.ctime)+' '+time.tzname[0], 64 | 'created_at': content.smart_time(entry.ctime), 65 | # 'shared': entry.shared, 66 | } 67 | 68 | if hasattr(entry, 'open_url'): 69 | lsfile['open_url'] = entry.open_url 70 | 71 | if entry.is_dir: 72 | lsfile['icon_src'] = 'dir-icon' 73 | lsfile['icon_alt'] = 'd' 74 | else: 75 | lsfile['icon_src'] = 'file-icon' 76 | lsfile['icon_alt'] = '-' 77 | 78 | if fname == play: 79 | lsfile['play_url'] = entry.href 80 | if entry.href.endswith('.m3u'): 81 | u, e = self.storage.read_text(file_path) 82 | if e: 83 | raise e 84 | for line in u.splitlines(): 85 | if not line.startswith('#'): 86 | lsfile['play_url'] = line 87 | break 88 | 89 | if entry.can_rename: 90 | lsfile['rename_url'] = url.my(path) + '?rename=' + fname 91 | 92 | lsdir.append(lsfile) 93 | 94 | clipboard = [] 95 | for entry in self.storage.clipboard_list(): 96 | loc = entry['tmp'] if entry['cut'] else entry['path'] 97 | isdir = self.storage.is_dir(loc) 98 | 99 | e = {} 100 | e['do'] = 'cut' if entry['cut'] else 'copy' 101 | e['path'] = entry['path'] 102 | e['icon_src'] = 'dir-icon' if isdir else 'file-icon' 103 | clipboard.append(e) 104 | 105 | lsdir = sorted(lsdir, key=lambda d: (not d['isdir'], d['name'])) 106 | for entry in lsdir: 107 | entry['tabindex'] = tabindex 108 | tabindex += 1 109 | 110 | user = getattr(req, 'user') 111 | templvars = { 112 | 'path': path, 113 | 'lsdir': lsdir, 114 | 'title': path, 115 | 'path_prefixes': addressbar, 116 | 'rename': req.args.get('rename'), 117 | 'clipboard': clipboard, 118 | 'upload_tabidx': tabindex, 119 | 'user': user 120 | } 121 | return resp.RenderTemplate('dir/index.htm', templvars) 122 | 123 | def action(self, req, storage, path): 124 | self.storage = storage 125 | 126 | actions = req.form.getlist('action') 127 | if 'rename' in actions: 128 | oldname = req.form.get('oldname') 129 | newname = req.form.get('newname') 130 | return self._rename(path, oldname, newname) 131 | 132 | action = actions[0] 133 | if action == 'upload': 134 | if 'file' not in req.files: 135 | raise resp.RequestError('no file in POST') 136 | return self._upload(req, path) 137 | # if action == 'share': 138 | # return self._share(path) 139 | if action == 'delete': 140 | files = req.form.getlist('file') 141 | return self._delete(path, files) 142 | if action == 'create': 143 | mime = req.form.get('mime') 144 | name = req.form.get('name') 145 | if mime == 'fs/dir': 146 | return self._mkdir(name) 147 | elif mime.startswith('text/'): 148 | fpath = os.path.join(path, name) 149 | e = self.storage.write_text(fpath, '') 150 | if e: 151 | raise e 152 | return resp.Redirect(url.my(name) + '?edit') 153 | raise resp.RequestError('cannot create file with type ' + mime) 154 | if action == 'cut': 155 | files = req.form.getlist('file') 156 | files = map(str.strip, files) 157 | for f in files: 158 | name = os.path.join(path, f) 159 | e = self.storage.clipboard_cut(name) 160 | if e: 161 | raise e 162 | return resp.Redirect(url.my(path)) 163 | if action == 'copy': 164 | files = req.form.getlist('file') 165 | files = map(str.strip, files) 166 | for f in files: 167 | name = os.path.join(path, f) 168 | e = self.storage.clipboard_copy(name) 169 | if e: 170 | raise e 171 | return resp.Redirect(url.my(path)) 172 | if action in ['paste', 'cb_clear']: 173 | into = path if action == 'paste' else None 174 | e = self.storage.clipboard_paste(into, dryrun=False) 175 | if e: 176 | raise e 177 | return resp.Redirect(url.my(path)) 178 | if action == 'cb_clear': 179 | e = self.storage.clipboard_clear() 180 | if e: 181 | raise e 182 | return resp.Redirect(url.my(path)) 183 | 184 | raise resp.RequestError('unknown POST: %s' % action) 185 | 186 | def _upload(self, req, path): 187 | redir_url = url.my(path) 188 | 189 | f = req.files['file'] 190 | if not f.filename: 191 | return resp.Redirect(redir_url) 192 | 193 | tmppath = self.storage.mktemp(f.filename) 194 | 195 | self._log('Saving file as %s ...' % tmppath) 196 | f.save(tmppath) 197 | self.storage.move_from_fpath(tmppath, path, f.filename) 198 | 199 | return resp.Redirect(redir_url) 200 | 201 | def _mkdir(self, dirname): 202 | e = self.storage.make_dir(dirname) 203 | if e: 204 | raise e 205 | 206 | return resp.Redirect(url.my(dirname)) 207 | 208 | def _rename(self, path, oldname, newname): 209 | self._log("mv %s/%s %s/%s" % (path, oldname, path, newname)) 210 | 211 | if os.path.dirname(oldname): 212 | raise resp.RequestError('Expected bare filename: ' + oldname) 213 | if os.path.dirname(newname): 214 | raise resp.RequestError('Expected bare filename: ' + newname) 215 | 216 | old = os.path.join(path, oldname) 217 | new = os.path.join(path, newname) 218 | 219 | self.storage.rename(old, new) 220 | return resp.Redirect(url.my(path)) 221 | 222 | def _delete(self, path, files): 223 | if isinstance(files, str): 224 | files = [files] 225 | 226 | for fname in files: 227 | if os.path.dirname(fname): 228 | return self.r400('Expected bare filename: ' + fname) 229 | 230 | fpath = os.path.join(path, fname) 231 | self._log("rm %s" % fpath) 232 | e = self.storage.delete(fpath) 233 | if e: 234 | raise e 235 | 236 | return resp.Redirect(url.my(path)) 237 | 238 | __all__ = [DirHandler] 239 | -------------------------------------------------------------------------------- /fileshelf/content/dir/tmpl/index.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | 3 | {% block style %} 4 | 81 | {% endblock %} 82 | 83 | {% block toolbar -%} 84 | {% endblock -%} 85 | 86 | {% block body %} 87 | {% if clipboard -%} 88 |
89 | 90 | clipboard 91 |
92 | 93 | 94 |
95 |
96 | 97 | 98 |
99 |
100 |
101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | {% for entry in clipboard -%} 110 | 111 | 112 | 113 | 114 | 121 | 122 | {% endfor %} 123 | 124 |
pathaction
{{ entry.do }}{{ entry.path }} 115 |
116 | 117 | 118 | 119 |
120 |
125 |
126 |
127 |
128 | {% endif %} 129 |
130 |
131 | 136 | 141 | 146 | 151 |
152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | {% for entry in lsdir %} 162 | 168 | 175 | 193 | 200 | 201 | 202 | 206 | 207 | {% if entry.play_url -%} 208 | 209 | 210 | 213 | 214 | 215 | 218 | 219 | 235 | {% endif -%} 236 | {% endfor -%} 237 |
name  created  size  
169 | {% if entry.open_url -%} 170 | 171 | {% else -%} 172 | 173 | {% endif -%} 174 | 176 | {% if rename == entry.name -%} 177 | 178 | 179 | 180 | 183 | 184 | 185 | {% elif entry.open_url -%} 186 | 187 | {{ entry.name }} 188 | 189 | {% else -%} 190 | {{ entry.name }} 191 | {% endif -%} 192 | 194 | {% if entry.rename_url -%} 195 | 196 | 197 | 198 | {% endif -%} 199 | {{ entry.created_at }}{{ entry.size }} 203 | 205 |
211 | 212 | 216 | 217 |
238 | 239 |
240 |
241 | 242 | 243 | 244 |
245 | {# after the main content is shown: -#} 246 | 251 | 252 | {% endblock %} 253 | -------------------------------------------------------------------------------- /fileshelf/content/edit/__init__.py: -------------------------------------------------------------------------------- 1 | import fileshelf.url as url 2 | import fileshelf.content as content 3 | import fileshelf.response as resp 4 | 5 | 6 | def codemirror_path(path=None): 7 | cdn_root = 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.23.0' 8 | return url.join(cdn_root, path) if path else cdn_root 9 | 10 | 11 | class EditHandler(content.Handler): 12 | conf = { 13 | 'mime_regex': { 14 | '^text/': content.Priority.CAN 15 | } 16 | } 17 | 18 | def render(self, req, storage, path): 19 | entry = storage.file_info(path) 20 | text, e = storage.read_text(path) 21 | if e: 22 | raise e 23 | 24 | args = { 25 | 'js_links': [ 26 | codemirror_path('codemirror.min.js'), 27 | codemirror_path('addon/dialog/dialog.min.js'), 28 | codemirror_path('addon/search/search.min.js'), 29 | codemirror_path('addon/search/searchcursor.min.js') 30 | ], 31 | 'css_links': [ 32 | codemirror_path('codemirror.min.css'), 33 | codemirror_path('addon/dialog/dialog.min.css') 34 | ], 35 | 'codemirror_root': codemirror_path(), 36 | 'text': text, 37 | 'mimetype': content.guess_mime(path), 38 | 'path_prefixes': url.prefixes(path, storage.exists), 39 | 'user': getattr(req, 'user'), 40 | 'read_only': not entry.can_write 41 | } 42 | tmpl = url.join(self.name, 'index.htm') 43 | return resp.RenderTemplate(tmpl, args) 44 | 45 | def action(self, req, storage, path): 46 | actlist = req.args.getlist(self.name) 47 | self._log(str(actlist)) 48 | 49 | if 'update' in actlist: 50 | self._log('update request for %s:' % path) 51 | # self._log(req.data) 52 | # self._log('------------------------------') 53 | e = storage.write_text(path, req.data) 54 | if e: 55 | self._log('RequestError: ' + str(e)) 56 | return resp.RequestError(str(e)) 57 | self._log('SendContents: saved') 58 | return resp.SendContents("saved") 59 | 60 | return resp.RequestError('unknown POST: ' + str(req.args)) 61 | 62 | __all__ = [EditHandler] 63 | -------------------------------------------------------------------------------- /fileshelf/content/edit/res/edit.js: -------------------------------------------------------------------------------- 1 | var editorModes = { 2 | clike: { 3 | desc: 'C-like', 4 | mime: 'text/css', 5 | fext: 'c,cc,cpp,java,scala' 6 | }, 7 | css: { 8 | desc: 'CSS', 9 | fext: 'css' 10 | }, 11 | diff: { 12 | desc: 'Diff', 13 | fext: 'diff,patch' 14 | }, 15 | erlang: { 16 | desc: 'Erlang', 17 | fext: 'erl' 18 | }, 19 | forth: { 20 | desc: 'Forth', 21 | fext: 'f' 22 | }, 23 | gas: { 24 | desc: 'GNU assembly', 25 | fext: 'S,s,asm' 26 | }, 27 | go: { 28 | desc: 'Go', 29 | fext: 'go' 30 | }, 31 | groovy: { 32 | desc: 'Groovy', 33 | fext: 'groovy' 34 | }, 35 | haskell: { 36 | desc: 'Haskell', 37 | fext: 'hs,lhs' 38 | }, 39 | javascript: { desc: 'Javascript', 40 | mime: "application/javascript", 41 | fext: "js" 42 | }, 43 | jinja2: { 44 | desc: 'Jinja2' 45 | }, 46 | lua: { 47 | desc: 'Lua', 48 | fext: 'lua' 49 | }, 50 | markdown: { 51 | desc: 'Markdown', 52 | fext: "md,markdown" 53 | }, 54 | mbox: { 55 | desc: 'mbox', 56 | fext: "mbox" 57 | }, 58 | mllike: { 59 | desc: 'Ocaml/SML', 60 | fext: "ml" 61 | }, 62 | perl: { 63 | desc: 'Perl', 64 | fext: "pl,pm" 65 | }, 66 | php: { 67 | desc: 'PHP', 68 | fext: "php" 69 | }, 70 | powershell: { 71 | desc: 'Powershell', 72 | fext: "ps1" 73 | }, 74 | python: { 75 | desc: 'Python', 76 | mime: "text/x-python", 77 | fext: 'py', 78 | }, 79 | r: { 80 | desc: 'R', 81 | fext: "r" 82 | }, 83 | ruby: { 84 | desc: 'Ruby', 85 | fext: "rb" 86 | }, 87 | rust: { 88 | desc: 'Rust', 89 | fext: "rs" 90 | }, 91 | scheme: { 92 | desc: 'Scheme', 93 | fext: "scm" 94 | }, 95 | shell: { 96 | desc: 'shell', 97 | mime: "text/x-sh", 98 | fext: "sh,bash" 99 | }, 100 | sql: { 101 | desc: 'SQL', 102 | fext: "sql" 103 | }, 104 | swift: { 105 | desc: 'Swift', 106 | fext: "swift" 107 | }, 108 | stex: { 109 | desc: 'Tex, Latex', 110 | fext: "tex", 111 | src: "https://codemirror.net/mode/stex/stex.js", 112 | }, 113 | troff: { 114 | desc: 'Troff', 115 | fext: "tr" 116 | }, 117 | verilog: { 118 | desc: 'Verilog', 119 | fext: "v" 120 | }, 121 | vue: { 122 | desc: 'Vue.js', 123 | fext: "vue" 124 | }, 125 | xml: { 126 | desc: 'XML, HTML', 127 | mime: "application/xml", 128 | fext: "xml,htm,html" 129 | }, 130 | yaml: { 131 | desc: 'YAML', 132 | fext: "yaml" 133 | }, 134 | }; 135 | 136 | (function (modes) { 137 | var selectMode = document.querySelector('#editor-mode'); 138 | for (var m in modes) { 139 | var mode = modes[m]; 140 | var opt = document.createElement('option'); 141 | opt.value = m; opt.innerText = mode.desc; 142 | if ('mime' in mode) { opt.setAttribute('data-mime', mode['mime']); } 143 | if ('fext' in mode) { opt.setAttribute('data-fext', mode['fext']); } 144 | if ('src' in mode) { opt.setAttribute('data-src', mode['src']); } 145 | selectMode.appendChild(opt); 146 | } 147 | })(editorModes); 148 | 149 | var editorFonts = [ 150 | "PT Mono", 151 | "Menlo", 152 | "Droid Mono", 153 | "Courier New", 154 | "Liberation Mono", 155 | ]; 156 | 157 | (function (fonts) { 158 | var selectFont = document.querySelector('#editor-font'); 159 | for (var i = 0; i < fonts.length; ++i) { 160 | var opt = document.createElement('option'); 161 | opt.value = opt.innerText = fonts[i]; 162 | selectFont.appendChild(opt); 163 | } 164 | })(editorFonts); 165 | 166 | var the_editor; 167 | var textarea = document.querySelector('#text-editor'); 168 | 169 | var loadedCache = {}; 170 | var loadCodemirror = function (path, loaded, src) { 171 | if (!src) 172 | src = data.codemirror_root + "/" + path; 173 | 174 | if (loadedCache[src]) 175 | return loaded(); 176 | 177 | var js_el = document.createElement('script'); 178 | js_el.onload = function () { 179 | loadedCache[src] = true; 180 | loaded(); 181 | }; 182 | js_el.src = src; 183 | var page = document.querySelector('#page'); 184 | page.appendChild(js_el); 185 | }; 186 | 187 | var saveText = function () { 188 | console.log('saving...'); 189 | var text = the_editor.getValue(); 190 | var url = location.pathname + '?edit=update'; 191 | 192 | var saveButton = document.querySelector('#save-btn'); 193 | saveButton.classList.add('save-btn-saving'); 194 | 195 | var xhr = new XMLHttpRequest(); 196 | xhr.onreadystatechange = function () { 197 | if (xhr.readyState !== XMLHttpRequest.DONE) 198 | return; 199 | 200 | saveButton.classList.remove('save-btn-saving'); 201 | if (xhr.status == 200) { 202 | the_editor.markClean(); 203 | saveButton.disabled = true; 204 | console.log('UPDATE: xhr.responseText: ' + xhr.responseText); 205 | } else { 206 | saveButton.classList.add('save-btn-failed'); 207 | console.log('UPDATE: xhr.status = ' + xhr.status); 208 | } 209 | }; 210 | xhr.onerror = function () { 211 | console.log('xhr error'); 212 | }; 213 | xhr.open('POST', url); 214 | xhr.setRequestHeader('Content-Type', 'text/plain'); 215 | xhr.send(text); 216 | }; 217 | 218 | var quitEditor = function () { 219 | var path = window.location.pathname.split('/'); 220 | path.pop(); 221 | var url = path.join('/'); 222 | window.location = url; 223 | }; 224 | 225 | var setupVim = function () { 226 | /* ex commands */ 227 | CodeMirror.Vim.defineEx('quit', 'q', function (wo, inp) { 228 | if (the_editor.isClean() || inp.input.endsWith('!')) { 229 | quitEditor(); 230 | } else { 231 | var msg = 'There are unsaved changes'; 232 | the_editor.openNotification(msg, { bottom: true }); 233 | } 234 | }); 235 | 236 | /* key mappings */ 237 | CodeMirror.Vim.map(';', ':', 'normal'); 238 | 239 | /* options */ 240 | var checkWrapLines = document.querySelector('input#editor-wrap-lines'); 241 | CodeMirror.Vim.defineOption('wrap', checkWrapLines.checked, 'boolean', [], 242 | function (val, arg) { 243 | console.log(arg); 244 | if (val === undefined) 245 | return checkWrapLines.checked; 246 | 247 | checkWrapLines.checked = val; 248 | checkWrapLines.onchange(); 249 | }); 250 | 251 | var checkLineNums = document.querySelector('input#editor-line-numbers'); 252 | CodeMirror.Vim.defineOption('number', checkLineNums.checked, 'boolean', [], 253 | function (val) { 254 | if (val === undefined) 255 | return checkLineNums.checked; 256 | 257 | checkLineNums.checked = val; 258 | checkLineNums.onchange(); 259 | }); 260 | 261 | var selectFontFamily = document.querySelector('select#editor-font'); 262 | CodeMirror.Vim.defineOption('font', selectFontFamily.selectedOptions[0], 263 | 'string', [], function (val) { 264 | if (val === undefined) 265 | return selectFontFamily.selectedOptions[0].value; 266 | }); 267 | }; 268 | 269 | window.onload = function () { 270 | var $ = function () { return document.querySelector(...arguments); } 271 | var selectMode = $('select#editor-mode'); 272 | 273 | var detectMode = function () { 274 | var the_ext = window.location.pathname.split('.'); 275 | if (the_ext.length < 2) 276 | return; 277 | the_ext = (function (a) { return a[a.length-1]; })(the_ext); 278 | console.log('detecting mode for extension ' + the_ext); 279 | 280 | for (var i = 0; i < selectMode.children.length; ++i) { 281 | var opt = selectMode.children[i]; 282 | var mime = opt.getAttribute('data-mime'); 283 | if (mime && mime === data.mimetype) 284 | return i; 285 | 286 | var file_exts = opt.getAttribute('data-fext'); 287 | if (!file_exts) continue; 288 | file_exts = file_exts.split(','); 289 | if (file_exts.indexOf(the_ext) >= 0) 290 | return i; 291 | } 292 | return null; 293 | }; 294 | 295 | CodeMirror.commands.save = saveText; 296 | 297 | the_editor = CodeMirror.fromTextArea(textarea, { 298 | lineNumbers: true 299 | }); 300 | the_editor.focus(); 301 | 302 | 303 | /* mode */ 304 | selectMode.onchange = function (event) { 305 | var mode_changed = function () { 306 | the_editor.setOption('mode', mode); 307 | console.log('editor mode changed to ' + (mode ? mode : 'plain')); 308 | }; 309 | var mode_opt = selectMode.selectedOptions[0]; 310 | var mode = mode_opt.value; 311 | if (!mode) 312 | return mode_changed(); 313 | var mode_src = mode_opt.getAttribute('data-src'); 314 | console.log('mode_src = ' + mode_src); 315 | 316 | loadCodemirror('mode/'+mode+'/'+mode+'.min.js', mode_changed, mode_src); 317 | }; 318 | 319 | var mode_index = detectMode(); 320 | if (mode_index) { 321 | var mode_opt = selectMode.options[mode_index]; 322 | console.log('detected mode: '+mode_opt.value+' ('+mode_index+')'); 323 | mode_opt.selected = true; 324 | selectMode.onchange(); 325 | } 326 | 327 | /* keymap */ 328 | var selectKeymap = $('select#editor-keymap'); 329 | selectKeymap.onchange = function () { 330 | var keymap_opt = selectKeymap.selectedOptions[0]; 331 | var keymap = keymap_opt.value; 332 | 333 | loadCodemirror('keymap/' + keymap + '.min.js', function () { 334 | the_editor.setOption('keyMap', keymap); 335 | if (keymap == 'vim') 336 | setupVim(); 337 | console.log('editor keymap changed to ' + keymap); 338 | }); 339 | 340 | if (window.localStorage) { 341 | localStorage.setItem('fileshelf/edit/keymap', keymap); 342 | } 343 | }; 344 | 345 | var keymap = selectKeymap.selectedOptions[0].value; 346 | if (window.localStorage) { 347 | var saved = localStorage.getItem('fileshelf/edit/keymap'); 348 | if (saved) { keymap = saved; } 349 | } 350 | console.log('selected keymap: ' + keymap); 351 | selectKeymap.onchange(); 352 | 353 | /* font */ 354 | var selectFontFamily = $('select#editor-font'); 355 | selectFontFamily.onchange = function () { 356 | var opt = selectFontFamily.selectedOptions[0]; 357 | var val = opt.value; 358 | if (window.localStorage) { localStorage.setItem('fileshelf/edit/font', val); } 359 | 360 | var cm = $('.CodeMirror'); 361 | cm.style.fontFamily = val; 362 | }; 363 | 364 | if (window.localStorage) { 365 | var fontFamily = localStorage.getItem('fileshelf/edit/font'); 366 | if (fontFamily) { 367 | $('option[value="' + fontFamily + '"]').selected = true; 368 | selectFontFamily.onchange(); 369 | } 370 | } 371 | 372 | /* font size */ 373 | var selectFontSize = $('input#editor-font-size'); 374 | selectFontSize.onchange = function (event) { 375 | var fontsize = this.value + 'pt'; 376 | if (window.localStorage) { 377 | localStorage.setItem('fileshelf/edit/fontsize', this.value); 378 | } 379 | 380 | var cmdiv = $('.CodeMirror'); 381 | cmdiv.style.fontSize = fontsize; 382 | 383 | console.log('font size changed to ' + fontsize); 384 | }; 385 | 386 | if (window.localStorage) { 387 | var fontsize = localStorage.getItem('fileshelf/edit/fontsize'); 388 | if (fontsize) { selectFontSize.value = fontsize; } 389 | } 390 | selectFontSize.onchange(); 391 | 392 | /* show line numbers */ 393 | var checkLineNumbers = $('input#editor-line-numbers'); 394 | var toggleLineNumbers = function () { 395 | var checked = checkLineNumbers.checked; 396 | the_editor.setOption('lineNumbers', checked); 397 | if (window.localStorage) { 398 | localStorage.setItem('fileshelf/edit/linenum', checked ? 'on' : 'off'); 399 | } 400 | }; 401 | checkLineNumbers.checked = true; 402 | if (window.localStorage) { 403 | if (localStorage.getItem('fileshelf/edit/linenum') === 'off') { 404 | checkLineNumbers.checked = false; 405 | } 406 | } 407 | checkLineNumbers.onchange = toggleLineNumbers; 408 | checkLineNumbers.onchange(); 409 | 410 | /* line wrap */ 411 | var checkLineWrap = $('input#editor-wrap-lines'); 412 | checkLineWrap.onchange = function () { 413 | var checked = checkLineWrap.checked; 414 | the_editor.setOption('lineWrapping', checked); 415 | if (window.localStorage) { 416 | localStorage.setItem('fileshelf/edit/linewrap', checked ? 'on':'off'); 417 | } 418 | }; 419 | checkLineWrap.checked = true; 420 | if (window.localStorage) { 421 | if (localStorage.getItem('fileshelf/edit/linewrap') === 'off') { 422 | checkLineWrap.checked = false; 423 | } 424 | } 425 | checkLineWrap.onchange(); 426 | 427 | /* resize */ 428 | var cmdiv = $('.CodeMirror'); 429 | var cmtop = cmdiv.offsetTop; 430 | cmdiv.style.height = 'calc(100vh - 1em - '+cmtop+'px)'; 431 | the_editor.refresh(); 432 | 433 | /* save button */ 434 | var saveButton = $('#save-btn'); 435 | saveButton.onclick = saveText; 436 | saveButton.disabled = true; 437 | the_editor.on('change', function () { 438 | saveButton.disabled = the_editor.isClean(); 439 | }); 440 | 441 | /* settings */ 442 | var settingsButton = $('#settings-btn'); 443 | var settingsTrig = $('#settings-summary'); 444 | settingsButton.onclick = function () { settingsTrig.click(); return false; }; 445 | }; 446 | -------------------------------------------------------------------------------- /fileshelf/content/edit/tmpl/index.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | {% block style %} 3 | 48 | {% endblock %} 49 | 50 | {% block toolbar %} 51 | 52 | {##} 53 | 54 | 55 | {{ super() }} 56 | 57 | {% endblock %} 58 | 59 | {% block body %} 60 | 63 | 64 |
65 | 68 |
69 | 70 | 71 | 72 | 75 | 84 | 85 | 88 | 91 | 92 | 95 | 98 | 99 | 100 | 101 | 104 | 109 | 110 | 113 | 116 | 117 | 120 | 127 | 128 | 129 | 130 | 131 | 132 |
73 | 74 | 76 |
77 | 82 |
83 |
86 | 87 | 89 | 90 | 93 | 94 | 96 | 97 |
102 | 103 | 105 | 108 | 111 | 112 | 114 | 115 | 118 | 119 | 121 | 126 |
133 |
134 |
135 | 137 | 143 | 144 | {% endblock %} 145 | -------------------------------------------------------------------------------- /fileshelf/content/epub/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import fileshelf.content as content 3 | 4 | 5 | class EpubHandler(content.Handler): 6 | conf = { 7 | 'extensions': { 8 | 'epub': content.Priority.SHOULD 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /fileshelf/content/epub/tmpl/index.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | {% block style %} 3 | 10 | {% endblock %} 11 | 12 | {% block toolbar %} 13 |
14 | 15 | 16 |
17 | {% endblock %} 18 | 19 | {% block body %} 20 | 24 |
25 |
26 | 27 | 28 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /fileshelf/content/img/__init__.py: -------------------------------------------------------------------------------- 1 | import fileshelf.content as content 2 | 3 | 4 | class ImageHandler(content.Handler): 5 | conf = { 6 | 'mime_regex': { 7 | #'^image/': content.Priority.CAN 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /fileshelf/content/img/tmpl/index.htm: -------------------------------------------------------------------------------- 1 | {% extends 'tmpl.htm' %} 2 | 3 | {% block style %} 4 | 9 | {% endblock %} 10 | 11 | {% block body %} 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /fileshelf/content/markdown/__init__.py: -------------------------------------------------------------------------------- 1 | import fileshelf.content as content 2 | import fileshelf.content.edit as edit 3 | 4 | 5 | class MarkdownHandler(edit.EditHandler): 6 | conf = { 7 | 'extensions': { 8 | 'md': content.Priority.SHOULD, 9 | 'markdown': content.Priority.SHOULD 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /fileshelf/content/markdown/tmpl/index.htm: -------------------------------------------------------------------------------- 1 | {% extends 'edit/index.htm' %} 2 | 3 | {% block toolbar %} 4 | markdown! 5 | {{ super() }} 6 | {% endblock %} 7 | 8 | {% block body %} 9 | {{ super() }} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /fileshelf/response.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | 4 | class RequestError(Exception): 5 | """ response status 400 is required """ 6 | pass 7 | 8 | 9 | class Response: 10 | """ content.Response is the base class for plugin responses """ 11 | pass 12 | 13 | 14 | class Redirect(Response): 15 | """ redirect to self.url """ 16 | def __init__(self, url): 17 | self.url = url 18 | 19 | def __call__(self): 20 | return flask.redirect(self.url) 21 | 22 | 23 | class SendContents(Response): 24 | """ send self.contents as response with status 200 """ 25 | def __init__(self, contents): 26 | self.contents = contents 27 | 28 | def __call__(self): 29 | return flask.make_response(self.contents) 30 | 31 | 32 | class RenderTemplate(Response): 33 | """ render self.tmpl with self.params, status 200 """ 34 | def __init__(self, tmpl, params): 35 | self.tmpl = tmpl 36 | self.params = params 37 | 38 | def __call__(self): 39 | return flask.render_template(self.tmpl, **self.params) 40 | -------------------------------------------------------------------------------- /fileshelf/rproxy.py: -------------------------------------------------------------------------------- 1 | class ReverseProxied(object): 2 | """Wrap the application in this middleware and configure the 3 | front-end server to add these headers, to let you quietly bind 4 | this to a URL other than / and to an HTTP scheme that is 5 | different than what is used locally. 6 | 7 | In nginx: 8 | location /myprefix { 9 | proxy_pass http://192.168.0.1:5001; 10 | proxy_set_header Host $host; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header X-Forwarded-Proto $scheme; 13 | proxy_set_header X-Script-Name /myprefix; 14 | } 15 | 16 | :param app: the WSGI application 17 | 18 | Thanks to http://flask.pocoo.org/snippets/35/ 19 | """ 20 | 21 | def __init__(self, app): 22 | self.app = app 23 | 24 | def __call__(self, environ, start_response): 25 | script_name = environ.get('HTTP_X_SCRIPT_NAME', '') 26 | if script_name: 27 | environ['SCRIPT_NAME'] = script_name 28 | path_info = environ['PATH_INFO'] 29 | if path_info.startswith(script_name): 30 | environ['PATH_INFO'] = path_info[len(script_name):] 31 | 32 | scheme = environ.get('HTTP_X_FORWARDED_PROTO', '') 33 | if scheme: 34 | environ['wsgi.url_scheme'] = scheme 35 | 36 | return self.app(environ, start_response) 37 | -------------------------------------------------------------------------------- /fileshelf/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from fileshelf.storage.local import LocalStorage 2 | 3 | __all__ = [LocalStorage] 4 | -------------------------------------------------------------------------------- /fileshelf/storage/local.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import stat 4 | import uuid 5 | import errno 6 | import shutil 7 | import uuid 8 | 9 | from werkzeug.utils import secure_filename 10 | 11 | import fileshelf.url as url 12 | import fileshelf.content as content 13 | 14 | 15 | class LocalStorage: 16 | def __init__(self, storage_dir, data_dir): 17 | os.path.isdir(storage_dir) or self._not_found(storage_dir) 18 | 19 | os.path.exists(data_dir) or os.mkdir(data_dir) 20 | 21 | clipboard_dir = os.path.join(data_dir, 'clipboard') 22 | os.path.exists(clipboard_dir) or os.mkdir(clipboard_dir) 23 | 24 | trash_dir = os.path.join(data_dir, 'trash') 25 | os.path.exists(trash_dir) or os.mkdir(trash_dir) 26 | 27 | self.storage_dir = storage_dir 28 | self.data_dir = data_dir 29 | self.clipboard_dir = clipboard_dir 30 | self.trash_dir = trash_dir 31 | 32 | def _not_found(self, path): 33 | raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), path) 34 | 35 | def _fullpath(self, *args): 36 | return os.path.join(self.storage_dir, *args) 37 | 38 | def _log(self, msg): 39 | print('## LocalStorage: ' + msg) 40 | 41 | def make_dir(self, path): 42 | path = self._fullpath(path) 43 | try: 44 | os.mkdir(path) 45 | except OSError as e: 46 | return e 47 | 48 | def list_dir(self, path): 49 | path = self._fullpath(path) 50 | return os.listdir(path) 51 | 52 | def move_from_fpath(self, fpath, path, name): 53 | """ moves a file from external `fpath` to internal `path`/`name` """ 54 | if os.path.sep in name: 55 | return ValueError('filename `%s` contains os.sep' % name) 56 | try: 57 | dst = self._fullpath(path, name) 58 | # just a security precaution: 59 | if not dst.startswith(self._fullpath()): 60 | self._log('dst=%s, fullpath=%s' % (dst, self._fullpath())) 61 | return ValueError('does not start with self._fullpath()') 62 | 63 | self._log('mv %s -> %s' % (fpath, dst)) 64 | shutil.move(fpath, dst) 65 | except (OSError, IOError) as e: 66 | return e 67 | 68 | def rename(self, oldpath, newpath): 69 | old = self._fullpath(oldpath) 70 | new = self._fullpath(newpath) 71 | try: 72 | self._log("mv %s %s" % (old, new)) 73 | shutil.move(old, new) 74 | except OSError as e: 75 | return e 76 | 77 | def delete(self, path): 78 | path = self._fullpath(path) 79 | try: 80 | if os.path.isdir(path): 81 | os.rmdir(path) 82 | else: 83 | os.remove(path) 84 | except OSError as e: 85 | return e 86 | 87 | def file_info(self, path): 88 | dir_st = os.lstat(self.storage_dir) 89 | 90 | fpath = os.path.join(self.storage_dir, path) 91 | 92 | def entry(): return 0 93 | entry.path = path 94 | entry.fpath = fpath 95 | 96 | st = os.lstat(fpath) 97 | entry.size = st.st_size 98 | entry.is_dir = stat.S_ISDIR(st.st_mode) 99 | 100 | entry.mimetype = content.guess_mime(path) 101 | 102 | def is_audio(): 103 | return entry.mimetype and entry.mimetype.startswith('audio/') 104 | 105 | def is_viewable(): 106 | return entry.mimetype and entry.mimetype in ['application/pdf'] 107 | 108 | entry.is_audio = is_audio 109 | entry.is_viewable = is_viewable 110 | 111 | entry.ctime = st.st_ctime 112 | 113 | entry.can_delete = bool(dir_st.st_mode & stat.S_IWUSR) 114 | entry.can_rename = entry.can_delete 115 | entry.can_read = os.access(fpath, os.R_OK) 116 | entry.can_write = os.access(fpath, os.W_OK) 117 | 118 | return entry 119 | 120 | def exists(self, path): 121 | path = self._fullpath(path) 122 | ret = os.path.exists(path) 123 | return ret 124 | 125 | def is_dir(self, path): 126 | return os.path.isdir(self._fullpath(path)) 127 | 128 | def read_text(self, path): 129 | """ returns (text or None, exception or None) """ 130 | path = self._fullpath(path) 131 | try: 132 | text = io.open(path, encoding='utf8').read() 133 | return text, None 134 | except (IOError, OSError, UnicodeDecodeError) as e: 135 | return None, e 136 | 137 | def write_text(self, path, data): 138 | path = self._fullpath(path) 139 | try: 140 | data = data.decode('utf-8') if hasattr(data, 'decode') else data 141 | with io.open(path, 'w', encoding='utf8') as f: 142 | f.write(data) 143 | except (IOError, OSError, UnicodeDecodeError) as e: 144 | return e 145 | 146 | def _clipboard_db(self): 147 | return os.path.join(self.data_dir, 'cb.txt') 148 | 149 | def clipboard_cut(self, path): 150 | src = os.path.join(self.storage_dir, path) 151 | self._log('cut(%s)' % src) 152 | 153 | name = str(uuid.uuid1()) 154 | dst = os.path.join(self.clipboard_dir, name) 155 | 156 | cb_db = self._clipboard_db() 157 | # try: 158 | shutil.move(src, dst) 159 | with io.open(cb_db, mode='a', encoding='utf8') as f: 160 | s = u'%s %s\n' % (name, path) 161 | self._log('adding cut record: ' + s) 162 | f.write(s) 163 | # except Exception as e: 164 | # return e 165 | 166 | def clipboard_copy(self, path): 167 | name = str(uuid.uuid1()) 168 | dst = os.path.join(self.clipboard_dir, name) 169 | src = os.path.join(self.storage_dir, path) 170 | try: 171 | os.symlink(src, dst) 172 | with io.open(self._clipboard_db(), 'a', encoding='utf8') as f: 173 | f.write('%s %s\n' % (name, path)) 174 | except Exception as e: 175 | return e 176 | 177 | def clipboard_list(self): 178 | cb_db = self._clipboard_db() 179 | if not os.path.exists(cb_db): 180 | return [] 181 | 182 | ret = [] 183 | lines = io.open(cb_db, encoding='utf8').readlines() 184 | for line in lines: 185 | line = line.strip() 186 | u = line[:36] 187 | path = line[37:] 188 | cut = os.path.join(self.clipboard_dir, u) 189 | ret.append({ 190 | 'path': path, 191 | 'tmp': cut, 192 | 'cut': not os.path.islink(cut) 193 | }) 194 | return ret 195 | 196 | def clipboard_paste(self, path=None, dryrun=False): 197 | ''' if path is None, move everything back ''' 198 | cb_db = self._clipboard_db() 199 | if not os.path.exists(cb_db): 200 | self._log('no ' + cb_db) 201 | return 202 | 203 | lines = io.open(cb_db, encoding='utf8').readlines() 204 | for line in lines: 205 | self._log('processing: ' + line) 206 | line = line.strip() 207 | u = line[:36] 208 | dst = line[37:] 209 | if path is not None: 210 | dst = os.path.basename(dst) 211 | dst = os.path.join(path, dst) 212 | try: 213 | tmp = os.path.join(self.clipboard_dir, u) 214 | dst = os.path.join(self.storage_dir, dst) 215 | self._log('processing %s -> %s' % (tmp, dst)) 216 | if os.path.islink(tmp): 217 | src = os.readlink(tmp) 218 | if path is not None: 219 | self._log('copy "%s" "%s"' % (src, dst)) 220 | if os.path.isdir(src): 221 | self._log('copytree(%s, %s)' % (src, dst)) 222 | dryrun or shutil.copytree(src, dst) 223 | else: 224 | self._log('copy(%s, %s)' % (src, dst)) 225 | dryrun or shutil.copy(src, dst) 226 | self._log('rm "%s"' % tmp) 227 | dryrun or os.remove(tmp) 228 | else: 229 | # TODO: check if dirname(dst) still exists 230 | # TODO: do something if there is a file with that name 231 | # if not os.path.exists(dst): 232 | self._log('mv "%s" "%s"' % (tmp, dst)) 233 | dryrun or shutil.move(tmp, dst) 234 | except Exception as e: 235 | self._log('clear(%s), error: %s' % (dst, e)) 236 | try: 237 | self._log('rm ' + cb_db) 238 | dryrun or os.remove(cb_db) 239 | except Exception as e: 240 | self._log("tried to remove %s, failed: %s" % (cb_db, e)) 241 | 242 | def static_download(self, path, offload): 243 | entry = self.file_info(path) 244 | if entry.size < offload.minsize: 245 | return (None, None) 246 | 247 | fname = os.path.basename(path) 248 | tmp_id = str(uuid.uuid1()) 249 | tmp_dir = os.path.join(offload.dir, tmp_id) 250 | tmp_url = url.join(offload.path, tmp_id, fname) 251 | fpath = self._fullpath(path) 252 | tmp_path = os.path.join(tmp_dir, fname) 253 | # self._log('static_download: ln %s %s' % (fpath, tmp_path)) 254 | try: 255 | os.mkdir(tmp_dir) 256 | os.symlink(fpath, tmp_path) 257 | return (tmp_url, None) 258 | except (OSError, IOError) as e: 259 | return (None, e) 260 | 261 | def mktemp(self, fname=None): 262 | tmp_dir = os.path.join(self.data_dir, 'tmp') 263 | if not os.path.exists(tmp_dir): 264 | os.mkdir(tmp_dir) 265 | _id = str(uuid.uuid1()) 266 | if fname: 267 | fname = _id + '.' + secure_filename(fname) 268 | else: 269 | fname = _id 270 | return os.path.join(tmp_dir, fname) 271 | -------------------------------------------------------------------------------- /fileshelf/url.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import urllib 3 | import flask 4 | 5 | _my = '/my' 6 | _pub = '/pub' 7 | _res = '/res' 8 | 9 | 10 | def join(*args): 11 | """ joins URLs """ 12 | return '/'.join([arg.rstrip('/') for arg in args if len(arg)]) 13 | 14 | 15 | def my(*args, **kwargs): 16 | path = join(*args) if args else None 17 | u = flask.url_for('path_handler', path=path) 18 | if kwargs.get('see'): 19 | u += '?see' 20 | elif kwargs.get('raw'): 21 | u += '?raw' 22 | return u 23 | 24 | 25 | def res(*args): 26 | path = join(*args) if args else None 27 | u = flask.url_for('static_handler', path=path) 28 | return u 29 | 30 | 31 | def _prefixes(exists, path): 32 | """ generates a list of [(path_chunk, path_href or None)] 33 | `path_href` may be None if this path is not in the filesystem 34 | """ 35 | url = my() 36 | res = [] 37 | pre = '' 38 | for d in path.split('/'): 39 | if not d: 40 | continue 41 | pre = os.path.join(pre, d) 42 | 43 | if exists(pre): 44 | url = join(url, d) 45 | else: 46 | url = None 47 | 48 | res.append((d, url)) 49 | return res 50 | 51 | 52 | def prefixes(path, exists=lambda _: True, tabindex=1): 53 | ps = _prefixes(exists, path) 54 | ret = [] 55 | for i, p in enumerate(ps): 56 | ret.append({ 57 | 'name': p[0], 58 | 'href': p[1], 59 | 'tabindex': tabindex + i 60 | }) 61 | return ret 62 | 63 | def quote(s: str) -> str: 64 | return urllib.parse.quote(s, safe='') 65 | 66 | def unquote(s: str) -> str: 67 | return urllib.parse.unquote(s) 68 | -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from fileshelf import FileShelf, config 6 | 7 | if __name__ == '__main__': 8 | from pathlib import Path 9 | p = Path(sys.argv[0]).expanduser().absolute() 10 | appdir = p.parent.as_posix() 11 | 12 | conf = config.from_arguments(appdir) 13 | 14 | if not conf.get('multiuser'): 15 | print("Serving:", conf['storage_dir'], file=sys.stderr) 16 | 17 | app = FileShelf(conf) 18 | app.run() 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==7.1.1 2 | Flask==1.1.2 3 | itsdangerous==1.1.0 4 | Jinja2==2.11.1 5 | MarkupSafe==1.1.1 6 | passlib==1.7.2 7 | Werkzeug==1.0.1 8 | -------------------------------------------------------------------------------- /static/dir.js: -------------------------------------------------------------------------------- 1 | /* selection */ 2 | window.focused = undefined; 3 | window.onfocus = function () { 4 | if (!window.focused) 5 | return; 6 | var focusable = focused.querySelector('.focusable'); 7 | if (focusable) 8 | focusable.focus(); 9 | }; 10 | 11 | var entries = document.querySelectorAll('.entry-row'); 12 | for (var i = 0; i < entries.length; ++i) { 13 | var tr = entries[i]; 14 | tr.onclick = function () { 15 | window.focused = this; 16 | var focusable = this.querySelector('.focusable'); 17 | if (focusable) focusable.focus(); 18 | }; 19 | 20 | var filesel = tr.querySelector('.entry-select'); 21 | filesel.style.display = 'none'; 22 | 23 | var focusable = tr.querySelector('.focusable'); 24 | if (focusable) { 25 | focusable.onfocus = function () { 26 | if (focused) { 27 | focused.classList.remove('focused'); 28 | /* TODO: if in existing selection, keep checked */ 29 | focused.querySelector('.file-select').checked = false; 30 | } 31 | focused = this.parentElement.parentElement; 32 | focused.classList.add('focused'); 33 | focused.querySelector('.file-select').checked = true; 34 | }; 35 | } 36 | } 37 | document.querySelector('#deh-select').style.display = 'none'; 38 | 39 | var renamefile = document.querySelector('#file-rename'); 40 | if (renamefile) { 41 | renamefile.onkeyup = function (e) { 42 | if (e.key === 'Escape') 43 | window.location = location.pathname; 44 | }; 45 | } 46 | 47 | var nooutline = document.createElement('style'); 48 | nooutline.innerText = '.entry-name > a:focus { outline: 0 }'; 49 | document.head.appendChild(nooutline); 50 | 51 | /* keyboard */ 52 | var macKbd = (navigator.userAgent.indexOf('Macintosh') >= 0); 53 | 54 | document.body.onkeydown = function (e) { 55 | //console.log('body.onkeydown: ', e); 56 | var isEditable = e.target.classList.contains('editable'); 57 | 58 | var newFocused; 59 | if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { 60 | //console.log((e.shiftKey?"shift+":"") + (e.ctrlKey?"ctrl+":"") + 61 | // (e.altKey?"alt+":"") + (e.metaKey?"meta+":"") + e.key); 62 | if (!isEditable && (macKbd ? e.metaKey : e.ctrlKey)) { 63 | switch (e.key) { 64 | case 'c': 65 | document.querySelector('#do-copy').click(); 66 | break; 67 | case 'x': 68 | document.querySelector('#do-cut').click(); 69 | break; 70 | case 'v': 71 | document.querySelector('form#cb-paste-form').submit(); 72 | break; 73 | } 74 | } 75 | if (macKbd ? (e.metaKey && e.key == 'Backspace') 76 | : (e.key == 'Delete')) { 77 | if (!isEditable) 78 | document.querySelector('#do-delete').click(); 79 | } 80 | 81 | if (macKbd ? e.metaKey : e.altKey) { 82 | switch (e.key) { 83 | case 'ArrowUp': 84 | var path = location.pathname.split('/'); 85 | if (path.length > 2) { 86 | path.pop(); 87 | window.location = path.join('/'); 88 | } 89 | break; 90 | case 'ArrowDown': 91 | var path = focused.querySelector('.file-link').href; 92 | window.location = path; 93 | break; 94 | } 95 | } 96 | } else { 97 | switch (e.key) { 98 | case 'ArrowUp': 99 | if (focused) { 100 | newFocused = focused; 101 | do { /* skip rows without "entry-name" */ 102 | newFocused = newFocused.previousElementSibling; 103 | } while (newFocused && !(newFocused.querySelector('.focusable'))); 104 | } else 105 | newFocused = document.querySelectorAll('.entry-row:last-child'); 106 | break; 107 | case 'ArrowDown': 108 | if (focused) { 109 | newFocused = focused; 110 | do { /* skip rows without "entry-name" */ 111 | newFocused = newFocused.nextElementSibling; 112 | } while (newFocused && !(newFocused.querySelector('.focusable'))); 113 | } else 114 | newFocused = document.querySelectorAll('.entry-row')[0]; 115 | break; 116 | case 'Home': 117 | if (!isEditable) newFocused = document.querySelector('.entry-row'); 118 | break; 119 | case 'End': 120 | if (!isEditable) newFocused = document.querySelector('.entry-row:last-child'); 121 | break; 122 | case 'F2': 123 | if (focused) { 124 | var rename = focused.querySelector('.entry-rename a'); 125 | if (rename) rename.click(); 126 | } 127 | break; 128 | default: 129 | console.log(e.key + ' is down'); 130 | } 131 | } 132 | /* focus next */ 133 | if (newFocused) { 134 | var focusable = newFocused.querySelector('.focusable'); 135 | if (focusable) focusable.focus(); 136 | focused = newFocused; 137 | } 138 | }; 139 | -------------------------------------------------------------------------------- /static/dir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/static/dir.png -------------------------------------------------------------------------------- /static/dl.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /static/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/static/file.png -------------------------------------------------------------------------------- /static/nerdy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/static/nerdy.png -------------------------------------------------------------------------------- /static/rename.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/static/rename.png -------------------------------------------------------------------------------- /static/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 10 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /storage/fs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/storage/fs.png -------------------------------------------------------------------------------- /storage/test/filenames/Стус.txt: -------------------------------------------------------------------------------- 1 | осліпле листя відчувало яр 2 | і палене збігало до потоку 3 | -------------------------------------------------------------------------------- /storage/test/filenames/שלום.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EarlGray/fileshelf/9d4f957736f06cb19975a587ce1d988de142dc2e/storage/test/filenames/שלום.txt -------------------------------------------------------------------------------- /storage/test/filenames/你好.txt: -------------------------------------------------------------------------------- 1 | 千里之行,一步开始 2 | -------------------------------------------------------------------------------- /storage/test/unicode/faces.txt: -------------------------------------------------------------------------------- 1 | (◕‿◕) 2 | (^̮^) 3 | ʘ‿ʘ 4 | ಠ_ಠ 5 | (◕‿◕) 6 | (◕‿◕) 7 | ಠ⌣ಠ 8 | ಠ‿ಠ 9 | (ʘ‿ʘ) 10 | (ಠ_ಠ) 11 | ¯\_(ツ)_/¯ 12 | (ಠ⌣ಠ 13 | ಠಠ⌣ಠ) 14 | (ಠ‿ಠ) 15 | ٩◔̯◔۶ 16 | ヽ༼ຈل͜ຈ༽ノ 17 | ♥‿♥ 18 | ◔̯◔ 19 | ٩◔̯◔۶ 20 | ⊙﹏⊙ 21 | (¬_¬) 22 | (¬_¬) 23 | (;一_一) 24 | (͡° ͜ʖ ͡°) 25 | ¯\_(ツ)_/¯ 26 | (° ͜ʖ °) 27 | ¯\(°_o)/¯ 28 | ( ゚ヮ゚) 29 | ヽ༼ຈل͜ຈ༽ノ 30 | (︺︹︺) 31 | (︺︹︺) 32 | -------------------------------------------------------------------------------- /tmpl/404.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | {% block body %} 3 |

Not found

4 | {% if maybe_new -%} 5 |

Maybe, you would like to create a {{ maybe_new.desc }} 6 | {{ maybe_new.path }}:

7 |
8 | 9 | 10 | 11 | 12 |
13 | {% else -%} 14 |

15 | This file is not found
16 | To try again is useless
17 | It is just not here
18 |

19 | {% endif -%} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /tmpl/500.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | {% block body %} 3 |

oops!

4 |
{{ e }}
5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /tmpl/frame.htm: -------------------------------------------------------------------------------- 1 | {% extends "tmpl.htm" %} 2 | 3 | {% block style %} 4 | 30 | {% endblock %} 31 | 32 | {% block toolbar %} 33 | 34 | {{ super() }} 35 | {% endblock %} 36 | 37 | {% block body %} 38 |
39 | 47 |
48 | 49 | 77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /tmpl/media.htm: -------------------------------------------------------------------------------- 1 | {% extends 'tmpl.htm' %} 2 | {% block body %} 3 |
4 | 7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /tmpl/res/dir.png.base64: -------------------------------------------------------------------------------- 1 | iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AoHFxYu1NCc1wAAAtpJREFUSMfVVD2PHEUUrOrp3VnfcbKAiMABiUPDBUhIjpCFCIjIIQKLxBIhvwHL/8GZJRIEmYXgH4DgHyCRQGDJ3rvT7s1Ov1cEPd3T4w/JhoiOekavq15VvW7g/74IAPd//B3nQwr9qoshkgDQCRBzAbvOD+tNAqQvb15/JYL41Tff4aeHv+L9W6c3xfCxixsCsLYJw192sf92sz76A7cA/PwKCu5+/wsgnazX3b1V130RyFC0cSqS8PiQ7OvH2ycPjvsTAEMFEIBLAMnyRnLsukE47gcAFo+vWL/bh49G04dmKQCEoBlAAIGryXVn3R9/MNIFrSAALoe7YBLMAQ8OgFxhM2CPHzY9H8Y3jq7efq33z8fkbwuCBEgNhQABQdINATckwZWJ3R1eviGYCSQQSMQYTvsYrkWSnx318d2wYbaEbLpXVVGAXZr2gvsMngkFhzAhvZPcP40uWRDhkxfQbM/UPeautfgu4O7T/3qm1lk09y1Zun92CiQ0tjVERc1U4xDkGZwojWEbzbUFPA8OmR3SPCEouRQbKkGxJyP5RNqec2kbk/lZvlMkqblg4VQGQAm3saoll3KELoGAkvtZPJifR0Eka7ya3FK1qQChBlzsm/dTNZn3gpLpPI7JdwoQ+RQq25CL5GaEG8I61KruZ7HSLo7J9xbkJLoF8tNBly4nS9r/VfJkVUaQu2sfh2SXXQjejP9iUts7UUkaKxe91DEnALlJl3FIdujoTj5nRjFPSYuTQ2QdgIz9jL9u7od4GNMYQlAg0eQ0B91YslDI6spSQVNm7mMckqUYoPb5XCgtaZJVSb0rzJ3POZRQMkIyT9EMJrmzfZ/bhpo3u3VAxSbOwRYeyQHAzdzixcXZI8r/lNlbnJppnw4ujqJ9Bhb3JAtkuXQMXfe3Mzzie5/cfnMcdqeH3cXrABDwHBUvWIXApzpvBK6OTp7E9ZXf+JJY/2bpv0O8xPoH5dFKhTVcfQ8AAAAldEVYdGRhdGU6Y3JlYXRlADIwMTYtMTAtMDdUMjM6MjI6NDYrMDI6MDDduPSoAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE2LTEwLTA3VDIzOjIyOjQ2KzAyOjAwrOVMFAAAAABJRU5ErkJggg== 2 | -------------------------------------------------------------------------------- /tmpl/res/dl.svg.base64: -------------------------------------------------------------------------------- 1 | PHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjUxMnB4IiBoZWlnaHQ9IjUxMnB4IiB2aWV3Qm94PSIwIDAgNTEyIDUxMiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNTEyIDUxMiIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8Zz48cGF0aCBkPSJNNDQ2Ljg0NCwyMDguODc1QzQ0Ny42MjUsMjAzLjMxMyw0NDgsMTk3LjY1Niw0NDgsMTkyYzAtNzAuNTYzLTU3LjQwNi0xMjgtMTI4LTEyOCBjLTQwLjkzOCwwLTc4LjUzMSwxOS4zNDQtMTAyLjM0NCw1MS4wNjNDMjA5LjI1LDExMy4wMzEsMjAwLjY4OCwxMTIsMTkyLDExMmMtNjEuNzUsMC0xMTIsNTAuMjUtMTEyLDExMiBjMCwxLjU2MywwLjAzMSwzLjA5NCwwLjA5NCw0LjYyNUMzMy44MTMsMjQyLjM3NSwwLDI4NS4zMTMsMCwzMzZjMCw2MS43NSw1MC4yNSwxMTIsMTEyLDExMmgyNzJjNzAuNTk0LDAsMTI4LTU3LjQwNiwxMjgtMTI4IEM1MTIsMjczLjM0NCw0ODYuMzQ0LDIzMS4xODgsNDQ2Ljg0NCwyMDguODc1eiBNMzg0LDQxNkgxMTJjLTQ0LjE4OCwwLTgwLTM1LjgxMy04MC04MHMzNS44MTMtODAsODAtODAgYzIuNDM4LDAsNC43NSwwLjUsNy4xMjUsMC43MTljLTQuNS0xMC03LjEyNS0yMS4wMzEtNy4xMjUtMzIuNzE5YzAtNDQuMTg4LDM1LjgxMy04MCw4MC04MGMxNC40MzgsMCwyNy44MTMsNC4xMjUsMzkuNSwxMC44MTMgQzI0NiwxMjAuMjUsMjgwLjE1Niw5NiwzMjAsOTZjNTMuMDMxLDAsOTYsNDIuOTY5LDk2LDk2YzAsMTIuNjI1LTIuNTk0LDI0LjYyNS03LjAzMSwzNS42ODhDNDQ5LjgxMywyMzguNzUsNDgwLDI3NS42ODgsNDgwLDMyMCBDNDgwLDM3My4wMzEsNDM3LjAzMSw0MTYsMzg0LDQxNnoiLz4gPHBvbHlnb24gcG9pbnRzPSIyODgsMTkyIDIyNCwxOTIgMjI0LDI4OCAxNjAsMjg4IDI1NiwzODQgMzUyLDI4OCAyODgsMjg4ICIvPjwvZz48L3N2Zz4NCg== 2 | -------------------------------------------------------------------------------- /tmpl/res/file.gif.base64: -------------------------------------------------------------------------------- 1 | R0lGODlhIAAgAMZLAGdnZ2xsbHR0dHh4eHl5eXp6fHt7fHt7fXx8foeHh5KSkp+fn6KioqWlpaampqioqKurq66urrKysrW1tba2tri4uLm5ubq6uru7u7u7w7y8vL29vb29w76+vr+/v8DAwMDAxsHBwcLCwsPDw8TExMXFxcXFzcfHx8vLy8zM1c3Nzc7Ozs/Pz8/P19DQ0NDQ2NHR0dHR2dPT09TU09TU1NfX19ra2t3d3N7e3t/f3+Hh4eLi4uPj4+Tk5OXl5ebm5ufn5+jo5+vr6+zs7O/v7/Dw8PLy8vPz8/j4+P39/f7+/v///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH+BEdJTVAAIfkEAQoAfwAsAAAAACAAIAAAB/6Af4KDhIWGh4iEISUlJCQjjpAjIheJiD1FPjybnJ08KiuWhUA6P6anqKY+LjUoooNEqbKqLDgyrqIePbOyPiw9NreiJDy8qT4rQjnBuIgixcanPihIQj04NaHO0NGnKCssLC4qFYgh3N0/PuvrPB7m6OmoPR2IHzykO/r7/P07OkB6vDt0z0iAAQgTKlw4IIAReog88NCxpKLFixgt6uixAVEHHkcACBhJsqRJAQCO9NDgsUfIkzBJplyJaEMPihlzXtzI8pDNlzFhzsSACINLkUFPpvRhAdGFmzqjLtHBFJGFo0mVHvlR7tBVoFllbu1qqAJUqTlLTUBEwQfYsGcot0pAJAEr3JEpfzxAFOFsxiRKAgsOvITHjgaIIPQwuJBAgiHRcCxA5GCXjsuYL7PbvI5UjkQMgAS5QQOG6dOoUbNYMeNEIgUcMphI0aK27du3X8QAIepAAQMHggsfThzBq+PI/wQCADs= 2 | -------------------------------------------------------------------------------- /tmpl/res/fs.css: -------------------------------------------------------------------------------- 1 | .no-such, .error { color: red; top: 0; } 2 | .hidden { display: none } 3 | .inline { display: inline-block; } 4 | .centered { margin: 0 auto; display: block; } 5 | .pull-right { text-align: right; } 6 | .pull-center { text-align: center; } 7 | .small { font-size: 80%; } 8 | .clearfix { overflow: auto; } 9 | 10 | a, a:visited { 11 | text-decoration: none; 12 | color: rgb(0, 0, 230); 13 | } 14 | #page { 15 | min-width: 30em; 16 | max-width: 60em; 17 | } 18 | @media (max-width: 600px) { 19 | #page { padding: 0 0; } 20 | } 21 | @media (min-width: 600px) { 22 | #page { padding: 0 2em; } 23 | } 24 | #addressbar { 25 | margin: 0; 26 | padding: 0.6em 0 0.2em 0; 27 | font-size: 1.6em; 28 | font-weight: bold; 29 | } 30 | #authbar { 31 | float: right; 32 | font-size: 15pt; 33 | } 34 | 35 | /* toolbar */ 36 | #toolbar { 37 | float: right; 38 | margin: 0.8em 2em 0.2em 0; 39 | } 40 | .tb-btn { 41 | font-family: sans-serif; 42 | font-size: 10pt; 43 | 44 | padding: 0.2em 0.4em 0.1em 0.4em; 45 | border-radius: 0.4em; 46 | border: 2px solid #2070f8; 47 | 48 | background-color: #2070ff; 49 | color: white; 50 | 51 | cursor: pointer; 52 | } 53 | .tb-btn:disabled { 54 | border: 2px solid grey; 55 | background-color: white; 56 | color: #aaa; 57 | } 58 | -------------------------------------------------------------------------------- /tmpl/tmpl.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FS: {{ title }} 5 | 6 | 7 | 8 | {% for css_href in css_links -%} 9 | 11 | {% endfor -%} 12 | {% for js_href in js_links -%} 13 | 14 | {% endfor -%} 15 | {% block style %} 16 | {% endblock -%} 17 | 20 | 21 | 22 |
23 | 49 |
50 | {% block body %} 51 | {% endblock %} 52 |
53 | 54 | 55 | --------------------------------------------------------------------------------