├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .readthedocs.yml ├── AUTHORS ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── build │ └── .keep └── source │ ├── _static │ └── .keep │ ├── _templates │ └── .keep │ ├── changelog.rst │ ├── conf.py │ ├── index.rst │ ├── install-tutorial.rst │ ├── intro.rst │ ├── license.rst │ ├── project.rst │ ├── quickstart.rst │ ├── rest.rst │ ├── user-cli.rst │ └── user.rst ├── pyproject.toml ├── requirements.d ├── dev.txt └── rtd.txt ├── scripts ├── sdist-sign └── upload-pypi ├── src └── bepasty │ ├── __init__.py │ ├── apis │ ├── __init__.py │ ├── lodgeit.py │ └── rest.py │ ├── app.py │ ├── bepasty_xstatic.py │ ├── cli │ ├── __init__.py │ ├── object.py │ └── server.py │ ├── config.py │ ├── constants.py │ ├── static │ └── app │ │ ├── bepasty.svg │ │ ├── css │ │ └── style.css │ │ ├── favicon.ico │ │ └── js │ │ ├── fileuploader.js │ │ ├── qrcode.js │ │ └── utils.js │ ├── storage │ ├── __init__.py │ └── filesystem │ │ └── __init__.py │ ├── templates │ ├── _layout.html │ ├── _utils.html │ ├── carousel.html │ ├── display.html │ ├── error.html │ ├── filelist.html │ ├── filelist_tableonly.html │ ├── index.html │ ├── qr.html │ └── redirect.html │ ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── screenshots.py │ ├── test_app.py │ ├── test_data.py │ ├── test_date_funcs.py │ ├── test_http.py │ ├── test_item.py │ ├── test_meta.py │ ├── test_name.py │ ├── test_rest_server.py │ ├── test_storage.py │ └── test_website.py │ ├── utils │ ├── __init__.py │ ├── date_funcs.py │ ├── decorators.py │ ├── formatters.py │ ├── hashing.py │ ├── http.py │ ├── name.py │ ├── permissions.py │ └── upload.py │ ├── views │ ├── __init__.py │ ├── delete.py │ ├── display.py │ ├── download.py │ ├── filelist.py │ ├── index.py │ ├── login.py │ ├── modify.py │ ├── qr.py │ ├── setkv.py │ ├── upload.py │ └── xstatic.py │ └── wsgi.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # badge: https://github.com/bepasty/bepasty-server/workflows/CI/badge.svg?branch=master 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: [ master ] 8 | paths: 9 | - '**.py' 10 | - '**.yml' 11 | - '**.toml' 12 | - '**.cfg' 13 | - '**.ini' 14 | - 'requirements.d/*' 15 | - '!docs/**' 16 | pull_request: 17 | branches: [ master ] 18 | paths: 19 | - '**.py' 20 | - '**.yml' 21 | - '**.toml' 22 | - '**.cfg' 23 | - '**.ini' 24 | - 'requirements.d/*' 25 | - '!docs/**' 26 | 27 | jobs: 28 | lint: 29 | 30 | runs-on: ubuntu-24.04 31 | timeout-minutes: 10 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Set up Python 36 | uses: actions/setup-python@v4 37 | with: 38 | python-version: '3.12' 39 | - name: Lint with flake8 40 | run: | 41 | pip install flake8-pyproject flake8 42 | flake8 src scripts 43 | 44 | pytest: 45 | 46 | needs: lint 47 | 48 | strategy: 49 | matrix: 50 | include: 51 | - os: ubuntu-24.04 52 | python-version: '3.10' 53 | toxenv: py310 54 | - os: ubuntu-24.04 55 | python-version: '3.11' 56 | toxenv: py311 57 | - os: ubuntu-24.04 58 | python-version: '3.12' 59 | toxenv: py312 60 | - os: ubuntu-24.04 61 | python-version: '3.13' 62 | toxenv: py313 63 | 64 | env: 65 | TOXENV: ${{ matrix.toxenv }} 66 | 67 | runs-on: ${{ matrix.os }} 68 | timeout-minutes: 10 69 | 70 | steps: 71 | - uses: actions/checkout@v3 72 | with: 73 | # just fetching 1 commit is not enough for setuptools-scm, so we fetch all 74 | fetch-depth: 0 75 | - name: Set up Python ${{ matrix.python-version }} 76 | uses: actions/setup-python@v4 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | - name: Install Python requirements 80 | run: | 81 | python -m pip install --upgrade pip setuptools wheel 82 | pip install -r requirements.d/dev.txt 83 | - name: run pytest via tox 84 | run: | 85 | tox --skip-missing-interpreters 86 | - name: Upload coverage to Codecov 87 | uses: codecov/codecov-action@v1 88 | env: 89 | OS: ${{ runner.os }} 90 | python: ${{ matrix.python-version }} 91 | with: 92 | env_vars: OS, python 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | .eggs/ 23 | src/bepasty/_version.py 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | .tox/ 31 | .coverage 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # Rope 45 | .ropeproject 46 | 47 | # Django stuff: 48 | *.log 49 | *.pot 50 | 51 | # Sphinx documentation 52 | docs/build/ 53 | 54 | .idea 55 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-24.04" 5 | tools: 6 | python: "3.12" 7 | 8 | sphinx: 9 | configuration: docs/source/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | - requirements: requirements.d/rtd.txt 16 | 17 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Bastian Blank 2 | 3 | Christian Fischer 4 | 5 | Thomas Waldmann 6 | 7 | Dennis Schmalacker 8 | 9 | Ana Balica 10 | 11 | Daniel Gonzalez 12 | 13 | Valentin Pratz 14 | 15 | Darko Ronic 16 | 17 | OGAWA Hirofumi 18 | 19 | Janne Heß 20 | 21 | (add your name/email above this line if you contributed to this project) 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2025 by the Bepasty Team, see the AUTHORS file. 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # stuff we need to include into the sdist is handled automatically by 2 | # setuptools_scm - it includes all git-committed files. 3 | # but we want to exclude some committed files/dirs not needed in the sdist: 4 | exclude .gitattributes .gitignore .github 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | bepasty 2 | ======= 3 | 4 | bepasty is like a pastebin for all kinds of files (text, image, audio, video, 5 | documents, ..., binary). 6 | 7 | The documentation is there: 8 | https://bepasty-server.readthedocs.org/en/latest/ 9 | 10 | Features 11 | -------- 12 | 13 | * Generic: 14 | 15 | - you can upload multiple files at once, simply by drag and drop 16 | - after upload, you get a unique link to a view of each file 17 | - on that view, we show actions you can do with the file, metadata of the 18 | file and, if possible, we also render the file contents 19 | - if you uploaded multiple files, you can create a pastebin with the list 20 | of all these files - with a single click! 21 | - Set an expiration date for your files 22 | 23 | * Text files: 24 | 25 | - we highlight all text file types supported by pygments (a lot!) 26 | - we display line numbers 27 | - we link from line numbers to their anchors, so you can easily get a link 28 | to a specific line 29 | 30 | * Image files: 31 | 32 | - we show the image (format support depends on browser) 33 | - for image list items, we can show a slide show ("carousel" view) 34 | - in the items list, a thumbnail of images is shown 35 | 36 | * Audio and video files: 37 | 38 | - we show the html5 player for it (format support depends on browser) 39 | 40 | * asciinema recordings: 41 | 42 | - we show the asciinema player for .cast files 43 | 44 | * URLs: 45 | 46 | - we support linking to / redirecting to external URLs, you can use 47 | this as a link shortener (avoiding privacy / data protection issues 48 | that may exist with other link shorteners) 49 | 50 | * PDFs: 51 | 52 | - we support rendering PDFs in your browser (if your browser is able to) 53 | 54 | * Storage: we use a storage backend api, currently we have backends for: 55 | 56 | - filesystem storage (just use a filesystem directory to store 57 | .meta and .data files) 58 | - currently there are no other storage implementations in master branch 59 | and releases. The "ceph cluster" storage implementation has issues and 60 | currently lives in branch "ceph-storage" until these issues are fixed. 61 | 62 | * Keeping some control: 63 | 64 | - flexible permissions: read, create, modify, delete, list, admin 65 | - assign permissions to users of login secrets 66 | - assign default permissions to not-logged-in users 67 | - you can purge files from storage by age, inactivity, size, type, ... 68 | - you can do consistency checks on the storage 69 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/bepasty.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/bepasty.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/bepasty" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/bepasty" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/build/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/docs/build/.keep -------------------------------------------------------------------------------- /docs/source/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/docs/source/_static/.keep -------------------------------------------------------------------------------- /docs/source/_templates/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/docs/source/_templates/.keep -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # bepasty documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jan 24 22:36:13 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | 19 | # on rtfd, the project gets installed via pip and scm_version generates _version: 20 | from bepasty._version import version 21 | 22 | # General information about the project. 23 | project = 'bepasty' 24 | author = 'The %s Team' % project 25 | copyright = '2020-2025, %s' % author 26 | description = 'a binary pastebin / file upload service' 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.todo', 39 | 'sphinx.ext.viewcode', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = '.rst' 47 | 48 | # The encoding of source files. 49 | # source_encoding = 'utf-8-sig' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = version 60 | # The full version, including alpha/beta/rc tags. 61 | release = version 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # language = None 66 | 67 | # There are two options for replacing |today|: either, you set today to some 68 | # non-false value, then it is used: 69 | # today = '' 70 | # Else, today_fmt is used as the format for a strftime call. 71 | # today_fmt = '%B %d, %Y' 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | exclude_patterns = [] 76 | 77 | # The reST default role (used for this markup: `text`) to use for all 78 | # documents. 79 | # default_role = None 80 | 81 | # If true, '()' will be appended to :func: etc. cross-reference text. 82 | # add_function_parentheses = True 83 | 84 | # If true, the current module name will be prepended to all description 85 | # unit titles (such as .. function::). 86 | # add_module_names = True 87 | 88 | # If true, sectionauthor and moduleauthor directives will be shown in the 89 | # output. They are ignored by default. 90 | # show_authors = False 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = 'sphinx' 94 | 95 | # A list of ignored prefixes for module index sorting. 96 | # modindex_common_prefix = [] 97 | 98 | # If true, keep warnings as "system message" paragraphs in the built documents. 99 | # keep_warnings = False 100 | 101 | 102 | # -- Options for HTML output ---------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | html_theme = 'default' 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | # html_theme_options = {} 112 | 113 | # Add any paths that contain custom themes here, relative to this directory. 114 | # html_theme_path = [] 115 | 116 | # The name for this set of Sphinx documents. If None, it defaults to 117 | # " v documentation". 118 | # html_title = None 119 | 120 | # A shorter title for the navigation bar. Default is the same as html_title. 121 | # html_short_title = None 122 | 123 | # The name of an image file (relative to this directory) to place at the top 124 | # of the sidebar. 125 | # html_logo = None 126 | 127 | # The name of an image file (within the static path) to use as favicon of the 128 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 129 | # pixels large. 130 | # html_favicon = None 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | html_static_path = ['_static'] 136 | 137 | # Add any extra paths that contain custom files (such as robots.txt or 138 | # .htaccess) here, relative to this directory. These files are copied 139 | # directly to the root of the documentation. 140 | # html_extra_path = [] 141 | 142 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 143 | # using the given strftime format. 144 | # html_last_updated_fmt = '%b %d, %Y' 145 | 146 | # If true, SmartyPants will be used to convert quotes and dashes to 147 | # typographically correct entities. 148 | # html_use_smartypants = True 149 | 150 | # Custom sidebar templates, maps document names to template names. 151 | # html_sidebars = {} 152 | 153 | # Additional templates that should be rendered to pages, maps page names to 154 | # template names. 155 | # html_additional_pages = {} 156 | 157 | # If false, no module index is generated. 158 | # html_domain_indices = True 159 | 160 | # If false, no index is generated. 161 | # html_use_index = True 162 | 163 | # If true, the index is split into individual pages for each letter. 164 | # html_split_index = False 165 | 166 | # If true, links to the reST sources are added to the pages. 167 | # html_show_sourcelink = True 168 | 169 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 170 | # html_show_sphinx = True 171 | 172 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 173 | # html_show_copyright = True 174 | 175 | # If true, an OpenSearch description file will be output, and all pages will 176 | # contain a tag referring to it. The value of this option must be the 177 | # base URL from which the finished HTML is served. 178 | # html_use_opensearch = '' 179 | 180 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 181 | # html_file_suffix = None 182 | 183 | # Output file base name for HTML help builder. 184 | htmlhelp_basename = 'bepastydoc' 185 | 186 | 187 | # -- Options for LaTeX output --------------------------------------------- 188 | 189 | latex_elements = { 190 | # The paper size ('letterpaper' or 'a4paper'). 191 | # 'papersize': 'letterpaper', 192 | 193 | # The font size ('10pt', '11pt' or '12pt'). 194 | # 'pointsize': '10pt', 195 | 196 | # Additional stuff for the LaTeX preamble. 197 | # 'preamble': '', 198 | } 199 | 200 | # Grouping the document tree into LaTeX files. List of tuples 201 | # (source start file, target name, title, 202 | # author, documentclass [howto, manual, or own class]). 203 | latex_documents = [ 204 | ('index', 'bepasty.tex', u'bepasty Documentation', 205 | author, 'manual'), 206 | ] 207 | 208 | # The name of an image file (relative to this directory) to place at the top of 209 | # the title page. 210 | # latex_logo = None 211 | 212 | # For "manual" documents, if this is true, then toplevel headings are parts, 213 | # not chapters. 214 | # latex_use_parts = False 215 | 216 | # If true, show page references after internal links. 217 | # latex_show_pagerefs = False 218 | 219 | # If true, show URL addresses after external links. 220 | # latex_show_urls = False 221 | 222 | # Documents to append as an appendix to all manuals. 223 | # latex_appendices = [] 224 | 225 | # If false, no module index is generated. 226 | # latex_domain_indices = True 227 | 228 | 229 | # -- Options for manual page output --------------------------------------- 230 | 231 | # One entry per manual page. List of tuples 232 | # (source start file, name, description, authors, manual section). 233 | man_pages = [ 234 | ('index', 'bepasty', u'bepasty Documentation', 235 | [author], 1) 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | # man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ------------------------------------------- 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | ('index', 'bepasty', u'bepasty Documentation', 249 | author, 'bepasty', description, 250 | 'Miscellaneous'), 251 | ] 252 | 253 | # Documents to append as an appendix to all manuals. 254 | # texinfo_appendices = [] 255 | 256 | # If false, no module index is generated. 257 | # texinfo_domain_indices = True 258 | 259 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 260 | # texinfo_show_urls = 'footnote' 261 | 262 | # If true, do not generate a @detailmenu in the "Top" node's menu. 263 | # texinfo_no_detailmenu = False 264 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to bepasty's documentation! 2 | =================================== 3 | 4 | bepasty is like a pastebin for every kind of file (text, image, audio, video, documents, ...). 5 | 6 | You can upload multiple files at once, simply by drag and drop. 7 | 8 | Contents 9 | -------- 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | intro 15 | user 16 | rest 17 | user-cli 18 | quickstart 19 | install-tutorial 20 | changelog 21 | project 22 | license 23 | -------------------------------------------------------------------------------- /docs/source/install-tutorial.rst: -------------------------------------------------------------------------------- 1 | 2 | ===================================================== 3 | Installation tutorial with Debian, NGinx and gunicorn 4 | ===================================================== 5 | 6 | preliminary packages: 7 | 8 | :: 9 | 10 | apt-get install build-essential nginx supervisor python-dev git-core python-pip python-virtualenv 11 | 12 | 13 | commands to run 14 | 15 | :: 16 | 17 | # add user bepasty to system 18 | adduser bepasty 19 | # change to user bepasty 20 | sudo su - bepasty 21 | # clone repository from github 22 | git clone https://github.com/bepasty/bepasty-server.git repo 23 | # create folder for storage 24 | mkdir storage 25 | # create folder for logs 26 | mkdir logs 27 | # create virtualenv 28 | virtualenv . 29 | # activate virtualenv 30 | . bin/activate 31 | cd repo 32 | # install bepasty and requirements 33 | pip install -e . 34 | # add gunicorn and gevent for hosting 35 | pip install gunicorn gevent 36 | 37 | config file for bepasty -- ``/home/bepasty/bepasty.conf``: 38 | 39 | Copy ``src/bepasty/config.py`` to ``/home/bepasty/bepasty.conf`` first, 40 | remove the ``class Config`` and remove all indents in the file. 41 | The comments can be removed too, if you feel the need to. 42 | At last modify these two configs variables: 43 | 44 | :: 45 | 46 | STORAGE = 'filesystem' 47 | STORAGE_FILESYSTEM_DIRECTORY = '/home/bepasty/storage/' 48 | 49 | 50 | add this content to ``/home/bepasty/bin/gunicorn_bepasty``: 51 | 52 | :: 53 | 54 | #!/bin/bash 55 | 56 | NAME="bepasty" 57 | HOME=/home/bepasty 58 | SOCKFILE=$HOME/gunicorn.sock # we will communicate using this unix socket 59 | PIDFILE=$HOME/gunicorn.pid 60 | NUM_WORKERS=3 # how many worker processes should Gunicorn spawn 61 | export BEPASTY_CONFIG=$HOME/bepasty.conf 62 | 63 | source $HOME/bin/activate 64 | 65 | cd $HOME/repo 66 | 67 | exec gunicorn bepasty.wsgi \ 68 | --name $NAME \ 69 | --workers $NUM_WORKERS \ 70 | --log-level=info \ 71 | --bind=unix:$SOCKFILE \ 72 | --pid $PIDFILE \ 73 | -k gevent 74 | 75 | Make it executable: ``chmod +x ~/bin/gunicorn_bepasty`` 76 | 77 | A nginx configuration i.e. in ``/etc/nginx/conf.d/bepasty.conf``: 78 | 79 | :: 80 | 81 | upstream pasty_server { 82 | server unix:/home/bepasty/gunicorn.sock fail_timeout=0; 83 | } 84 | 85 | server { 86 | listen 80; 87 | #listen [::]:80; #uncomment this if your server supports IPv6 88 | server_name paste.example.org; # <-- add your domainname here 89 | 90 | access_log /home/bepasty/logs/nginx-access.log; 91 | error_log /home/bepasty/logs/nginx-error.log; 92 | 93 | client_max_body_size 32M; 94 | 95 | location / { 96 | proxy_set_header Host $http_host; 97 | proxy_pass http://pasty_server; 98 | } 99 | 100 | location /static/ { 101 | alias /home/bepasty/repo/src/bepasty/static/; 102 | } 103 | } 104 | 105 | Now reload your nginx configuration: `service nginx reload`. 106 | 107 | Supervisord config i.e. in ``/etc/supervisor/conf.d/bepasty.conf``: 108 | 109 | :: 110 | 111 | [program:bepasty] 112 | command = /home/bepasty/bin/gunicorn_bepasty ; Command to start app 113 | user = bepasty ; User to run as 114 | stdout_logfile = /home/bepasty/logs/gunicorn_supervisor.log ; Where to write log messages 115 | redirect_stderr = true ; Save stderr in the same log 116 | 117 | Finally reload supervisor: `service supervisor reload` 118 | -------------------------------------------------------------------------------- /docs/source/intro.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | -------------------------------------------------------------------------------- /docs/source/license.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | .. include:: ../../LICENSE 5 | 6 | Authors 7 | ======= 8 | 9 | .. include:: ../../AUTHORS 10 | 11 | -------------------------------------------------------------------------------- /docs/source/project.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | The bepasty software Project 3 | ============================ 4 | 5 | History 6 | ======= 7 | 8 | The initial version of the bepasty(-server) software was developed in 48h in the WSGI Wrestle 2013 contest by: 9 | 10 | * `Bastian Blank `_ 11 | * `Christian Fischer `_ 12 | 13 | 14 | Project site 15 | ============ 16 | 17 | Source code repository, issue tracker (bugs, ideas about enhancements, todo, 18 | feedback, ...), link to documentation is all there: 19 | 20 | https://github.com/bepasty/ 21 | 22 | 23 | Contributing 24 | ============ 25 | 26 | Feedback is welcome. 27 | 28 | If you find some issue, have some idea or some patch, please submit them via the issue tracker. 29 | 30 | Or even better: if you use git, fork our repo, make your changes and submit a pull request. 31 | 32 | For small fixes, you can even just edit the files on github (github will then fork, change and submit a pull request 33 | automatically). 34 | 35 | Development 36 | =========== 37 | 38 | :: 39 | 40 | # Create a new virtualenv 41 | virtualenv bepasty-server-env 42 | # Activate the virtualenv 43 | source bepasty-server-env/bin/activate 44 | # Clone the official bepasty-server (or your fork, if you want to send PULL requests) 45 | git clone https://github.com/bepasty/bepasty-server.git 46 | cd bepasty-server 47 | # This will use the current directory for the installed package. 48 | # Very useful during development! It will also autoreload when files are changed 49 | pip install -e . 50 | # Run the bepasty-server in debug mode. The server is reachable in http://127.0.0.1:5000 51 | bepasty-server --debug 52 | 53 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Installing bepasty 5 | ------------------ 6 | 7 | You can install bepasty either from PyPi (latest release) or from the git repository (latest available code). 8 | 9 | :: 10 | 11 | # from PyPi: 12 | pip install bepasty 13 | 14 | # if you'ld like to have python-magic to help determining files' mime types, use: 15 | pip install bepasty[magic] 16 | 17 | # from git repo 18 | pip install -e git+https://github.com/bepasty/bepasty-server.git#egg=bepasty-server 19 | 20 | Configuring bepasty 21 | ------------------- 22 | Before you can use bepasty, you need to carefully configure it (it won't work in default configuration and most of 23 | the configuration settings need your attention). 24 | 25 | When setting up permissions and giving out login secrets, carefully think about whom you give which permissions, 26 | especially when setting up the ``DEFAULT_PERMISSIONS`` (which apply to not-logged-in users). 27 | 28 | Here is the documentation straight from its config: 29 | 30 | .. autoclass:: bepasty.config.Config 31 | :members: 32 | 33 | 34 | To create a local and non-default configuration, copy ``bepasty/config.py`` to e.g. ``/srv/bepasty/bepasty.conf`` 35 | first, remove the ``class Config`` and remove all indents in the file. 36 | The comments can be removed too, if you feel the need to. 37 | At last modify these two configs variables: then modify it: 38 | 39 | :: 40 | 41 | # Note: no Config class required, just simple KEY = value lines: 42 | SECRET_KEY = '........................' 43 | STORAGE = 'filesystem' 44 | STORAGE_FILESYSTEM_DIRECTORY = '/srv/bepasty/storage/' 45 | # ... 46 | 47 | 48 | Important notes: 49 | 50 | * if you copied the file from the ``bepasty/config.py`` it will have 51 | a "class Config" in it and all the settings are inside that class. This is 52 | **not** what you need. Due to how flask config files work, you need to 53 | remove the class statement and outdent all the settings, so you just have 54 | global KEY = VALUE statements left on the top level of the config file. 55 | * if you run over http (like for trying it locally / for development), you 56 | need to change the configuration to use SESSION_SECURE_COOKIE = False 57 | (otherwise you can not login as it won't transmit the cookie over unsecure 58 | http). 59 | 60 | 61 | Starting bepasty server 62 | ----------------------- 63 | 64 | You can run the bepasty server with your local configuration by pointing to it via the BEPASTY_CONFIG 65 | environment variable like this: 66 | 67 | :: 68 | 69 | BEPASTY_CONFIG=/srv/bepasty/bepasty.conf bepasty-server 70 | 71 | Important note: 72 | 73 | * Use an absolute path as value for BEPASTY_CONFIG. 74 | 75 | 76 | The builtin WSGI server is recommended only for development and non-production use. 77 | 78 | For production, you should use a WSGI server like gunicorn, apache+mod-wsgi, nginx+uwsgi, etc. 79 | 80 | :: 81 | 82 | gunicorn bepasty.wsgi 83 | 84 | 85 | Invoking CLI commands 86 | --------------------- 87 | 88 | All bepasty commands expect either a --config argument or that the BEPASTY_CONFIG environment 89 | variable points to your configuration file. 90 | 91 | The "object" command operates on objects stored in the storage. You can get infos about them ("info" subcommand), 92 | you can set some flags on them ("set"), you can remove all or some ("purge"), you can check the consistency 93 | ("consistency"), etc... 94 | 95 | To get help about the object command, use: 96 | 97 | :: 98 | 99 | bepasty-object --help 100 | 101 | 102 | To get help about the object purge subcommand, use: 103 | 104 | :: 105 | 106 | bepasty-object purge --help 107 | 108 | 109 | To run the object purge subcommand (here: dry-run == do not remove anything, files >= 10MiB AND age >= 14 days), 110 | use something like: 111 | 112 | :: 113 | 114 | bepasty-object purge --dry-run --size 10 --age 14 '*' 115 | 116 | 117 | If you upgraded bepasty, you might need to upgrade the stored metadata to the current bepasty metadata schema: 118 | 119 | :: 120 | 121 | bepasty-object migrate '*' 122 | 123 | 124 | Note: the '*' needs to be quoted with single-quotes so the shell does not expand it. it tells the command to operate 125 | on all names in the storage (you could also give some specific names instead of '*'). 126 | -------------------------------------------------------------------------------- /docs/source/user-cli.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Using bepasty with non-web clients 3 | ================================== 4 | 5 | pastebinit 6 | ========== 7 | 8 | pastebinit is a popular pastebin client (included in debian, ubuntu and maybe 9 | elsewhere) that can be configured to work with bepasty: 10 | 11 | 12 | Configuration 13 | ------------- 14 | 15 | ~/.pastebinit.xml:: 16 | 17 | 18 | https://bepasty.example.org 19 | 20 | 21 | 22 | Notes: 23 | 24 | * we set an empty default format so pastebinit will transmit this (and not its 25 | internal format default [which is "text" and completely useless for us as it 26 | is not a valid contenttype]) 27 | 28 | ~/.pastebin.d/bepasty.conf:: 29 | 30 | [pastebin] 31 | basename = bepasty.example.org 32 | regexp = https://bepasty.example.org 33 | 34 | [format] 35 | content = text 36 | title = filename 37 | format = contenttype 38 | page = page 39 | password = token 40 | 41 | [defaults] 42 | page = +upload 43 | 44 | 45 | Usage 46 | ----- 47 | 48 | Simplest:: 49 | 50 | echo "test" | pastebinit 51 | 52 | More advanced:: 53 | 54 | # give title (filename), password, input file 55 | pastebinit -t example.py -p yourpassword -i example.py 56 | 57 | # read from stdin, give title (filename), give format (contenttype) 58 | cat README | pastebinit -t README -f text/plain 59 | 60 | Notes: 61 | 62 | * we use -t ("title") to transmit the desired filename (we do not have a 63 | "title", but the filename that is used for downloading the pastebin is 64 | prominently displayed above the content, so can be considered as title also). 65 | * bepasty guesses the contenttype from the filename given with -t. if you 66 | do not give a filename there or the contenttype is not guessable from it, 67 | you may need to give -f also (e.g. -f text/plain). 68 | * if you give the contenttype, but not the filename, bepasty will make up 69 | a filename. 70 | * you need to use -p if the bepasty instance you use requires you to log in 71 | before you can create pastebins. 72 | -------------------------------------------------------------------------------- /docs/source/user.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Using bepasty's web interface 3 | ============================= 4 | 5 | .. _permissions: 6 | 7 | Logging in and Permissions 8 | ========================== 9 | 10 | You may need to log in to get enough permissions required for the misc. functions of bepasty. 11 | 12 | Your current permissions are shown on the web interface (in the navigation bar). 13 | 14 | To log in, you need to know credentials - ask the admin who runs the site. 15 | 16 | Bepasty does **not** use the usual user/password scheme, but **only** uses passwords (or 17 | passphrases) as credentials - there are no separate user names. 18 | 19 | The site admin can assign permissions to login credentials (and also to the anonymous, not logged-in user): 20 | 21 | * create: be able to create pastebins 22 | * modify: be able to modify pastebins 23 | * read: be able to read / download pastebins 24 | * delete: be able to delete pastebins 25 | * list: be able to list (discover) all pastebins 26 | * admin: be able to lock/unlock files, do actions even if upload is not completed or item is locked 27 | 28 | Be careful about who you give what permissions - especially "admin" and "list" are rather critical ones. 29 | 30 | If you want good privacy, do not give "list" to anybody (except site administrator maybe). 31 | 32 | If you want to do everything rather in the public, you may give "list" to users (or even use it by 33 | default for not-logged-in users). 34 | 35 | "admin" likely should be given only to very trusted people, like site administrator. 36 | 37 | 38 | Pasting text 39 | ============ 40 | 41 | Just paste it into that big upper text input box. 42 | 43 | Content-Type: Below the box you can optionally choose the type of your content (if you don't, it will become plain text). 44 | Just start typing some letters of the type, e.g. if you pasted some python code, type pyt and see how it 45 | offers you some choices based on that. Based on that type, we will highlight your text (using the Pygments 46 | library, which supports a lot of text formats). 47 | 48 | File name: You can optionally give a filename for your paste. If someone later downloads it, the browser will 49 | use the filename you gave. If you don't give a filename, bepasty will make something up. 50 | 51 | Maximum lifetime: The file will be automatically deleted after this time is over 52 | 53 | When finished, click on "Submit". bepasty will save your text using a unique ID and redirect you to the URL 54 | where you can view or download your pasted text. 55 | 56 | Uploading files 57 | =============== 58 | 59 | See that big box below the text input box - you can: 60 | 61 | * click it to upload files via the file selection dialogue of your browser 62 | * drag files from your desktop and drop them there 63 | 64 | Note: some features require a modern browser, like a current Firefox or Chrome/Chromium with Javascript enabled. 65 | 66 | It will show you a progress indication while the files upload. 67 | 68 | After the files are uploaded, bepasty will show you a list of links to the individual views of each uploaded file. 69 | Make sure you keep all the links (open the links in new tabs / new windows) - they are the only way to access the files. 70 | 71 | Additionally, bepasty prepared a file list for you (that has all the unique IDs of your uploaded files). If you 72 | create a list item by hitting the respective button, bepasty will store that list in yet another pastebin item, so 73 | you need to remember only that one URL. It's also a nice way to communicate a collection of files as you only need to 74 | communicate that one URL (not each individual file's URL). 75 | 76 | Viewing / Downloading files 77 | =========================== 78 | 79 | Just visit the file's unique URL to view, download or delete it. 80 | 81 | bepasty will show you metadata like: 82 | 83 | * file name 84 | * precise file size 85 | * upload date/time (UTC) 86 | * (last) download date/time (UTC) - viewing the data also counts as download 87 | * expiration date, if set 88 | * sha256 hash of the file contents 89 | 90 | bepasty also supports directly displaying the data, for these content types: 91 | 92 | * lists of files (if a list item was created at upload time) 93 | * text files (highlighted depending on the content-type) 94 | * PDFs (if you browser can render PDFs or has a plugin doing that) 95 | * asciinema cast files 96 | * image files, like jpeg, png and svg 97 | * audio/video files (using the html5 player widget, format support might depend on your browser and OS) 98 | * for other file types, you need to download them and open them with the appropriate application 99 | 100 | File hashes 101 | =========== 102 | 103 | If you're unfamiliar with hashes like SHA256, you might wonder what they are good for and why we show them. 104 | 105 | A hash is something like a checksum or fingerprint of the file contents. We compute the hash while or directly 106 | after the file upload. If you have 2 files at different places and they have the same SHA256 hash, you can be 107 | pretty sure that they have completely identical contents. 108 | 109 | If you are looking at a file you just uploaded yourself, it might be interesting to compare the size and hash with 110 | the values you see for your local file to make sure the upload worked correctly. 111 | 112 | Same after you download a file: check whether the hash matches for the file on bepasty and your downloaded file. 113 | 114 | If you transfer a file from location A via bepasty (B) to location C, you can also compare the file hashes at locations 115 | A and C to make sure the file was not modified or corrupted while being transferred. 116 | 117 | Important stuff to remember 118 | =========================== 119 | 120 | * if you get credentials from an admin, do not make them available to other persons except if explicitly allowed 121 | * files may go away at any time, always remember that a pastebin site is only for short-term temporary storage 122 | (how long this is depends on the site's / site admin's policy and available disk space) 123 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=77.0.3", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | write_to = "src/bepasty/_version.py" 7 | 8 | [project] 9 | name = "bepasty" 10 | dynamic = ["version"] 11 | license = "BSD-2-Clause" 12 | license-files = ["LICENSE", "AUTHORS"] 13 | authors = [{name="The Bepasty Team (see AUTHORS file)"}] 14 | maintainers = [{name="Thomas Waldmann", email="tw@waldmann-edv.de"}] 15 | description = "a binary pastebin / file upload service" 16 | readme = "README.rst" 17 | keywords = ["text", "image", "audio", "video", "binary", "pastebin", "upload", "download", "service", "wsgi", "flask"] 18 | requires-python = ">=3.10" 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Operating System :: OS Independent", 23 | "Framework :: Flask", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | ] 31 | dependencies = [ 32 | "werkzeug", 33 | "Flask", 34 | "markupsafe", 35 | "Pygments >= 2.12.0", 36 | "xstatic < 2.0.0", 37 | "XStatic-asciinema-player <= 2.6.1.1", 38 | "xstatic-bootbox >= 5.4.0, <= 5.5.1.1", 39 | "xstatic-bootstrap >=4.0.0.0, <= 4.5.3.1", 40 | "xstatic-font-awesome <= 4.7.0.0", 41 | "xstatic-jquery <= 3.5.1.1", 42 | "xstatic-jquery-ui <= 1.13.0.1", 43 | "xstatic-jquery-file-upload <= 10.31.0.1", 44 | "xstatic-pygments <= 2.9.0.1", 45 | ] 46 | 47 | [project.scripts] 48 | bepasty-server = "bepasty.cli.server:main" 49 | bepasty-object = "bepasty.cli.object:main" 50 | 51 | [project.optional-dependencies] 52 | magic = ["python-magic"] 53 | pillow = ["Pillow"] 54 | 55 | [project.urls] 56 | Homepage = "https://github.com/bepasty/bepasty-server/" 57 | Documentation = "https://bepasty-server.readthedocs.org/" 58 | Changelog = "https://github.com/bepasty/bepasty-server/blob/master/CHANGES.rst" 59 | 60 | [tool.pytest.ini_options] 61 | norecursedirs = [".eggs", ".git", ".tox", "build", ] 62 | markers = [ 63 | "slow", 64 | "needs_server", 65 | ] 66 | 67 | [tool.flake8] 68 | max-line-length = 120 69 | -------------------------------------------------------------------------------- /requirements.d/dev.txt: -------------------------------------------------------------------------------- 1 | build 2 | tox 3 | flake8-pyproject 4 | flake8 5 | pytest 6 | pytest-cov 7 | selenium 8 | codecov 9 | twine 10 | -------------------------------------------------------------------------------- /requirements.d/rtd.txt: -------------------------------------------------------------------------------- 1 | # Defining the exact version will make sure things don't break 2 | sphinx==5.3.0 3 | sphinx_rtd_theme==1.1.1 4 | readthedocs-sphinx-search==0.3.2 5 | -------------------------------------------------------------------------------- /scripts/sdist-sign: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | R=$1 4 | 5 | if [ "$R" = "" ]; then 6 | echo "Usage: sdist-sign 1.2.3" 7 | exit 8 | fi 9 | 10 | if [ "$QUBES_GPG_DOMAIN" = "" ]; then 11 | GPG=gpg 12 | else 13 | GPG=qubes-gpg-client-wrapper 14 | fi 15 | 16 | python -m build --sdist 17 | 18 | D=dist/bepasty-$R.tar.gz 19 | 20 | $GPG --detach-sign --local-user "Thomas Waldmann" --armor --output $D.asc $D 21 | -------------------------------------------------------------------------------- /scripts/upload-pypi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | R=$1 4 | 5 | if [ "$R" = "" ]; then 6 | echo "Usage: upload-pypi 1.2.3 [test]" 7 | exit 8 | fi 9 | 10 | if [ "$2" = "test" ]; then 11 | export TWINE_REPOSITORY=testpypi 12 | else 13 | export TWINE_REPOSITORY=bepasty 14 | fi 15 | 16 | D=dist/bepasty-$R.tar.gz 17 | 18 | twine upload "$D" 19 | -------------------------------------------------------------------------------- /src/bepasty/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/src/bepasty/__init__.py -------------------------------------------------------------------------------- /src/bepasty/apis/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from .lodgeit import LodgeitUpload 4 | from .rest import ItemDetailView, ItemDownloadView, ItemModifyView, \ 5 | ItemUploadView, InfoView, ItemDeleteView, ItemLockView, ItemUnlockView 6 | 7 | 8 | blueprint = Blueprint('bepasty_apis', __name__, url_prefix='/apis') 9 | 10 | blueprint.add_url_rule('/lodgeit/', view_func=LodgeitUpload.as_view('lodgeit')) 11 | blueprint.add_url_rule('/rest', view_func=InfoView.as_view('api_info')) 12 | blueprint.add_url_rule('/rest/items', view_func=ItemUploadView.as_view('items')) 13 | blueprint.add_url_rule('/rest/items/', view_func=ItemDetailView.as_view('items_detail')) 14 | blueprint.add_url_rule('/rest/items//download', view_func=ItemDownloadView.as_view('items_download')) 15 | blueprint.add_url_rule('/rest/items//delete', view_func=ItemDeleteView.as_view('items_delete')) 16 | blueprint.add_url_rule('/rest/items//modify', view_func=ItemModifyView.as_view('items_modify')) 17 | blueprint.add_url_rule('/rest/items//lock', view_func=ItemLockView.as_view('items_lock')) 18 | blueprint.add_url_rule('/rest/items//unlock', view_func=ItemUnlockView.as_view('items_unlock')) 19 | -------------------------------------------------------------------------------- /src/bepasty/apis/lodgeit.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import urllib 3 | 4 | from flask import request 5 | from flask.views import MethodView 6 | from pygments.lexers import get_all_lexers 7 | from werkzeug.exceptions import Forbidden 8 | 9 | from ..constants import FOREVER 10 | from ..utils.http import redirect_next 11 | from ..utils.permissions import CREATE, may 12 | from ..utils.upload import create_item 13 | 14 | 15 | class LodgeitUpload(MethodView): 16 | """ 17 | lodgeit paste form 18 | """ 19 | # most stuff lodgeit support comes directly from pygments 20 | # for all other stuff we fall back to text/plain. 21 | TRANS = {} 22 | for lexer in get_all_lexers(): 23 | # (name, aliases, filetypes, mimetypes) 24 | # e.g. ('Diff', ('diff',), ('*.diff', '*.patch'), ('text/x-diff', 'text/x-patch')) 25 | if len(lexer[1]) == 0: 26 | continue 27 | name = lexer[1][0] 28 | cts = lexer[3] 29 | # find a content-type, preferably one with text/* 30 | for ct in cts: 31 | if ct.startswith("text/"): 32 | break 33 | else: 34 | if cts: 35 | ct = cts[0] 36 | else: 37 | ct = None 38 | if ct: 39 | TRANS[name] = ct 40 | 41 | def post(self): 42 | if not may(CREATE): 43 | raise Forbidden() 44 | lang = request.form.get('language') 45 | content_type = self.TRANS.get(lang) 46 | content_type_hint = 'text/plain' 47 | filename = None 48 | t = request.form['code'] 49 | # t is already unicode, but we want utf-8 for storage 50 | t = t.encode('utf-8') 51 | size = len(t) 52 | f = BytesIO(t) 53 | maxlife_timestamp = FOREVER 54 | name = create_item(f, filename, size, content_type, content_type_hint, 55 | maxlife_stamp=maxlife_timestamp) 56 | return redirect_next('bepasty.display', name=name, _anchor=urllib.parse.quote(filename)) 57 | -------------------------------------------------------------------------------- /src/bepasty/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import hashlib 4 | 5 | from flask import ( 6 | Flask, 7 | current_app, 8 | g as flaskg, # searching for 1 letter name "g" isn't nice, thus we use flaskg 9 | render_template, 10 | session, 11 | ) 12 | from markupsafe import Markup 13 | 14 | from .apis import blueprint as blueprint_apis 15 | from .storage import create_storage 16 | from .utils.name import setup_werkzeug_routing 17 | from .utils.permissions import ( 18 | ADMIN, 19 | CREATE, 20 | DELETE, 21 | MODIFY, 22 | LIST, 23 | READ, 24 | get_permission_icons, 25 | get_permissions, 26 | logged_in, 27 | may, 28 | ) 29 | from .views import blueprint 30 | 31 | import mimetypes 32 | mimetypes.add_type('application/x-asciinema-recording', '.cast') 33 | 34 | 35 | class PrefixMiddleware: 36 | def __init__(self, app, prefix=''): 37 | self.app = app 38 | self.prefix = prefix 39 | 40 | def __call__(self, environ, start_response): 41 | if environ['PATH_INFO'].startswith(self.prefix): 42 | environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] 43 | environ['SCRIPT_NAME'] = self.prefix 44 | return self.app(environ, start_response) 45 | else: 46 | start_response('404', [('Content-Type', 'text/plain')]) 47 | return [b'This URL does not belong to the bepasty app.'] 48 | 49 | 50 | def setup_secret_key(app): 51 | """ 52 | The secret key is used to sign cookies and cookies not signed with the 53 | current secret key are considered invalid. 54 | 55 | Here, we amend the configured secret key, so it depends on some 56 | other config values. Changing any of these values will change the 57 | computed secret key (and thus invalidate all previously made 58 | cookies). 59 | 60 | Currently supported secret-changing config values: PERMISSIONS 61 | """ 62 | # if app.config['SECRET_KEY'] is empty, keep as NullSession 63 | if app.config['SECRET_KEY']: 64 | perms = sorted(k + v for k, v in app.config['PERMISSIONS'].items()) 65 | perms = ''.join(perms).encode() 66 | app.config['SECRET_KEY'] += hashlib.sha256(perms).hexdigest() 67 | 68 | 69 | def create_app(): 70 | app = Flask(__name__) 71 | 72 | app.config.from_object('bepasty.config.Config') 73 | if os.environ.get('BEPASTY_CONFIG'): 74 | app.config.from_envvar('BEPASTY_CONFIG') 75 | 76 | setup_secret_key(app) 77 | 78 | prefix = app.config.get('APP_BASE_PATH') 79 | if prefix is not None: 80 | app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=prefix) 81 | 82 | app.storage = create_storage(app) 83 | setup_werkzeug_routing(app) 84 | 85 | app.register_blueprint(blueprint) 86 | app.register_blueprint(blueprint_apis) 87 | 88 | @app.errorhandler(403) 89 | def url_forbidden(e): 90 | heading = 'Forbidden' 91 | body = Markup("""\ 92 |

93 | You are not allowed to access the requested URL. 94 |

95 |

96 | If you entered the URL manually please check your spelling and try again. 97 |

98 |

99 | Also check if you maybe forgot to log in or if your permissions are insufficient for this. 100 |

101 | """) 102 | return render_template('error.html', heading=heading, body=body), 403 103 | 104 | @app.errorhandler(404) 105 | def url_not_found(e): 106 | heading = 'Not found' 107 | body = Markup("""\ 108 |

109 | The requested URL was not found on the server. 110 |

111 |

112 | If you entered the URL manually please check your spelling and try again. 113 |

114 | """) 115 | return render_template('error.html', heading=heading, body=body), 404 116 | 117 | @app.before_request 118 | def before_request(): 119 | """ 120 | before the request is handled (by its view function), we compute some 121 | stuff here and make it easily available. 122 | """ 123 | flaskg.logged_in = logged_in() 124 | flaskg.permissions = get_permissions() 125 | flaskg.icon_permissions = get_permission_icons() 126 | if flaskg.logged_in: 127 | session.permanent = current_app.config['PERMANENT_SESSION'] 128 | 129 | def datetime_format(ts): 130 | """ 131 | takes a unix timestamp and outputs a iso8601-like formatted string. 132 | times are always UTC, but we don't include the TZ here for brevity. 133 | it should be made clear (e.g. in the template) that the date/time is UTC. 134 | """ 135 | if not ts: # we use 0 to indicate undefined time 136 | return 'undefined' 137 | return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts)) 138 | 139 | app.jinja_env.filters['datetime'] = datetime_format 140 | 141 | app.jinja_env.globals['flaskg'] = flaskg 142 | app.jinja_env.globals['may'] = may 143 | app.jinja_env.globals['ADMIN'] = ADMIN 144 | app.jinja_env.globals['LIST'] = LIST 145 | app.jinja_env.globals['CREATE'] = CREATE 146 | app.jinja_env.globals['MODIFY'] = MODIFY 147 | app.jinja_env.globals['READ'] = READ 148 | app.jinja_env.globals['DELETE'] = DELETE 149 | 150 | return app 151 | -------------------------------------------------------------------------------- /src/bepasty/bepasty_xstatic.py: -------------------------------------------------------------------------------- 1 | from xstatic.main import XStatic 2 | 3 | # names below must be package names 4 | mod_names = [ 5 | 'asciinema_player', 6 | 'bootbox', 7 | 'bootstrap', 8 | 'font_awesome', 9 | 'jquery', 10 | 'jquery_ui', 11 | 'jquery_file_upload', 12 | 'pygments', 13 | ] 14 | 15 | pkg = __import__('xstatic.pkg', fromlist=mod_names) 16 | serve_files = {} 17 | for mod_name in mod_names: 18 | mod = getattr(pkg, mod_name) 19 | xs = XStatic(mod, root_url='/static', provider='local', protocol='http') 20 | serve_files[xs.name] = xs.base_dir 21 | -------------------------------------------------------------------------------- /src/bepasty/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/src/bepasty/cli/__init__.py -------------------------------------------------------------------------------- /src/bepasty/cli/object.py: -------------------------------------------------------------------------------- 1 | """ 2 | bepasty-object commandline interface 3 | """ 4 | 5 | 6 | import os 7 | import argparse 8 | import logging 9 | import time 10 | 11 | from flask import Flask 12 | 13 | from ..constants import ( 14 | COMPLETE, 15 | FILENAME, 16 | FOREVER, 17 | HASH, 18 | LOCKED, 19 | SIZE, 20 | TIMESTAMP_DOWNLOAD, 21 | TIMESTAMP_MAX_LIFE, 22 | TIMESTAMP_UPLOAD, 23 | TYPE, 24 | ) 25 | from ..utils.hashing import compute_hash 26 | from ..storage import create_storage 27 | 28 | 29 | class Main: 30 | argparser = argparse.ArgumentParser(prog='bepasty-object') 31 | _subparsers = argparser.add_subparsers() 32 | argparser.add_argument('--config', dest='config', metavar='CONFIG', help='bepasty configuration file') 33 | argparser.add_argument('names', metavar='NAME', nargs='+') 34 | 35 | def do_migrate(self, storage, name, args): 36 | tnow = time.time() 37 | with storage.openwrite(name) as item: 38 | # compatibility support for bepasty 0.0.1 and pre-0.1.0 39 | # old items might have a 'timestamp' value which is not used any more 40 | # (superseded by 'timestamp-*') - delete it: 41 | item.meta.pop('timestamp', None) 42 | # old items might miss some of the timestamps we require, 43 | # just initialize them with the current time: 44 | for ts_key in [TIMESTAMP_UPLOAD, TIMESTAMP_DOWNLOAD, ]: 45 | if ts_key not in item.meta: 46 | item.meta[ts_key] = tnow 47 | if LOCKED not in item.meta: 48 | unlocked = item.meta.pop('unlocked', None) 49 | if unlocked is not None: 50 | locked = not unlocked 51 | else: 52 | locked = False 53 | item.meta[LOCKED] = locked 54 | if COMPLETE not in item.meta: 55 | item.meta[COMPLETE] = True 56 | if FILENAME not in item.meta: 57 | item.meta[FILENAME] = 'missing' 58 | if TYPE not in item.meta: 59 | item.meta[TYPE] = 'application/octet-stream' 60 | if SIZE not in item.meta: 61 | item.meta[SIZE] = item.data.size 62 | if HASH not in item.meta: 63 | item.meta[HASH] = '' # see do_consistency 64 | if TIMESTAMP_MAX_LIFE not in item.meta: 65 | item.meta[TIMESTAMP_MAX_LIFE] = FOREVER 66 | 67 | _parser = _subparsers.add_parser('migrate', help='Migrate metadata to current schema') 68 | _parser.set_defaults(func=do_migrate) 69 | 70 | def do_purge(self, storage, name, args): 71 | tnow = time.time() 72 | with storage.openwrite(name) as item: 73 | file_name = item.meta[FILENAME] 74 | file_size = item.meta[SIZE] 75 | t_upload = item.meta[TIMESTAMP_UPLOAD] 76 | t_download = item.meta[TIMESTAMP_DOWNLOAD] 77 | file_type = item.meta[TYPE] 78 | max_lifetime = item.meta.get(TIMESTAMP_MAX_LIFE, FOREVER) 79 | purge = True # be careful: we start from True, then AND the specified criteria 80 | if args.purge_age is not None: 81 | dt = args.purge_age * 24 * 3600 # n days since upload 82 | purge = purge and t_upload < tnow - dt 83 | if args.purge_inactivity is not None: 84 | dt = args.purge_inactivity * 24 * 3600 # n days inactivity (no download) 85 | purge = purge and t_download < tnow - dt 86 | if args.purge_size is not None: 87 | max_size = args.purge_size * 1024 * 1024 # size in MiB 88 | purge = purge and file_size > max_size 89 | if args.purge_type is not None: 90 | purge = purge and file_type.startswith(args.purge_type) 91 | if max_lifetime is not None: 92 | purge = purge and tnow > max_lifetime > 0 93 | if purge: 94 | print('removing: %s (%s %dB %s)' % (name, file_name, file_size, file_type)) 95 | if not args.purge_dry_run: 96 | storage.remove(name) 97 | 98 | _parser = _subparsers.add_parser('purge', help='Purge objects') 99 | _parser.set_defaults(func=do_purge) 100 | _parser.add_argument('-D', '--dry-run', dest='purge_dry_run', action='store_true', 101 | help='do not remove anything, just display what would happen') 102 | _parser.add_argument('-A', '--age', dest='purge_age', type=int, default=None, 103 | help='only remove if upload older than PURGE_AGE days') 104 | _parser.add_argument('-I', '--inactivity', dest='purge_inactivity', type=int, default=None, 105 | help='only remove if latest download older than PURGE_INACTIVITY days') 106 | _parser.add_argument('-S', '--size', dest='purge_size', type=int, default=None, 107 | help='only remove if file size > PURGE_SIZE MiB') 108 | _parser.add_argument('-T', '--type', dest='purge_type', default=None, 109 | help='only remove if file mimetype starts with PURGE_TYPE') 110 | 111 | def do_consistency(self, storage, name, args): 112 | with storage.openwrite(name) as item: 113 | file_name = item.meta[FILENAME] 114 | meta_size = item.meta[SIZE] 115 | meta_type = item.meta[TYPE] 116 | meta_hash = item.meta[HASH] 117 | 118 | print('checking: %s (%s %dB %s)' % (name, file_name, meta_size, meta_type)) 119 | 120 | size = item.data.size 121 | size_consistent = size == meta_size 122 | if not size_consistent: 123 | print("Inconsistent file size: meta: %d file: %d" % (meta_size, size)) 124 | if args.consistency_fix: 125 | print("Writing computed file size into metadata...") 126 | item.meta[SIZE] = size 127 | size_consistent = True 128 | 129 | file_hash = compute_hash(item.data, size) 130 | hash_consistent = meta_hash == file_hash 131 | if not hash_consistent: 132 | if meta_hash: 133 | print("Inconsistent hashes:") 134 | print("meta: %s" % meta_hash) 135 | print("file: %s" % file_hash) 136 | else: 137 | # the upload code can not compute hashes for chunked uploads and thus stores an empty hash. 138 | # we can fix that empty hash with the computed hash from the file we have in storage. 139 | print("Empty hash in metadata.") 140 | if args.consistency_fix or args.consistency_compute and not meta_hash: 141 | print("Writing computed file hash into metadata...") 142 | item.meta[HASH] = file_hash 143 | hash_consistent = True 144 | 145 | if args.consistency_remove and not (size_consistent and hash_consistent): 146 | print('REMOVING inconsistent file!') 147 | storage.remove(name) 148 | 149 | _parser = _subparsers.add_parser('consistency', help='Consistency-related functions') 150 | _parser.set_defaults(func=do_consistency) 151 | _parser.add_argument('-C', '--compute', dest='consistency_compute', action='store_true', 152 | help='compute missing hashes and write into metadata') 153 | _parser.add_argument('-F', '--fix', dest='consistency_fix', action='store_true', 154 | help='write computed hash/size into metadata') 155 | _parser.add_argument('-R', '--remove', dest='consistency_remove', action='store_true', 156 | help='remove files with inconsistent hash/size') 157 | 158 | def do_info(self, storage, name, args): 159 | with storage.open(name) as item: 160 | print(name) 161 | for key, value in sorted(item.meta.items()): 162 | print(' ', key, value) 163 | 164 | _parser = _subparsers.add_parser('info', help='Display information about objects') 165 | _parser.set_defaults(func=do_info) 166 | 167 | def do_set(self, storage, name, args): 168 | with storage.openwrite(name) as item: 169 | print(name) 170 | 171 | if args.flag_complete is not None: 172 | if args.flag_complete: 173 | print(' set complete') 174 | else: 175 | print(' set not complete') 176 | item.meta[COMPLETE] = args.flag_complete 177 | 178 | if args.flag_locked is not None: 179 | if args.flag_locked: 180 | print(' set locked') 181 | else: 182 | print(' set not locked') 183 | item.meta[LOCKED] = args.flag_locked 184 | 185 | _parser = _subparsers.add_parser('set', help='Set flags on objects') 186 | _parser.set_defaults(func=do_set) 187 | _group = _parser.add_mutually_exclusive_group() 188 | _group.add_argument('-L', '--lock', dest='flag_locked', action='store_true', default=None) 189 | _group.add_argument('-l', '--unlock', dest='flag_locked', action='store_false', default=None) 190 | _group = _parser.add_mutually_exclusive_group() 191 | _group.add_argument('-C', '--incomplete', dest='flag_complete', action='store_false', default=None) 192 | _group.add_argument('-c', '--complete', dest='flag_complete', action='store_true', default=None) 193 | 194 | def __call__(self): 195 | args = Main.argparser.parse_args() 196 | 197 | # Setup minimal application 198 | app = Flask(__name__) 199 | app.config.from_object('bepasty.config.Config') 200 | if os.environ.get('BEPASTY_CONFIG'): 201 | app.config.from_envvar('BEPASTY_CONFIG') 202 | if args.config is not None: 203 | cfg_path = os.path.abspath(args.config) 204 | app.config.from_pyfile(cfg_path) 205 | app.storage = storage = create_storage(app) 206 | 207 | # Setup application context 208 | with app.app_context(): 209 | # Run all before request functions by hand 210 | for i in app.before_request_funcs.get(None, ()): 211 | i() 212 | 213 | if len(args.names) == 1 and args.names[0] == '*': 214 | names = list(storage) 215 | else: 216 | names = args.names 217 | for name in names: 218 | try: 219 | args.func(self, storage, name, args) 220 | except Exception: 221 | logging.exception('Failed to handle item %s', name) 222 | 223 | 224 | def main(): 225 | logging.basicConfig() 226 | Main()() 227 | 228 | 229 | if __name__ == '__main__': 230 | main() 231 | -------------------------------------------------------------------------------- /src/bepasty/cli/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | bepasty-server commandline interface 3 | """ 4 | 5 | import argparse 6 | 7 | from ..app import create_app 8 | 9 | 10 | def main(): 11 | argparser = argparse.ArgumentParser(prog='bepasty-server') 12 | argparser.add_argument('--host', help='Host to listen on') 13 | argparser.add_argument('--port', type=int, help='Port to listen on') 14 | argparser.add_argument('--debug', help='Activate debug mode', action='store_true') 15 | 16 | args = argparser.parse_args() 17 | app = create_app() 18 | print(" * Starting bepasty server...") 19 | app.run(host=args.host, port=args.port, debug=args.debug) 20 | 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /src/bepasty/config.py: -------------------------------------------------------------------------------- 1 | class Config: 2 | """ 3 | This is the basic configuration class for bepasty. 4 | 5 | IMPORTANT: 6 | 7 | The config is only loaded at startup time of the app, so if you change it, 8 | you need to restart the wsgi app process(es) to make it load the updated 9 | config. 10 | """ 11 | 12 | #: name of this site (put YOUR bepasty fqdn here) 13 | SITENAME = 'bepasty.example.org' 14 | 15 | #: base URL path of app (if not served on root URL, but e.g. on http://example.org/bepasty ). 16 | #: setting this to a non-None value will activate the PrefixMiddleware that splits PATH_INFO 17 | #: into SCRIPT_NAME (== APP_BASE_PATH) and the rest of PATH_INFO. 18 | APP_BASE_PATH = None # '/bepasty' 19 | 20 | #: Whether files are automatically locked after upload. 21 | #: 22 | #: If you want to require admin approval and manual unlocking for each 23 | #: uploaded file, set this to True. 24 | UPLOAD_LOCKED = False 25 | 26 | #: The asciinema player theme (one of asciinema, tango, solarized-dark, 27 | #: solarized-light, monokai) 28 | ASCIINEMA_THEME = 'asciinema' 29 | 30 | #: The site admin can set some maximum allowed file size he wants to 31 | #: accept here. This is the maximum size an uploaded file may have. 32 | MAX_ALLOWED_FILE_SIZE = 5 * 1000 * 1000 * 1000 33 | 34 | #: The maximum http request body size. 35 | #: This is an information given to rest api clients so they can adjust 36 | #: their chunk size accordingly. 37 | #: 38 | #: This needs to be in sync with (or at least not beyond) the web server 39 | #: settings: 40 | #: apache: LimitRequestBody 1048576 # apache default is 0 (unlimited) 41 | #: nginx: client_max_body_size 1m; # nginx default (== 1048576) 42 | MAX_BODY_SIZE = 1 * 1024 * 1024 - 8192 # 8kiB safety margin, issue #155 43 | 44 | #: Setup maximum file sizes for specific content-types. If an item is 45 | #: beyond the limit set for its type, it will not be rendered, but just 46 | #: offered for download. Lookup within MAX_RENDER_SIZE is done by 47 | #: first-match and it is automatically sorted for longer content-type- 48 | #: prefixes first. 49 | #: 50 | #: Format of entries: content-type-prefix: max_size 51 | MAX_RENDER_SIZE = { 52 | # each list entry has 38 bytes, do not render > 1000 items 53 | 'text/x-bepasty-list': 1000 * 38, 54 | # stuff rendered with syntax highlighting (expensive for server and 55 | # client) and also used for other text/* types as we use same code to 56 | # get a (non-highlighted) display with line numbers: 57 | 'HIGHLIGHT_TYPES': 100 * 1000, 58 | # the in-browser pdf reader is sometimes rather slow and should 59 | # rather not be used for big PDFs: 60 | 'application/pdf': 10 * 1000 * 1000, 61 | 'application/x-pdf': 10 * 1000 * 1000, 62 | # images / audio / video can be rather big, we do not process them: 63 | 'image/': 20 * 1000 * 1000, 64 | 'audio/': 1 * 1000 * 1000 * 1000, 65 | 'video/': 5 * 1000 * 1000 * 1000, 66 | # DEFAULT - matches everything not matched otherwise. 67 | # As we have catched everything we are able to render already, 68 | # this maybe should be a medium size, just for the case we forget 69 | # something: 70 | '': 1 * 1000 * 1000, 71 | } 72 | 73 | # Whether to use the python-magic module to guess a file's mime 74 | # type by looking into its content (if the mime type can not be 75 | # determined from the filename extension). 76 | # NOTE: 77 | # libmagic may have security issues, so maybe you should only use 78 | # it if you trust all users with upload permissions ('create'). 79 | USE_PYTHON_MAGIC = False 80 | 81 | #: Define storage backend, choose from: 82 | #: 83 | #: - 'filesystem' 84 | #: 85 | STORAGE = 'filesystem' 86 | 87 | #: Filesystem storage path 88 | STORAGE_FILESYSTEM_DIRECTORY = '/tmp/' 89 | 90 | #: server secret key needed for safe session cookies. 91 | #: you must set a very long (20-100 chars), very random, very secret string here, 92 | #: otherwise bepasty will not work (and crash when trying to log in)! 93 | SECRET_KEY = '' 94 | 95 | #: transmit cookie only over https (if you use http, set this to False) 96 | SESSION_COOKIE_SECURE = True 97 | #: use a permanent session (True, cookie will expire after the given 98 | #: time, see below) or not (False, cookie will get removed when browser 99 | #: is closed) 100 | PERMANENT_SESSION = False 101 | #: lifetime of the permanent session (in seconds) 102 | PERMANENT_SESSION_LIFETIME = 31 * 24 * 3600 103 | 104 | #: Bepasty does **not** use the usual user/password scheme, but **only** 105 | #: uses passwords (or passphrases - we'll call both "a secret" below) as 106 | #: log-in credentials - there are no separate user names. 107 | #: 108 | #: People who log-in using such a secret may get more permissions than 109 | #: those who do not log-in (who just get DEFAULT_PERMISSIONS). 110 | #: 111 | #: Depending on the secret used to log-in, they will get the permissions 112 | #: configured here, see below. 113 | #: 114 | #: You can use same secret / same permissions for all privileged users or 115 | #: set up different secrets / different permissions for each user. 116 | #: 117 | #: If you want to be able to revoke permissions for some user / some group 118 | #: of users, it might be a good idea to remember to whom you gave which 119 | #: secret (and also handle it in a rather fine-grained way). 120 | #: 121 | #: PERMISSIONS is a dict that maps secrets to permissions, use it like: 122 | #: 123 | #: :: 124 | #: 125 | #: PERMISSIONS = { 126 | #: 'myadminsecret_1.21d-3!wdar34': 'admin,list,create,modify,read,delete', 127 | #: 'uploadersecret_rtghtrbrrrfsd': 'create,read', 128 | #: 'joe_doe_89359299887711335537': 'create,read,delete', 129 | #: } 130 | PERMISSIONS = { 131 | # 'foo': 'admin,list,create,modify,read,delete', 132 | } 133 | 134 | #: not-logged-in users get these permissions - 135 | #: usually they are either no permissions ('') or read-only ('read'). 136 | DEFAULT_PERMISSIONS = '' 137 | -------------------------------------------------------------------------------- /src/bepasty/constants.py: -------------------------------------------------------------------------------- 1 | FILENAME = 'filename' 2 | TYPE = 'type' 3 | TYPE_HINT = 'type-hint' 4 | LOCKED = 'locked' 5 | SIZE = 'size' 6 | COMPLETE = 'complete' 7 | HASH = 'hash' 8 | TIMESTAMP_UPLOAD = 'timestamp-upload' 9 | TIMESTAMP_DOWNLOAD = 'timestamp-download' 10 | TIMESTAMP_MAX_LIFE = 'timestamp-max-life' 11 | ID = 'id' # storage name 12 | FOREVER = -1 13 | 14 | # headers 15 | TRANSACTION_ID = 'Transaction-ID' # keep in sync with bepasty-cli 16 | 17 | # used internally only 18 | internal_meta = [TYPE_HINT] 19 | -------------------------------------------------------------------------------- /src/bepasty/static/app/bepasty.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | 30 | 31 | 32 | 34 | 37 | 41 | 42 | 43 | 63 | 67 | B 80 | pasty 112 | 117 | 122 | 123 | -------------------------------------------------------------------------------- /src/bepasty/static/app/css/style.css: -------------------------------------------------------------------------------- 1 | /* base styles 2 | -------------------------------------------------- */ 3 | 4 | html, 5 | body { 6 | height: 100%; 7 | max-width: 100%; 8 | margin: 0; 9 | -webkit-font-smoothing: antialiased; 10 | font-size: 100%; 11 | background-color: #fff; 12 | } 13 | 14 | #footer { 15 | height: 30px; 16 | position: relative; 17 | } 18 | 19 | #formupload { 20 | height: 400px; 21 | width: 100%; 22 | font-family: monospace; 23 | } 24 | 25 | #files p { 26 | word-break: break-all; 27 | } 28 | #files p .break-word { 29 | word-break: break-word; 30 | font-weight: normal !important; 31 | } 32 | 33 | #filelist { 34 | height: auto; 35 | width: 100%; 36 | font-family: monospace; 37 | } 38 | 39 | .fileupload-abort { 40 | float: right; 41 | margin-left: 10px; 42 | } 43 | 44 | .dropzone { 45 | width: 100%; 46 | padding: 3em; 47 | } 48 | 49 | #wrapper { 50 | min-height: 100%; 51 | margin: 0 auto -30px; 52 | padding: 0 0 60px; 53 | } 54 | 55 | #wrapper > .container { 56 | padding: 0 15px 60px; 57 | } 58 | 59 | .jumbotron .fa { 60 | font-size: 80px; 61 | } 62 | 63 | .jumbotron { 64 | font-size: 100%; 65 | text-align: center; 66 | } 67 | 68 | .alert { 69 | padding: 5px 10px; 70 | } 71 | 72 | .alert-processing { 73 | color: #464646; 74 | background-color: #f0f0f0; 75 | border-color: #eeeeee; 76 | } 77 | 78 | .alert-processing hr { 79 | border-top-color: #e6e6e6; 80 | } 81 | 82 | .alert-processing .alert-link { 83 | color: #959595; 84 | } 85 | 86 | /* if data is too wide, show scrollbars */ 87 | .data { 88 | width: 100%; 89 | overflow: auto; 90 | } 91 | 92 | .line-highlight { 93 | background-color: #ffc; 94 | } 95 | 96 | /* override pygments table highlighting style */ 97 | table.highlighttable { 98 | width: 100%; 99 | border: 1px solid #ccc; 100 | background-color: #f5f5f5; 101 | } 102 | 103 | .linenodiv pre, .highlight pre { 104 | margin-bottom: 0; 105 | -webkit-border-radius: 0; 106 | -moz-border-radius: 0; 107 | border-radius: 0; 108 | padding: 9.5px; 109 | } 110 | 111 | .highlight p { 112 | margin: 0; 113 | padding: 0 0.7em; 114 | } 115 | 116 | .linenodiv pre { 117 | border-right-style: none; 118 | } 119 | 120 | .linenodiv pre a:hover { 121 | text-decoration: none; 122 | } 123 | 124 | .highlight pre { 125 | padding-left: 0; 126 | padding-right: 0; 127 | border-left: 1px solid #ccc; 128 | } 129 | 130 | /* Set minimal width and let the contents push the block width */ 131 | .linenos { 132 | width: 1px; 133 | } 134 | 135 | /* autocomplete for modify dialog */ 136 | .ui-autocomplete { 137 | z-index: 1065; 138 | } 139 | -------------------------------------------------------------------------------- /src/bepasty/static/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/src/bepasty/static/app/favicon.ico -------------------------------------------------------------------------------- /src/bepasty/static/app/js/fileuploader.js: -------------------------------------------------------------------------------- 1 | jqXHR = {}; 2 | $(function () { 3 | 'use strict'; 4 | 5 | // Generate human readable file size 6 | function humansize (size) { 7 | var suffix = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"], 8 | tier = 0; 9 | 10 | while (size >= 1024) { 11 | size = size / 1024; 12 | tier++; 13 | } 14 | 15 | return Math.round(size * 10) / 10 + " " + suffix[tier]; 16 | } 17 | 18 | $('#fileupload') 19 | .fileupload({ 20 | dataType: 'json', 21 | autoUpload: true, 22 | singleFileUploads: true, 23 | maxChunkSize: MAX_BODY_SIZE, 24 | maxFileSize: MAX_ALLOWED_FILE_SIZE 25 | }) 26 | 27 | .on('fileuploadadd', function (e, data) { }) 28 | 29 | .on('fileuploadsubmit', function (e, data) { 30 | var $this = $(this); 31 | var file = data.files[0] 32 | // Create new item 33 | $.ajax({ 34 | type: 'POST', 35 | url: UPLOAD_NEW_URL, 36 | data: JSON.stringify({ 37 | filename: file.name, 38 | size: file.size, 39 | type: file.type, 40 | maxlife_unit: $("select[name=maxlife-unit] option:selected").val(), 41 | maxlife_value: $("input[name=maxlife-value]").val() 42 | }), 43 | contentType: 'application/json', 44 | success: function (result) { 45 | data.url = result.url; 46 | 47 | data.context = $('
') 48 | .appendTo('#files'); 49 | 50 | var abortButton = $(' 40 | 41 | 42 | 74 |
75 | 76 | 77 | 78 | 79 | 80 |
81 | {% block content %}{% endblock %} 82 |
83 | 84 | 85 | 86 | 87 | 88 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {% block extra_script %}{% endblock %} 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/bepasty/templates/_utils.html: -------------------------------------------------------------------------------- 1 | {% macro filelist(files) %} 2 | 3 | 4 | 5 | 12 | 15 | 20 | 23 | 24 | 25 | 26 | {% for file in files %} 27 | 28 | 37 | 42 | 51 | 86 | 87 | {% endfor %} 88 | 89 |
6 | Filename 7 |
8 | Type 9 |
10 | Size [B] 11 |
13 | Thumbnail 14 | 16 | Upload [UTC] 17 |
18 | Download [UTC] 19 |
21 | Actions 22 |
29 | 30 | {{ file['filename'] }} 31 | 32 |
33 | {{ file['type'] }} 34 |
35 | {{ file['size'] }} 36 |
38 | 39 | 40 | 41 | 43 | {{ file['timestamp-upload'] | datetime }} 44 |
45 | {% if file['timestamp-download'] %} 46 | {{ file['timestamp-download'] | datetime }} 47 | {% else %} 48 | never 49 | {% endif %} 50 |
52 |
53 | 54 | display 55 | 56 | 57 | download 58 | 59 | 60 | inline 61 | 62 | {% if may(DELETE) %} 63 |
64 | 67 |
68 | {% endif %} 69 | {% if may(ADMIN) %} 70 | {% if not file['locked'] %} 71 |
72 | 75 |
76 | {% else %} 77 |
78 | 81 |
82 | {% endif %} 83 | {% endif %} 84 |
85 |
90 | {% endmacro %} 91 | 92 | {% macro input_filename(value) -%} 93 | 94 | {%- endmacro %} 95 | 96 | {% macro input_contenttype(value) -%} 97 | 98 | {%- endmacro %} 99 | 100 | {% macro contenttype_autocomplete(selector, contenttypes) -%} 101 | var availableTypes = ["{{ contenttypes | join('","') | safe}}"]; 102 | {{ selector|safe }}.autocomplete({source: availableTypes}); 103 | {%- endmacro %} 104 | -------------------------------------------------------------------------------- /src/bepasty/templates/carousel.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {% block extra_link %} 4 | 16 | {% endblock %} 17 | 18 | {% block content %} 19 | 44 | {% endblock content %} 45 | -------------------------------------------------------------------------------- /src/bepasty/templates/display.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {%- import '_utils.html' as utils -%} 4 | 5 | {% block content %} 6 |
7 |
8 |

9 | {{ item.meta['filename'] }} 10 |

11 |
12 | {% if not item.meta['locked'] or may(ADMIN) %} 13 | {% if is_list_item %} 14 | 15 | Carousel 16 | 17 | {% endif %} 18 | 19 | QR 20 | 21 | 22 | Download 23 | 24 | 25 | Inline 26 | 27 | {% endif %} 28 | {% if may(MODIFY) %} 29 |
30 | 31 |
32 |
33 | 34 |
35 | {{ utils.input_filename(item.meta['filename']) }} 36 |
37 |
38 |
39 | 40 |
41 | {{ utils.input_contenttype(item.meta['type']) }} 42 |
43 |
44 | 45 |
46 |
47 | 50 | {% endif %} 51 | {% if may(DELETE) %} 52 |
53 | 54 | 57 |
58 | {% endif %} 59 | {% if may(ADMIN) %} 60 | {% if not item.meta['locked'] %} 61 |
62 | 65 |
66 | {% else %} 67 |
68 | 71 |
72 | {% endif %} 73 | {% endif %} 74 |
75 |
76 |
77 |

78 | Type: {{ item.meta['type'] }}, 79 | Size: {{ item.meta['size'] }} bytes, 80 | SHA256: {{ item.meta['hash'] }}. 81 |
82 | UTC timestamps: 83 | upload: {{ item.meta['timestamp-upload'] | datetime }}, 84 | download: {{ item.meta['timestamp-download'] | datetime }}, 85 | {% if item.meta['timestamp-max-life'] > 0 %} 86 | max lifetime: {{ item.meta['timestamp-max-life'] | datetime }}. 87 | {% else %} 88 | max lifetime: forever. 89 | {% endif %} 90 |

91 |
92 | {{ rendered_content }} 93 |
94 |
95 |
96 | {% endblock content %} 97 | 98 | {% block extra_link %} 99 | 100 | 101 | 102 | 103 | {% endblock %} 104 | 105 | {% block extra_script %} 106 | 107 | 108 | 109 | {% if may(MODIFY) %} 110 | 116 | {% endif %} 117 | {% endblock extra_script %} 118 | -------------------------------------------------------------------------------- /src/bepasty/templates/error.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

7 | {{ heading }} 8 |

9 |
10 |
11 |
12 | {{ body }} 13 |
14 |
15 |
16 | {% endblock content %} 17 | -------------------------------------------------------------------------------- /src/bepasty/templates/filelist.html: -------------------------------------------------------------------------------- 1 | {% import "_utils.html" as utils %} 2 | {% extends "_layout.html" %} 3 | 4 | {% block content %} 5 |
6 |
7 | {{ utils.filelist(files) }} 8 |
9 |
10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /src/bepasty/templates/filelist_tableonly.html: -------------------------------------------------------------------------------- 1 | {% import "_utils.html" as utils %} 2 | {{ utils.filelist(files) }} 3 | -------------------------------------------------------------------------------- /src/bepasty/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {%- import '_utils.html' as utils %} 4 | 5 | {% macro maximum_lifetime() -%} 6 |
7 |
8 |
9 |
10 |
11 | 20 |
21 |
22 | {% endmacro %} 23 | 24 | {% block content %} 25 | {% if may(CREATE) %} 26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | {{ utils.input_contenttype() }} 35 |
36 |
37 | {{ utils.input_filename() }} 38 |
39 |
40 | 41 |
42 |
43 |
44 | 45 | {{ maximum_lifetime() }} 46 | 47 | 48 | drag and drop files here - or click to select files 49 | 50 | 51 | 52 | 53 |
54 |
55 |
56 | 57 | 72 |
73 | 74 |
75 | 78 |
79 |
80 | 81 |
82 |
83 | 84 |
85 |
86 |
87 | 88 | {% else %} 89 |
90 |

bepasty, the Binary File Upload Site

91 |
92 |
93 |
94 |
95 | 96 |

Free and Nice

97 |

98 | A pastebin for all the stuff,
99 | not just for text. 100 |

101 |
102 |
103 | 104 |

Free and Open Source

105 |

106 | bepasty is free and open source software.
107 | bepasty project on github 108 |

109 |
110 |
111 | 112 |

Awesome Code

113 |

114 | Powered by Python and 115 | Flask. 116 |

117 |
118 |
119 |
120 | {% endif %} 121 | {% endblock content %} 122 | 123 | {% block extra_link %} 124 | 125 | 126 | 127 | 128 | {% endblock %} 129 | 130 | {% block extra_script %} 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 149 | 150 | 151 | 156 | {% endblock %} 157 | -------------------------------------------------------------------------------- /src/bepasty/templates/qr.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {% block extra_link %} 4 | 5 | 6 | 70 | {% endblock %} 71 | 72 | 73 | {% block content %} 74 |
75 |
76 |
77 |
78 | 79 |
80 | 82 |
83 |
84 |
85 | 86 |
87 | 93 |
94 | 95 |
96 | 97 |
98 |
99 |
100 | 101 |
102 | 103 |
104 |
105 | 106 |
107 |
108 |
109 |
110 |
111 |
112 | {% endblock %} 113 | -------------------------------------------------------------------------------- /src/bepasty/templates/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Redirecting ...

8 |

9 | If you do not get redirected automatically in {{ delay }}s, feel free to click the link: 10 |
11 |
12 | {{ url }} 13 |

14 | 15 |

Warning

16 |

17 | Please note that the target site's content is neither controlled nor verified by us. 18 | Not even the link has been verified by us. 19 |

20 |

21 | You use the internet on your own risk. If you break something, you own the pieces. 22 |

23 | 24 | 25 | -------------------------------------------------------------------------------- /src/bepasty/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/src/bepasty/tests/__init__.py -------------------------------------------------------------------------------- /src/bepasty/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from os import close, unlink 2 | from random import random 3 | from tempfile import mkstemp 4 | 5 | import pytest 6 | 7 | from bepasty.app import create_app 8 | 9 | 10 | @pytest.fixture(scope='module') 11 | def app(request): 12 | """ 13 | creates a bepasty App-Instance 14 | """ 15 | app = create_app() 16 | yield app 17 | unlink(app.config['DATABASE']) 18 | 19 | 20 | @pytest.fixture(scope='module') 21 | def testclient(request, app): 22 | """ 23 | creates a Flask-testclient instance for bepasty 24 | """ 25 | db_file, app.config['DATABASE'] = mkstemp() 26 | # reset default permissions 27 | app.config['DEFAULT_PERMISSIONS'] = '' 28 | # setup a secret key 29 | app.config['SECRET_KEY'] = str(random()) 30 | # setup permissions 31 | app.config['PERMISSIONS'] = { 32 | 'l': 'list', 33 | 'c': 'create', 34 | 'r': 'read', 35 | 'd': 'delete', 36 | 'a': 'admin' 37 | } 38 | yield app.test_client() 39 | close(db_file) 40 | -------------------------------------------------------------------------------- /src/bepasty/tests/screenshots.py: -------------------------------------------------------------------------------- 1 | from selenium.webdriver import Firefox 2 | from selenium.webdriver.common.by import By 3 | from selenium.webdriver.support import expected_conditions 4 | from selenium.webdriver.support.ui import WebDriverWait 5 | from selenium.common.exceptions import NoSuchElementException 6 | from selenium.common.exceptions import ElementNotInteractableException 7 | 8 | import pytest 9 | import os 10 | import time 11 | import tempfile 12 | 13 | 14 | @pytest.mark.needs_server 15 | class TestScreenShots: 16 | url_base = 'http://localhost:5000' 17 | # bootstrap4 breakpoints 18 | screenshot_dir = 'screenshots' 19 | screen_sizes = [(450, 700), (576, 800), (768, 600), (992, 768), (1200, 1024)] 20 | screenshot_seq = 1 21 | 22 | def setup_class(self): 23 | """ 24 | Setup: Open a mozilla browser, login 25 | """ 26 | self.browser = Firefox() 27 | self.browser.get(self.url_base + '/invalid') 28 | 29 | def teardown_class(self): 30 | """ 31 | Tear down: Close the browser 32 | """ 33 | self.browser.quit() 34 | 35 | def wait_present(self, xpath, timeout=10): 36 | cond = expected_conditions.presence_of_element_located((By.XPATH, xpath)) 37 | WebDriverWait(self.browser, timeout).until(cond) 38 | 39 | def screen_shot(self, name, w, h): 40 | if not os.path.isdir(self.screenshot_dir): 41 | os.mkdir(self.screenshot_dir) 42 | self.browser.save_screenshot( 43 | '{}/{:02d}-{}-{}x{}.png'.format(self.screenshot_dir, 44 | self.screenshot_seq, name, w, h) 45 | ) 46 | 47 | def screen_shots(self, name): 48 | for w, h in self.screen_sizes: 49 | self.browser.set_window_size(w, h) 50 | time.sleep(.1) 51 | self.screen_shot(name, w, h) 52 | 53 | def scroll_to_bottom(self): 54 | self.set_smallest_window_size() 55 | self.browser.execute_script('window.scrollTo(0, document.body.scrollHeight);') 56 | 57 | def set_smallest_window_size(self): 58 | # change smallest screen size 59 | w, h = self.screen_sizes[0] 60 | self.browser.set_window_size(w, h) 61 | time.sleep(.1) 62 | 63 | def set_largest_window_size(self): 64 | # change largest screen size 65 | w, h = self.screen_sizes[-1] 66 | self.browser.set_window_size(w, h) 67 | time.sleep(.1) 68 | 69 | def toggle_hamburger(self): 70 | self.set_smallest_window_size() 71 | 72 | # toggle hamburger menu 73 | try: 74 | self.browser.find_element_by_xpath('//button[@class="navbar-toggler"]').click() 75 | except ElementNotInteractableException: 76 | pass 77 | time.sleep(.5) 78 | 79 | self.set_largest_window_size() 80 | 81 | def top_screen_shots(self, name): 82 | self.screen_shots(f'{name}1') 83 | 84 | # open hamburger 85 | self.toggle_hamburger() 86 | 87 | self.screen_shots(f'{name}2') 88 | 89 | # close hamburger 90 | self.toggle_hamburger() 91 | 92 | def error_404(self): 93 | # NOTE: 404 error 94 | self.screen_shots("error404") 95 | self.screenshot_seq += 1 96 | 97 | def login(self): 98 | self.browser.get(self.url_base) 99 | 100 | # NOTE: login screen, 1 - close hamburger, 2 - open hamburger 101 | self.top_screen_shots("top") 102 | self.screenshot_seq += 1 103 | 104 | token = self.browser.find_element_by_name("token") 105 | password = "foo" 106 | # login 107 | token.send_keys(password) 108 | token.submit() 109 | self.wait_present("//input[@value='Logout']") 110 | 111 | # NOTE: upload screen, 1 - close hamburger, 2 - open hamburger 112 | self.top_screen_shots("upload") 113 | self.screenshot_seq += 1 114 | 115 | try: 116 | self.browser.find_element_by_xpath("//input[@value='Logout']") 117 | except NoSuchElementException: 118 | raise ValueError("Can't login! Please edit your config, go to PERMISSIONS setting " 119 | "and add a new secret 'foo' with all permissions.") 120 | 121 | def upload_file(self, path): 122 | # set file path 123 | fileupload = self.browser.find_element_by_id('fileupload') 124 | fileupload.send_keys(path) 125 | 126 | form = self.browser.find_element_by_xpath('//form[@action="/+upload"]') 127 | form.click() 128 | 129 | def upload_view(self): 130 | # small files 131 | for i in (1, 2, 3): 132 | with tempfile.NamedTemporaryFile(suffix=".sh") as fp: 133 | fp.write(b"""\ 134 | #!/bin/sh 135 | 136 | if [ $# -le 0 ]; then 137 | echo "no argument" 2>&1 138 | exit 1 139 | fi 140 | 141 | echo "hello, world!" 142 | """) 143 | fp.flush() 144 | self.upload_file(fp.name) 145 | 146 | self.scroll_to_bottom() 147 | 148 | # NOTE: uploaded screen 149 | self.screen_shots("uploading1") 150 | 151 | # big file 152 | with tempfile.NamedTemporaryFile(suffix=".bin") as fp: 153 | os.truncate(fp.name, 1024 * 1024 * 1024) 154 | self.upload_file(fp.name) 155 | 156 | self.scroll_to_bottom() 157 | 158 | # NOTE: in-progress uploading screen 159 | self.screen_shots("uploading2") 160 | self.screenshot_seq += 1 161 | 162 | # click abort 163 | abort = self.browser.find_element_by_id('fileupload-abort') 164 | abort.click() 165 | time.sleep(.5) 166 | 167 | # NOTE: abort bootbox 168 | self.screen_shots("abort") 169 | self.screenshot_seq += 1 170 | 171 | ok = self.browser.find_element_by_class_name('bootbox-accept') 172 | ok.click() 173 | 174 | self.scroll_to_bottom() 175 | 176 | # NOTE: aborted upload screen 177 | self.screen_shots("uploading3") 178 | self.screenshot_seq += 1 179 | 180 | def list_view(self): 181 | self.browser.get(self.url_base + '/+list') 182 | # NOTE: list screen 183 | self.screen_shots("list") 184 | self.screenshot_seq += 1 185 | 186 | def display_view(self): 187 | self.browser.get(self.url_base + '/+list') 188 | list_link = self.browser.find_elements_by_xpath('//tr/td/a') 189 | list_link[0].click() 190 | 191 | # highlight line 192 | self.browser.get(self.browser.current_url + '#L-4') 193 | 194 | # NOTE: display screen 195 | self.screen_shots("display") 196 | self.screenshot_seq += 1 197 | 198 | modify = self.browser.find_element_by_id('modify-btn') 199 | modify.click() 200 | time.sleep(.5) 201 | 202 | # NOTE: modify bootbox 203 | self.screen_shots("modify") 204 | self.screenshot_seq += 1 205 | 206 | modify_cancel = self.browser.find_element_by_class_name('bootbox-cancel') 207 | modify_cancel.click() 208 | time.sleep(.5) 209 | 210 | lock = self.browser.find_element_by_id('lock-btn') 211 | lock.click() 212 | # NOTE: display with lock screen 213 | self.screen_shots("lock") 214 | self.screenshot_seq += 1 215 | 216 | qr = self.browser.find_element_by_id('qr-btn') 217 | qr.click() 218 | # NOTE: QR code screen 219 | self.screen_shots("qr") 220 | self.screenshot_seq += 1 221 | 222 | def test(self): 223 | self.error_404() 224 | self.login() 225 | self.upload_view() 226 | self.list_view() 227 | self.display_view() 228 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_app.py: -------------------------------------------------------------------------------- 1 | # 2 | # app tests 3 | # 4 | 5 | from flask import request, url_for 6 | from flask.views import MethodView 7 | 8 | from ..app import create_app 9 | from ..config import Config 10 | 11 | 12 | def test_secret_key(monkeypatch): 13 | monkeypatch.setattr(Config, 'PERMISSIONS', { 14 | 'admin': 'admin,list,create,read,delete', 15 | 'full': 'list,create,read,delete', 16 | 'none': '', 17 | }) 18 | monkeypatch.setattr(Config, 'SECRET_KEY', 'secret') 19 | 20 | app = create_app() 21 | secret_key = app.config['SECRET_KEY'] 22 | assert len(secret_key) > len(Config.SECRET_KEY) 23 | 24 | Config.PERMISSIONS = { 25 | 'admin': 'admin,list,create,read,delete', 26 | 'none': '', 27 | } 28 | app = create_app() 29 | assert app.config['SECRET_KEY'] != secret_key 30 | 31 | 32 | class TestView(MethodView): 33 | callback = None 34 | 35 | def get(self): 36 | TestView.callback() 37 | return 'done' 38 | 39 | 40 | def prepare(callback): 41 | app = create_app() 42 | app.add_url_rule('/test_call', view_func=TestView.as_view('test.test_call')) 43 | 44 | TestView.callback = staticmethod(callback) 45 | client = app.test_client() 46 | 47 | assert app.config['APP_BASE_PATH'] == Config.APP_BASE_PATH 48 | 49 | return app, client 50 | 51 | 52 | def test_none(monkeypatch): 53 | monkeypatch.setattr(Config, 'APP_BASE_PATH', None) 54 | 55 | def none_callback(): 56 | url = url_for('test.test_call') 57 | assert url == request.path 58 | 59 | app, client = prepare(none_callback) 60 | 61 | response = client.get('/bepasty/test_call') 62 | assert response.status_code == 404 63 | 64 | response = client.get('/test_call') 65 | assert response.status_code == 200 66 | assert response.data == b'done' 67 | 68 | 69 | def test_prefix(monkeypatch): 70 | monkeypatch.setattr(Config, 'APP_BASE_PATH', '/bepasty') 71 | 72 | def prefix_callback(): 73 | url = url_for('test.test_call') 74 | assert url == Config.APP_BASE_PATH + request.path 75 | 76 | app, client = prepare(prefix_callback) 77 | 78 | response = client.get('/test_call') 79 | assert response.status_code == 404 80 | 81 | response = client.get('/bepasty/test_call') 82 | assert response.status_code == 200 83 | assert response.data == b'done' 84 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_data.py: -------------------------------------------------------------------------------- 1 | from bepasty.storage.filesystem import Data 2 | 3 | 4 | def test(tmpdir): 5 | p = tmpdir.join('test.data') 6 | 7 | d = Data(p.open('w+b')) 8 | assert d.size == 0 9 | 10 | d.write(b'a' * 1024, 0) 11 | assert d.size == 1024 12 | 13 | d.write(b'a' * 1024, 1024 * 3) 14 | assert d.size == 1024 * 4 15 | 16 | assert d.read(1024, 0) == b'a' * 1024 17 | assert d.read(1024, 1024) == b'\0' * 1024 18 | 19 | d.close() 20 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_date_funcs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bepasty.constants import FOREVER 4 | from bepasty.utils.date_funcs import get_maxlife, time_unit_to_sec 5 | 6 | 7 | def test_get_maxlife(): 8 | assert get_maxlife({}, underscore=False) == 60 * 60 * 24 * 30 9 | assert get_maxlife({}, underscore=True) == 60 * 60 * 24 * 30 10 | 11 | 12 | @pytest.mark.parametrize('unit,expectation', [ 13 | ('MINUTES', 60), 14 | ('HOURS', 60 * 60), 15 | ('DAYS', 60 * 60 * 24), 16 | ('WEEKS', 60 * 60 * 24 * 7), 17 | ('MONTHS', 60 * 60 * 24 * 30), 18 | ('YEARS', 60 * 60 * 24 * 365), 19 | ('FOREVER', FOREVER), 20 | ]) 21 | def test_unit_to_secs(unit, expectation): 22 | assert time_unit_to_sec(1, unit) == expectation 23 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_http.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from werkzeug.exceptions import BadRequest 3 | 4 | from bepasty.utils.http import ContentRange 5 | 6 | 7 | def test_contentrange_parse(): 8 | r = ContentRange.parse('bytes 0-0/2') 9 | assert r.begin == 0 10 | assert r.end == 0 11 | assert r.complete == 2 12 | assert r.size == 1 13 | assert not r.is_complete 14 | 15 | r = ContentRange.parse('bytes 0-1/2') 16 | assert r.begin == 0 17 | assert r.end == 1 18 | assert r.complete == 2 19 | assert r.size == 2 20 | assert r.is_complete 21 | 22 | with pytest.raises(BadRequest): 23 | ContentRange.parse('test 0-1/2') 24 | 25 | with pytest.raises(BadRequest): 26 | ContentRange.parse('bytes 1-0/2') 27 | 28 | with pytest.raises(BadRequest): 29 | ContentRange.parse('bytes 0-2/2') 30 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_item.py: -------------------------------------------------------------------------------- 1 | from bepasty.storage.filesystem import Item 2 | 3 | 4 | def test(tmpdir): 5 | pm = tmpdir.join("test.meta") 6 | pd = tmpdir.join("test.data") 7 | 8 | with Item(pm.open('w+b'), pd.open('w+b')) as i: 9 | assert i.data is not None 10 | assert i.meta is not None 11 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_meta.py: -------------------------------------------------------------------------------- 1 | from bepasty.storage.filesystem import Meta 2 | 3 | 4 | def test(tmpdir): 5 | p = tmpdir.join("test.meta") 6 | 7 | m = Meta(p.open('w+b')) 8 | assert len(m) == 0 9 | m.close() 10 | 11 | m = Meta(p.open('r+b')) 12 | m['flag'] = True 13 | assert len(m) == 1 14 | m.close() 15 | 16 | m = Meta(p.open('r+b')) 17 | assert len(m) == 1 18 | assert m['flag'] is True 19 | m.close() 20 | 21 | 22 | def test_iter(tmpdir): 23 | p = tmpdir.join("test.meta") 24 | 25 | m = Meta(p.open('w+b')) 26 | keys = ["foo", "bar", "baz", ] 27 | for key in keys: 28 | m[key] = True 29 | m.close() 30 | 31 | m = Meta(p.open('r+b')) 32 | assert set(list(m)) == set(keys) 33 | m.close() 34 | 35 | 36 | def test_del(tmpdir): 37 | p = tmpdir.join("test.meta") 38 | key = "foo" 39 | 40 | m = Meta(p.open('w+b')) 41 | m[key] = True 42 | m.close() 43 | 44 | m = Meta(p.open('r+b')) 45 | del m[key] 46 | m.close() 47 | 48 | m = Meta(p.open('r+b')) 49 | assert len(m) == 0 50 | assert key not in m 51 | m.close() 52 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_name.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bepasty.utils.name import ItemName, encode, make_id 4 | 5 | 6 | def test_create(): 7 | fake_storage = {} 8 | n = ItemName.create(fake_storage) 9 | assert n 10 | 11 | 12 | def test_create_many(): 13 | fake_storage = {} 14 | length = 1 15 | count = 400 # way more than we can do with this name length 16 | max_seen_length = 0 17 | for i in range(count): 18 | name = ItemName.create(fake_storage, length=length, max_length=length * 4, max_tries=10) 19 | # use the name in storage, so it is not available any more 20 | fake_storage[name] = None 21 | max_seen_length = max(max_seen_length, len(name)) 22 | # it should automatically use longer names, if it runs out of unique names: 23 | assert max_seen_length > length 24 | # we should get all unique names we wanted, no duplicates: 25 | assert len(list(fake_storage)) == count 26 | 27 | 28 | def test_make_id_type(): 29 | assert isinstance(make_id(2), str) 30 | 31 | 32 | def test_make_id_length(): 33 | for length in range(10): 34 | assert len(make_id(length)) == length 35 | 36 | 37 | def test_make_id_alphabet(): 38 | # id must contain alphabet chars ONLY 39 | assert set(make_id(10, alphabet="abc")) <= {'a', 'b', 'c'} 40 | 41 | 42 | def test_make_id_unique(): 43 | length, count = 6, 10000 44 | ids = {make_id(length) for i in range(count)} 45 | # if we did not encounter duplicates, set size must be 46 | # of course, in extremely rare cases, this test might fail 47 | assert len(ids) == count 48 | 49 | 50 | def test_encode_length(): 51 | length = 42 52 | assert len(encode(12345, length)) == length 53 | 54 | 55 | def test_encode_binary(): 56 | assert encode(0, 0, "01") == [] # zero length 57 | assert encode(1, 0, "01") == [] # zero length 58 | assert encode(0, 1, "01") == ['0'] # length match 59 | assert encode(1, 1, "01") == ['1'] # length match 60 | assert encode(0, 2, "01") == ['0', '0'] # leading zeroes 61 | assert encode(1, 2, "01") == ['0', '1'] # leading zeroes 62 | assert encode(2, 2, "01") == ['1', '0'] # length match 63 | assert encode(3, 2, "01") == ['1', '1'] # length match 64 | assert encode(4, 2, "01") == ['0', '0'] # overflow truncated 65 | 66 | 67 | def test_encode_special(): 68 | # equivalent to binary, but we see the code is rather flexible 69 | assert encode(0, 2, ".+") == ['.', '.'] 70 | assert encode(1, 2, ".+") == ['.', '+'] 71 | assert encode(2, 2, ".+") == ['+', '.'] 72 | assert encode(3, 2, ".+") == ['+', '+'] 73 | 74 | 75 | def test_encode_decimal(): 76 | assert encode(123, 3, "0123456789") == ['1', '2', '3'] # length match 77 | assert encode(456, 4, "0123456789") == ['0', '4', '5', '6'] # leading zeroes 78 | assert encode(7890, 3, "0123456789") == ['8', '9', '0'] # overflow truncated 79 | 80 | 81 | def test_encode_hex(): 82 | assert encode(31, 2, "0123456789ABCDEF") == ['1', 'F'] 83 | 84 | 85 | def test_encode_errors(): 86 | with pytest.raises(ValueError): # ValueError: alphabet must be at least 2 elements long 87 | encode(1, 1, "0") 88 | 89 | with pytest.raises(ValueError): # ValueError: length must be >= 0 90 | encode(1, -1, "0") 91 | 92 | with pytest.raises(ValueError): # ValueError: negative values are not supported 93 | encode(-1, 1, "0") 94 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bepasty.storage.filesystem import Storage 4 | 5 | 6 | def test_contains(tmpdir): 7 | storage = Storage(str(tmpdir)) 8 | name = "foo" 9 | # check if it is not there yet 10 | assert name not in storage 11 | with storage.create(name, 0): 12 | # we just want it created, no need to write sth into it 13 | pass 14 | # check if it is there 15 | assert name in storage 16 | storage.remove(name) 17 | # check if it is gone 18 | assert name not in storage 19 | 20 | 21 | def test_iter(tmpdir): 22 | storage = Storage(str(tmpdir)) 23 | # nothing there yet 24 | assert list(storage) == [] 25 | names = ["foo", "bar", "baz", ] 26 | for name in names: 27 | with storage.create(name, 0): 28 | # we just want it created, no need to write sth into it 29 | pass 30 | assert set(list(storage)) == set(names) 31 | 32 | 33 | def test_invalid_name(tmpdir): 34 | storage = Storage(str(tmpdir)) 35 | name = "../invalid" 36 | with pytest.raises(RuntimeError): 37 | storage.create(name, 0) 38 | -------------------------------------------------------------------------------- /src/bepasty/tests/test_website.py: -------------------------------------------------------------------------------- 1 | from selenium.webdriver import Firefox 2 | from selenium.webdriver.chrome.service import Service as ChromeService 3 | from selenium.webdriver import Chrome 4 | from selenium.webdriver.common.keys import Keys 5 | from selenium.common.exceptions import ( 6 | NoSuchDriverException, NoSuchElementException 7 | ) 8 | 9 | import pytest 10 | 11 | import time 12 | 13 | 14 | @pytest.mark.needs_server 15 | class TestMaxlifeFeature: 16 | """ 17 | Checks if the maxlife feature is working 18 | """ 19 | 20 | def setup_class(self): 21 | """ 22 | Setup: Open a mozilla browser, login 23 | """ 24 | try: 25 | self.browser = Firefox() 26 | except NoSuchDriverException: 27 | service = ChromeService(executable_path="/usr/bin/chromedriver") 28 | self.browser = Chrome(service=service) 29 | self.browser.get('http://localhost:5000/') 30 | token = self.browser.find_element_by_name("token") 31 | password = "foo" 32 | # login 33 | token.send_keys(password) 34 | token.send_keys(Keys.ENTER) 35 | time.sleep(.1) 36 | try: 37 | self.browser.find_element_by_xpath("//input[@value='Logout']") 38 | except NoSuchElementException: 39 | raise ValueError("Can't login!!! Create a user 'foo' with the permissions" 40 | "'read' and 'create' in your PERMISSIONS in the config") 41 | 42 | def teardown_class(self): 43 | """ 44 | Tear down: Close the browser 45 | """ 46 | self.browser.quit() 47 | 48 | @property 49 | def page_body_lowercase(self): 50 | return self.browser.find_element_by_tag_name("body").text.lower() 51 | 52 | def test_unit_input_exists(self): 53 | unit_input = self.browser.find_element_by_name("maxlife-unit") 54 | assert unit_input is not None 55 | value_input = self.browser.find_element_by_name("maxlife-value") 56 | assert value_input is not None 57 | 58 | def fill_form(self): 59 | """ 60 | Fills test values to the form and submits it 61 | :return: tuple(filename, pasted_text) 62 | """ 63 | filename = "test.txt" 64 | text_to_paste = "This is test" 65 | paste_input = self.browser.find_element_by_id("formupload") 66 | paste_input.send_keys(text_to_paste) 67 | filename_input = self.browser.find_element_by_id("filename") 68 | filename_input.send_keys(filename) 69 | contenttype_input = self.browser.find_element_by_id("contenttype") 70 | contenttype_input.send_keys("text/plain") 71 | contenttype_input.send_keys(Keys.ENTER) 72 | time.sleep(.2) # give some time to render next view 73 | return filename, text_to_paste 74 | 75 | def delete_current_file(self): 76 | self.browser.find_element_by_id("del-btn").click() 77 | time.sleep(.2) 78 | self.browser.find_element_by_class_name("bootbox-accept").click() 79 | 80 | def test_paste_keep_forever(self): 81 | self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='forever']").click() 82 | value_input = self.browser.find_element_by_name("maxlife-value") 83 | value_input.clear() 84 | value_input.send_keys(1) 85 | self.fill_form() 86 | assert "max lifetime: forever" in self.page_body_lowercase 87 | self.delete_current_file() 88 | 89 | def test_paste_keep_minutes(self): 90 | self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='minutes']").click() 91 | value_input = self.browser.find_element_by_name("maxlife-value") 92 | value_input.clear() 93 | value_input.send_keys(1) 94 | self.fill_form() 95 | assert "max lifetime: forever" not in self.page_body_lowercase 96 | self.delete_current_file() 97 | 98 | def test_filename_gets_displayed(self): 99 | filename, _ = self.fill_form() 100 | assert filename.lower() in self.page_body_lowercase 101 | self.delete_current_file() 102 | 103 | def test_pasted_text_gets_displayed(self): 104 | _, pasted_text = self.fill_form() 105 | self.browser.find_element_by_id("inline-btn").click() 106 | assert pasted_text.lower() in self.page_body_lowercase 107 | self.browser.back() 108 | self.delete_current_file() 109 | 110 | @pytest.mark.slow 111 | def test_file_gets_deleted_after_expiry_time(self): 112 | self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='minutes']").click() 113 | value_input = self.browser.find_element_by_name("maxlife-value") 114 | value_input.clear() 115 | value_input.send_keys(1) 116 | self.fill_form() 117 | time.sleep(61) 118 | self.browser.find_element_by_id("inline-btn").click() 119 | assert "not found" in self.page_body_lowercase 120 | -------------------------------------------------------------------------------- /src/bepasty/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bepasty/bepasty-server/623c427e6eee90dc1da8432d1fb5125a3e7c27f7/src/bepasty/utils/__init__.py -------------------------------------------------------------------------------- /src/bepasty/utils/date_funcs.py: -------------------------------------------------------------------------------- 1 | import time 2 | from flask import current_app 3 | from werkzeug.exceptions import BadRequest 4 | 5 | from ..constants import FOREVER, TIMESTAMP_MAX_LIFE 6 | 7 | 8 | def get_maxlife(data, underscore): 9 | unit_key = 'maxlife_unit' if underscore else 'maxlife-unit' 10 | unit_default = 'MONTHS' 11 | unit = data.get(unit_key, unit_default).upper() 12 | value_key = 'maxlife_value' if underscore else 'maxlife-value' 13 | value_default = '1' 14 | try: 15 | value = int(data.get(value_key, value_default)) 16 | except (ValueError, TypeError): 17 | raise BadRequest(description=f'{value_key} header is incorrect') 18 | try: 19 | return time_unit_to_sec(value, unit) 20 | except KeyError: 21 | raise BadRequest(description=f'{unit_key} header is incorrect') 22 | 23 | 24 | def time_unit_to_sec(value, unit): 25 | """ 26 | Converts a numeric value and with a string time unit unit to a time in seconds 27 | 28 | :param value: int 29 | :param unit: str in ['MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS', 'FOREVER'] 30 | :return: time in seconds 31 | """ 32 | units = { 33 | 'MINUTES': 60, 34 | 'HOURS': 60 * 60, 35 | 'DAYS': 60 * 60 * 24, 36 | 'WEEKS': 60 * 60 * 24 * 7, 37 | 'MONTHS': 60 * 60 * 24 * 30, 38 | 'YEARS': 60 * 60 * 24 * 365, 39 | 'FOREVER': FOREVER, 40 | } 41 | secs = units[unit] * value if units[unit] > 0 else units[unit] 42 | return secs 43 | 44 | 45 | def delete_if_lifetime_over(item, name): 46 | """ 47 | :return: True if file was deleted 48 | """ 49 | if 0 < item.meta[TIMESTAMP_MAX_LIFE] < time.time(): 50 | try: 51 | current_app.storage.remove(name) 52 | except OSError: 53 | pass 54 | return True 55 | return False 56 | -------------------------------------------------------------------------------- /src/bepasty/utils/decorators.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | 3 | 4 | def threaded(func): 5 | """ 6 | decorator to run a function asynchronously (in a thread) 7 | 8 | be careful: do not access flask threadlocals in f! 9 | """ 10 | def wrapper(*args, **kwargs): 11 | t = Thread(target=func, args=args, kwargs=kwargs) 12 | t.start() 13 | return wrapper 14 | -------------------------------------------------------------------------------- /src/bepasty/utils/formatters.py: -------------------------------------------------------------------------------- 1 | from pygments.formatters.html import HtmlFormatter 2 | 3 | 4 | class CustomHtmlFormatter(HtmlFormatter): 5 | """Custom HTML formatter. Adds an option to wrap lines into HTML

tags.""" 6 | def __init__(self, **options): 7 | super().__init__(**options) 8 | self.lineparagraphs = options.get('lineparagraphs', '') 9 | 10 | def _wrap_lineparagraphs(self, inner): 11 | """Wrap lines into

tags 12 | 13 | :param inner: iterator of tuples of format (code, text) 14 | :return: iterator of tuples containing updated wrapped lines 15 | """ 16 | s = self.lineparagraphs 17 | i = self.linenostart - 1 18 | for t, line in inner: 19 | if t: 20 | i += 1 21 | yield 1, '

%s

' % (s, i, line) 22 | else: 23 | yield 0, line 24 | 25 | def format_unencoded(self, tokensource, outfile): 26 | """ 27 | The formatting process uses several nested generators; which of 28 | them are used is determined by the user's options. 29 | 30 | Each generator should take at least one argument, ``inner``, 31 | and wrap the pieces of text generated by this. 32 | 33 | Always yield 2-tuples: (code, text). If "code" is 1, the text 34 | is part of the original tokensource being highlighted, if it's 35 | 0, the text is some piece of wrapping. This makes it possible to 36 | use several different wrappers that process the original source 37 | linewise, e.g. line number generators. 38 | """ 39 | source = self._format_lines(tokensource) 40 | 41 | # As a special case, we wrap line numbers before line highlighting 42 | # so the line numbers get wrapped in the highlighting tag. 43 | if not self.nowrap and self.linenos == 2: 44 | source = self._wrap_inlinelinenos(source) 45 | 46 | if self.hl_lines: 47 | source = self._highlight_lines(source) 48 | 49 | if not self.nowrap: 50 | if self.lineanchors: 51 | source = self._wrap_lineanchors(source) 52 | if self.linespans: 53 | source = self._wrap_linespans(source) 54 | # vvv customization of bepasty start: 55 | if self.lineparagraphs: 56 | source = self._wrap_lineparagraphs(source) 57 | # ^^^ customization of bepasty end. 58 | source = self.wrap(source) 59 | if self.linenos == 1: 60 | source = self._wrap_tablelinenos(source) 61 | source = self._wrap_div(source) 62 | if self.full: 63 | source = self._wrap_full(source, outfile) 64 | 65 | for t, piece in source: 66 | outfile.write(piece) 67 | -------------------------------------------------------------------------------- /src/bepasty/utils/hashing.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 as hash_new 2 | 3 | SIZE = 1024 * 1024 4 | 5 | 6 | def compute_hash(data, size): 7 | """ 8 | compute hash of storage item's data file, return hexdigest 9 | """ 10 | hasher = hash_new() 11 | offset = 0 12 | while offset < size: 13 | buf = data.read(SIZE, offset) 14 | offset += len(buf) 15 | hasher.update(buf) 16 | return hasher.hexdigest() 17 | -------------------------------------------------------------------------------- /src/bepasty/utils/http.py: -------------------------------------------------------------------------------- 1 | import collections 2 | from urllib.parse import urlparse, urljoin 3 | 4 | from flask import request, redirect, url_for 5 | from werkzeug.exceptions import BadRequest 6 | 7 | 8 | # safely and comfortably redirect 9 | # some stuff taken from / inspired by http://flask.pocoo.org/snippets/62/ 10 | 11 | def is_safe_url(target): 12 | """ 13 | check if target will lead to the same server 14 | """ 15 | ref_url = urlparse(request.host_url) 16 | test_url = urlparse(urljoin(request.host_url, target)) 17 | return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc 18 | 19 | 20 | def _redirect_target_url(d, use_referrer, endpoint, **values): 21 | """ 22 | return redirect url to (in that order): 23 | 24 | - from d 25 | - referrer (if use_referrer is True) 26 | - the url for endpoint/values 27 | """ 28 | targets = [d.get('next'), request.referrer, url_for(endpoint, **values)] 29 | if not use_referrer: 30 | del targets[1] 31 | for target in targets: 32 | if target and is_safe_url(target): 33 | return target 34 | 35 | 36 | # GET - for next 2, you may want to create urls with: 37 | # url_for(endpoint, ..., next=something) 38 | 39 | def get_redirect_target(endpoint, **values): 40 | return _redirect_target_url(request.values, False, endpoint, **values) 41 | 42 | 43 | def get_redirect_target_referrer(endpoint, **values): 44 | return _redirect_target_url(request.values, True, endpoint, **values) 45 | 46 | 47 | # POST - for next 2, you may want this in the form: 48 | # 49 | 50 | def redirect_next(endpoint, **values): 51 | return redirect(_redirect_target_url(request.form, False, endpoint, **values)) 52 | 53 | 54 | def redirect_next_referrer(endpoint, **values): 55 | return redirect(_redirect_target_url(request.form, True, endpoint, **values)) 56 | 57 | 58 | class ContentRange(collections.namedtuple('ContentRange', ('begin', 'end', 'complete'))): 59 | """ 60 | Work with Content-Range headers. 61 | """ 62 | __slots__ = () 63 | 64 | @classmethod 65 | def parse(cls, content_range): 66 | """ 67 | Parse Content-Range header. 68 | Format is "bytes 0-524287/2000000". 69 | """ 70 | try: 71 | range_type, range_count = content_range.split(' ', 1) 72 | except ValueError: 73 | raise BadRequest(description='Content-Range Header is incorrect') 74 | # There are no other types then "bytes" 75 | if range_type != 'bytes': 76 | raise BadRequest 77 | 78 | try: 79 | range_count, range_complete = range_count.split('/', 1) 80 | except ValueError: 81 | raise BadRequest(description='Content-Range Header is incorrect') 82 | 83 | try: 84 | # For now, */2000000 format is not supported 85 | range_begin, range_end = range_count.split('-', 1) 86 | 87 | range_begin = int(range_begin) 88 | range_end = int(range_end) 89 | except ValueError: 90 | raise BadRequest(description='Content-Range Header has invalid range') 91 | 92 | # For now, 0-10/* format is not supported 93 | try: 94 | range_complete = int(range_complete) 95 | except ValueError: 96 | raise BadRequest(description='Content-Range Header has invalid length') 97 | 98 | if range_begin <= range_end < range_complete: 99 | return ContentRange(range_begin, range_end, range_complete) 100 | 101 | raise BadRequest 102 | 103 | @classmethod 104 | def from_request(cls): 105 | """ 106 | Read Content-Range from request and parse it 107 | """ 108 | content_range = request.headers.get('Content-Range') 109 | if content_range is not None: 110 | return cls.parse(content_range) 111 | 112 | @property 113 | def is_complete(self): 114 | return self.complete == self.end + 1 115 | 116 | @property 117 | def size(self): 118 | return self.end - self.begin + 1 119 | 120 | 121 | class DownloadRange(collections.namedtuple('DownloadRange', ('begin', 'end'))): 122 | """ 123 | Work with Range headers. 124 | """ 125 | __slots__ = () 126 | 127 | @classmethod 128 | def parse(cls, content_range): 129 | """ 130 | Parse Range header. 131 | Format is "bytes=0-524287". 132 | """ 133 | try: 134 | range_type, range_count = content_range.split('=', 1) 135 | except ValueError: 136 | raise BadRequest(description='Range Header is incorrect') 137 | # There are no other types than "bytes" 138 | if range_type != 'bytes': 139 | raise BadRequest(description='Range Header is incorrect') 140 | 141 | try: 142 | range_begin, range_end = range_count.split('-', 1) 143 | except ValueError: 144 | raise BadRequest(description='Range Header is incorrect') 145 | 146 | try: 147 | range_begin = int(range_begin) 148 | except ValueError: 149 | raise BadRequest(description='Range Header has invalid first') 150 | 151 | if range_end: 152 | # For now, set of ranges (e.g. 0-1,2-10) is not supported 153 | try: 154 | range_end = int(range_end) 155 | except ValueError: 156 | raise BadRequest(description='Range Header has invalid last') 157 | else: 158 | range_end = -1 159 | 160 | if range_begin <= range_end or range_end == -1: 161 | return DownloadRange(range_begin, range_end) 162 | 163 | raise BadRequest(description='Range Header is incorrect') 164 | 165 | @classmethod 166 | def from_request(cls): 167 | """ 168 | Read Content-Range from request and parse it 169 | """ 170 | download_range = request.headers.get('Range') 171 | if download_range is not None: 172 | return cls.parse(download_range) 173 | 174 | @property 175 | def size(self): 176 | return self.end - self.begin + 1 177 | -------------------------------------------------------------------------------- /src/bepasty/utils/name.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from werkzeug.routing import BaseConverter 5 | 6 | ID_LENGTH = 8 7 | 8 | letters_lower = set("abcdefghijklmnopqrstuvwxyz") 9 | letters_upper = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ") 10 | digits = set("0123456789") 11 | 12 | # this stuff might be hard to read / differentiate or is otherwise unwanted: 13 | unwanted = set( 14 | "1lI" 15 | "0O" 16 | "8B" 17 | "5S" 18 | "+" # used in URL dispatching for views, e.g. +login 19 | "/" # used in URLs and fs paths 20 | ) 21 | 22 | all_chars = letters_lower | letters_upper | digits 23 | all_chars -= unwanted 24 | all_chars = ''.join(sorted(all_chars)) 25 | 26 | 27 | def encode(x, length, alphabet=all_chars): 28 | """ 29 | encode x in alphabet (with "leading zeroes") 30 | 31 | :param x: integer number 32 | :param length: length of return sequence 33 | :param alphabet: alphabet to choose characters from (default: all_chars) 34 | :return: sequence of alphabet members [list] 35 | """ 36 | if x < 0: 37 | raise ValueError("negative values are not supported") 38 | if length < 0: 39 | raise ValueError("length must be >= 0") 40 | n = len(alphabet) 41 | if n < 2: 42 | raise ValueError("alphabet must be at least 2 elements long") 43 | code = [] 44 | counter = length 45 | while x > 0 and counter > 0: 46 | x, r = divmod(x, n) 47 | code.append(alphabet[r]) 48 | counter -= 1 49 | leading_zeroes = length - len(code) 50 | code += list(alphabet[0] * leading_zeroes) 51 | return list(reversed(code)) 52 | 53 | 54 | def make_id(length, x=None, alphabet=all_chars): 55 | """ 56 | generate a id of from value . 57 | if x is None, use a random value for x. 58 | for the id, use elements from alphabet. 59 | """ 60 | base = len(alphabet) # e.g. 10 61 | if x is None: 62 | x = random.randint( 63 | 0, # 000 (length==3) ... 64 | base ** length - 1 # 999 (length==3) 65 | ) 66 | return ''.join(encode(x, length, alphabet)) 67 | 68 | 69 | class ItemName(str): 70 | def __new__(cls, uid): 71 | return str(uid) 72 | 73 | @classmethod 74 | def create(cls, storage, length=ID_LENGTH, max_length=2 * ID_LENGTH, max_tries=10): 75 | """ 76 | create a unique item name in storage, wanted name length is . 77 | 78 | we try at most times to find a unique name of a specific length - 79 | if we do not succeed, we increase name length and try again. 80 | if we can't find a unique name even for longer lengths up to max_length, 81 | we'll raise RuntimeError. 82 | """ 83 | name = None # avoid false alarm about reference before assignment 84 | while length <= max_length: 85 | tries = 0 86 | while tries < max_tries: 87 | name = make_id(length) 88 | if name not in storage: 89 | break 90 | tries += 1 91 | if tries < max_tries: 92 | # we found a name, break out of outer while also 93 | break 94 | length += 1 95 | if length > max_length: 96 | raise RuntimeError("no unique names available") 97 | return cls(name) 98 | 99 | 100 | class ItemNameConverter(BaseConverter): 101 | """ 102 | Accept the names of the style as we generate. 103 | """ 104 | # for a while, support both old uuid4-style as well as new shorter IDs 105 | regex = ( 106 | '([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})' 107 | '|' 108 | '([%(valid)s]{%(length)d})' 109 | ) % dict(valid=re.escape(all_chars), length=ID_LENGTH) 110 | weight = 200 111 | 112 | 113 | def setup_werkzeug_routing(app): 114 | app.url_map.converters['itemname'] = ItemNameConverter 115 | -------------------------------------------------------------------------------- /src/bepasty/utils/permissions.py: -------------------------------------------------------------------------------- 1 | from flask import request, session, current_app 2 | from flask import g as flaskg 3 | 4 | 5 | # in the code, please always use this constants for permission values: 6 | ADMIN = 'admin' 7 | LIST = 'list' 8 | CREATE = 'create' 9 | MODIFY = 'modify' 10 | READ = 'read' 11 | DELETE = 'delete' 12 | 13 | # key in the session: 14 | PERMISSIONS = 'permissions' 15 | LOGGEDIN = 'loggedin' 16 | 17 | permission_icons = { 18 | 'admin': 'user', 19 | 'list': 'list', 20 | 'create': 'plus', 21 | 'modify': 'edit', 22 | 'read': 'book', 23 | 'delete': 'trash' 24 | } 25 | 26 | 27 | def lookup_permissions(token): 28 | """ 29 | look up the permissions string for the secret in the configuration. 30 | if no such secret is configured, return None 31 | """ 32 | return current_app.config['PERMISSIONS'].get(token) 33 | 34 | 35 | def get_permissions(): 36 | """ 37 | get the permissions for the current user (if logged in) 38 | or the default permissions (if not logged in). 39 | """ 40 | auth = request.authorization 41 | if auth: 42 | # http basic auth header present 43 | permissions = lookup_permissions(auth.password) 44 | elif 'token' in request.values: 45 | # token present in query args or post form (can be used by cli clients) 46 | permissions = lookup_permissions(request.values['token']) 47 | else: 48 | # look into session, login might have put something there 49 | permissions = session.get(PERMISSIONS) 50 | if permissions is None: 51 | permissions = current_app.config['DEFAULT_PERMISSIONS'] 52 | permissions = set(permissions.split(',')) 53 | return permissions 54 | 55 | 56 | def get_permission_icons(): 57 | return [ 58 | (permission, permission_icons[permission]) 59 | for permission in sorted(get_permissions()) if permission 60 | ] 61 | 62 | 63 | def may(permission): 64 | """ 65 | check whether the current user has the permission 66 | """ 67 | return permission in flaskg.permissions 68 | 69 | 70 | def logged_in(): 71 | """ 72 | is the user logged-in? 73 | """ 74 | return session.get(LOGGEDIN, False) 75 | -------------------------------------------------------------------------------- /src/bepasty/utils/upload.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import mimetypes 4 | from pygments.lexers import get_lexer_for_filename 5 | from pygments.util import ClassNotFound as NoPygmentsLexer 6 | from werkzeug.exceptions import BadRequest, RequestEntityTooLarge 7 | 8 | from flask import current_app 9 | 10 | try: 11 | import magic as magic_module 12 | magic = magic_module.Magic(mime=True) 13 | magic_bufsz = magic.getparam(magic_module.MAGIC_PARAM_BYTES_MAX) 14 | except ImportError: 15 | magic = None 16 | magic_bufsz = None 17 | 18 | from ..constants import ( 19 | COMPLETE, 20 | FILENAME, 21 | FOREVER, 22 | HASH, 23 | LOCKED, 24 | SIZE, 25 | TIMESTAMP_DOWNLOAD, 26 | TIMESTAMP_MAX_LIFE, 27 | TIMESTAMP_UPLOAD, 28 | TYPE, 29 | TYPE_HINT, 30 | internal_meta, 31 | ) 32 | from .name import ItemName 33 | from .decorators import threaded 34 | from .hashing import compute_hash, hash_new 35 | 36 | # we limit to 250 characters as we do not want to accept arbitrarily long 37 | # filenames. other than that, there is no specific reason we could not 38 | # also take more (or less). 39 | MAX_FILENAME_LENGTH = 250 40 | 41 | 42 | class Upload: 43 | _filename_re = re.compile(r'[^a-zA-Z0-9 *+:;.,_-]+') 44 | _type_re = re.compile(r'[^a-zA-Z0-9/+.-]+') 45 | 46 | @classmethod 47 | def filter_size(cls, i): 48 | """ 49 | Filter size. 50 | Check for advertised size. 51 | """ 52 | try: 53 | i = int(i) 54 | except (ValueError, TypeError): 55 | raise BadRequest(description='Size is invalid') 56 | if i > current_app.config['MAX_ALLOWED_FILE_SIZE']: 57 | raise RequestEntityTooLarge() 58 | return i 59 | 60 | @classmethod 61 | def filter_filename(cls, filename, storage_name, content_type, content_type_hint): 62 | """ 63 | Filter filename. 64 | Only allow some basic characters and shorten to 50 characters. 65 | """ 66 | # Make up filename if we don't have one 67 | if not filename: 68 | if not content_type: 69 | content_type = content_type_hint 70 | # note: stdlib mimetypes.guess_extension is total crap 71 | if content_type.startswith("text/"): 72 | ext = ".txt" 73 | else: 74 | ext = ".bin" 75 | filename = storage_name + ext 76 | return cls._filename_re.sub('', filename)[:MAX_FILENAME_LENGTH] 77 | 78 | @classmethod 79 | def filter_type(cls, ct, ct_hint, filename=None): 80 | """ 81 | Filter Content-Type 82 | Only allow some basic characters and shorten to 50 characters. 83 | 84 | Return value: 85 | tuple[0] - content-type string 86 | tuple[1] - whether tuple[0] is hint or not 87 | True: content-type is just a hint 88 | False: content-type is not a hint, was specified by user 89 | """ 90 | if not ct and filename: 91 | ct, encoding = mimetypes.guess_type(filename) 92 | 93 | if not ct: 94 | try: 95 | lexer = get_lexer_for_filename(filename) 96 | except NoPygmentsLexer: 97 | pass 98 | else: 99 | if len(lexer.mimetypes) > 0: 100 | ct = lexer.mimetypes[0] 101 | if not ct: 102 | return ct_hint, True 103 | return cls._type_re.sub('', ct)[:50], False 104 | 105 | @classmethod 106 | def meta_new(cls, item, input_size, input_filename, input_type, 107 | input_type_hint, storage_name, maxlife_stamp=FOREVER): 108 | item.meta[FILENAME] = cls.filter_filename( 109 | input_filename, storage_name, input_type, input_type_hint 110 | ) 111 | item.meta[SIZE] = cls.filter_size(input_size) 112 | ct, hint = cls.filter_type(input_type, input_type_hint, input_filename) 113 | item.meta[TYPE] = ct 114 | item.meta[TYPE_HINT] = hint 115 | item.meta[TIMESTAMP_UPLOAD] = int(time.time()) 116 | item.meta[TIMESTAMP_DOWNLOAD] = 0 117 | item.meta[LOCKED] = current_app.config['UPLOAD_LOCKED'] 118 | item.meta[COMPLETE] = False 119 | item.meta[HASH] = '' 120 | item.meta[TIMESTAMP_MAX_LIFE] = maxlife_stamp 121 | 122 | @classmethod 123 | def meta_complete(cls, item, file_hash): 124 | # update TYPE by python-magic if not decided yet 125 | if item.meta.pop(TYPE_HINT, False): 126 | if magic and current_app.config.get('USE_PYTHON_MAGIC', False): 127 | if item.meta[TYPE] == 'application/octet-stream': 128 | item.meta[TYPE] = magic.from_buffer(item.data.read(magic_bufsz, 0)) 129 | item.meta[COMPLETE] = True 130 | item.meta[HASH] = file_hash 131 | 132 | @staticmethod 133 | def data(item, f, size_input, offset=0): 134 | """ 135 | Copy data from temp file into storage. 136 | """ 137 | read_length = 16 * 1024 138 | size_written = 0 139 | hasher = hash_new() 140 | 141 | while True: 142 | read_length = min(read_length, size_input) 143 | if size_input == 0: 144 | break 145 | 146 | buf = f.read(read_length) 147 | if not buf: 148 | # Should not happen, we already checked the size 149 | raise RuntimeError 150 | 151 | item.data.write(buf, offset + size_written) 152 | hasher.update(buf) 153 | 154 | len_buf = len(buf) 155 | size_written += len_buf 156 | size_input -= len_buf 157 | 158 | return size_written, hasher.hexdigest() 159 | 160 | 161 | def create_item(f, filename, size, content_type, content_type_hint, 162 | maxlife_stamp=FOREVER): 163 | """ 164 | create an item from open file with the given metadata, return the item name. 165 | """ 166 | name = ItemName.create(current_app.storage) 167 | with current_app.storage.create(name, size) as item: 168 | size_written, file_hash = Upload.data(item, f, size) 169 | Upload.meta_new(item, size, filename, content_type, content_type_hint, 170 | name, maxlife_stamp=maxlife_stamp) 171 | Upload.meta_complete(item, file_hash) 172 | return name 173 | 174 | 175 | def filter_internal(meta): 176 | """ 177 | filter internal meta data out. 178 | """ 179 | return {k: v for k, v in meta.items() if k not in internal_meta} 180 | 181 | 182 | @threaded 183 | def background_compute_hash(storage, name): 184 | with storage.openwrite(name) as item: 185 | size = item.meta[SIZE] 186 | file_hash = compute_hash(item.data, size) 187 | item.meta[HASH] = file_hash 188 | -------------------------------------------------------------------------------- /src/bepasty/views/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | from .delete import DeleteView 4 | from .display import DisplayView, CarouselView 5 | from .download import DownloadView, InlineView, ThumbnailView 6 | from .modify import ModifyView 7 | from .qr import QRView 8 | from .filelist import FileListView 9 | from .index import index 10 | from .login import LoginView, LogoutView 11 | from .setkv import LockView, UnlockView 12 | from .upload import UploadAbortView, UploadContinueView, UploadNewView, UploadView 13 | from .xstatic import xstatic 14 | 15 | 16 | blueprint = Blueprint('bepasty', __name__) 17 | 18 | blueprint.add_url_rule('/', view_func=index) 19 | blueprint.add_url_rule('/xstatic/', defaults=dict(filename=''), view_func=xstatic) 20 | blueprint.add_url_rule('/xstatic//', view_func=xstatic) 21 | blueprint.add_url_rule('/+list', view_func=FileListView.as_view('filelist')) 22 | blueprint.add_url_rule('/+login', view_func=LoginView.as_view('login')) 23 | blueprint.add_url_rule('/+logout', view_func=LogoutView.as_view('logout')) 24 | blueprint.add_url_rule('/', view_func=DisplayView.as_view('display')) 25 | blueprint.add_url_rule('//+carousel', view_func=CarouselView.as_view('carousel')) 26 | blueprint.add_url_rule('//+delete', view_func=DeleteView.as_view('delete')) 27 | blueprint.add_url_rule('//+download', view_func=DownloadView.as_view('download')) 28 | blueprint.add_url_rule('//+inline', view_func=InlineView.as_view('inline')) 29 | blueprint.add_url_rule('//+thumbnail', view_func=ThumbnailView.as_view('thumbnail')) 30 | blueprint.add_url_rule('//+modify', view_func=ModifyView.as_view('modify')) 31 | blueprint.add_url_rule('//+qr', view_func=QRView.as_view('qr')) 32 | blueprint.add_url_rule('//+lock', view_func=LockView.as_view('lock')) 33 | blueprint.add_url_rule('//+unlock', view_func=UnlockView.as_view('unlock')) 34 | blueprint.add_url_rule('/+upload', view_func=UploadView.as_view('upload')) 35 | blueprint.add_url_rule('/+upload/new', view_func=UploadNewView.as_view('upload_new')) 36 | blueprint.add_url_rule('/+upload/', view_func=UploadContinueView.as_view('upload_continue')) 37 | blueprint.add_url_rule('/+upload//abort', view_func=UploadAbortView.as_view('upload_abort')) 38 | -------------------------------------------------------------------------------- /src/bepasty/views/delete.py: -------------------------------------------------------------------------------- 1 | import errno 2 | 3 | from flask import current_app, render_template 4 | from flask.views import MethodView 5 | from werkzeug.exceptions import NotFound, Forbidden 6 | 7 | from ..constants import COMPLETE, FILENAME, LOCKED 8 | from ..utils.http import redirect_next_referrer 9 | from ..utils.permissions import ADMIN, DELETE, may 10 | 11 | 12 | class DeleteView(MethodView): 13 | def error(self, item, error): 14 | return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 15 | 16 | def response(self, name): 17 | return redirect_next_referrer('bepasty.index') 18 | 19 | def post(self, name): 20 | if not may(DELETE): 21 | raise Forbidden() 22 | try: 23 | with current_app.storage.open(name) as item: 24 | if not item.meta[COMPLETE] and not may(ADMIN): 25 | error = 'Upload incomplete. Try again later.' 26 | self.error(item, error) 27 | 28 | if item.meta[LOCKED] and not may(ADMIN): 29 | raise Forbidden() 30 | 31 | current_app.storage.remove(name) 32 | 33 | except OSError as e: 34 | if e.errno == errno.ENOENT: 35 | raise NotFound() 36 | raise 37 | 38 | return self.response(name) 39 | -------------------------------------------------------------------------------- /src/bepasty/views/display.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import time 3 | 4 | from flask import current_app, render_template, url_for, request 5 | from flask.views import MethodView 6 | from markupsafe import Markup 7 | from werkzeug.exceptions import NotFound, Forbidden 8 | from pygments import highlight 9 | from pygments.lexers import get_lexer_for_mimetype 10 | from pygments.util import ClassNotFound as NoPygmentsLexer 11 | 12 | from ..constants import COMPLETE, FILENAME, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TYPE 13 | from ..utils.date_funcs import delete_if_lifetime_over 14 | from ..utils.formatters import CustomHtmlFormatter 15 | from ..utils.permissions import ADMIN, READ, may 16 | 17 | from .index import contenttypes_list 18 | from .filelist import file_infos 19 | 20 | 21 | def rendering_allowed(item_type, item_size, use_pygments, complete): 22 | """ 23 | check if rendering is allowed, checks for: 24 | 25 | * whether the item is completely uploaded 26 | * whether the size is within the configured limits for the content-type 27 | """ 28 | if not complete: 29 | return False 30 | if use_pygments: 31 | # if we use pygments, special restrictions apply 32 | item_type = 'HIGHLIGHT_TYPES' 33 | # create a tuple list [(content_type_prefix, max_size), ...] with long prefixes first 34 | ct_size = sorted(current_app.config['MAX_RENDER_SIZE'].items(), key=lambda e: len(e[0]), reverse=True) 35 | for ct, size in ct_size: 36 | if item_type.startswith(ct): 37 | return item_size <= size 38 | # there should be one entry with ct == '', so we should never get here: 39 | return False 40 | 41 | 42 | class DisplayView(MethodView): 43 | def get(self, name, view='normal'): 44 | if not may(READ): 45 | raise Forbidden() 46 | try: 47 | item = current_app.storage.openwrite(name) 48 | except OSError as e: 49 | if e.errno == errno.ENOENT: 50 | raise NotFound() 51 | raise 52 | 53 | with item as item: 54 | complete = item.meta[COMPLETE] 55 | if not complete and not may(ADMIN): 56 | error = 'Upload incomplete. Try again later.' 57 | return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 58 | 59 | if item.meta[LOCKED] and not may(ADMIN): 60 | raise Forbidden() 61 | 62 | if delete_if_lifetime_over(item, name): 63 | raise NotFound() 64 | 65 | def read_data(item): 66 | # reading the item for rendering is registered like a download 67 | data = item.data.read(item.data.size, 0) 68 | item.meta[TIMESTAMP_DOWNLOAD] = int(time.time()) 69 | return data 70 | 71 | size = item.meta[SIZE] 72 | ct = item.meta[TYPE] 73 | try: 74 | get_lexer_for_mimetype(ct) 75 | use_pygments = True 76 | ct_pygments = ct 77 | except NoPygmentsLexer: 78 | if ct.startswith('text/'): 79 | # seems like we found a text type not supported by pygments 80 | # use text/plain so we get a display with line numbers 81 | use_pygments = True 82 | ct_pygments = 'text/plain' 83 | else: 84 | use_pygments = False 85 | 86 | is_list_item = False 87 | if rendering_allowed(ct, size, use_pygments, complete): 88 | if ct.startswith('text/x-bepasty-'): 89 | # special bepasty items - must be first, don't feed to pygments 90 | if ct == 'text/x-bepasty-list': 91 | is_list_item = True 92 | names = read_data(item).decode('utf-8').splitlines() 93 | files = sorted(file_infos(names), key=lambda f: f[FILENAME]) 94 | if view == 'normal': 95 | rendered_content = Markup(render_template('filelist_tableonly.html', files=files)) 96 | elif view == 'carousel': 97 | # this template renders to a complete html page 98 | # we only consider image items for this 99 | files = [f for f in files if f[TYPE].startswith('image/')] 100 | return render_template('carousel.html', files=files) 101 | else: 102 | raise NotImplementedError 103 | elif ct == 'text/x-bepasty-redirect': 104 | url = read_data(item).decode('utf-8') 105 | delay = request.args.get('delay', '3') 106 | return render_template('redirect.html', url=url, delay=delay) 107 | else: 108 | rendered_content = "Can't render this content type." 109 | elif ct.startswith('image/'): 110 | src = url_for('bepasty.download', name=name) 111 | rendered_content = Markup('the image' % src) 112 | elif ct.startswith('audio/'): 113 | src = url_for('bepasty.download', name=name) 114 | alt_msg = 'html5 audio element not supported by your browser.' 115 | rendered_content = Markup(f'') 116 | elif ct.startswith('video/'): 117 | src = url_for('bepasty.download', name=name) 118 | alt_msg = 'html5 video element not supported by your browser.' 119 | rendered_content = Markup(f'') 120 | elif ct in ['application/pdf', 'application/x-pdf', ]: 121 | src = url_for('bepasty.inline', name=name) 122 | link_txt = 'Click to see PDF' 123 | rendered_content = Markup(f'{link_txt}') 124 | elif ct == 'application/x-asciinema-recording': 125 | src = url_for('bepasty.download', name=name) 126 | rendered_content = Markup('' % 127 | (src, item.meta[FILENAME], 128 | current_app.config.get('ASCIINEMA_THEME', 'asciinema'))) 129 | elif use_pygments: 130 | text = read_data(item) 131 | # TODO we don't have the coding in metadata 132 | try: 133 | text = text.decode('utf-8') 134 | except UnicodeDecodeError: 135 | # well, it is not utf-8 or ascii, so we can only guess... 136 | text = text.decode('iso-8859-1') 137 | lexer = get_lexer_for_mimetype(ct_pygments) 138 | formatter = CustomHtmlFormatter(linenos='table', lineanchors="L", 139 | lineparagraphs="L", anchorlinenos=True) 140 | rendered_content = Markup(highlight(text, lexer, formatter)) 141 | else: 142 | rendered_content = "Can't render this content type." 143 | else: 144 | if not complete: 145 | rendered_content = "Rendering not allowed (not complete). Is it still being uploaded?" 146 | else: 147 | rendered_content = "Rendering not allowed (too big?). Try download" 148 | 149 | return render_template('display.html', name=name, item=item, 150 | rendered_content=rendered_content, 151 | contenttypes=contenttypes_list(), 152 | is_list_item=is_list_item) 153 | 154 | 155 | class CarouselView(DisplayView): 156 | def get(self, name): 157 | return super().get(name, view='carousel') 158 | -------------------------------------------------------------------------------- /src/bepasty/views/download.py: -------------------------------------------------------------------------------- 1 | import errno 2 | from io import BytesIO 3 | import os 4 | import time 5 | 6 | try: 7 | import PIL 8 | except ImportError: 9 | # Pillow / PIL is optional 10 | PIL = None 11 | else: 12 | from PIL import Image 13 | 14 | from flask import Response, current_app, render_template, stream_with_context 15 | from flask.views import MethodView 16 | from werkzeug.exceptions import NotFound, Forbidden 17 | 18 | from ..constants import COMPLETE, FILENAME, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TYPE 19 | from ..utils.date_funcs import delete_if_lifetime_over 20 | from ..utils.permissions import ADMIN, READ, may 21 | 22 | 23 | class DownloadView(MethodView): 24 | content_disposition = 'attachment' # to trigger download 25 | 26 | def err_incomplete(self, item, error): 27 | return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 28 | 29 | def stream(self, item, start, limit): 30 | with item as _item: 31 | # Stream content from storage 32 | offset = max(0, start) 33 | while offset < limit: 34 | buf = _item.data.read(min(limit - offset, 16 * 1024), offset) 35 | offset += len(buf) 36 | yield buf 37 | item.meta[TIMESTAMP_DOWNLOAD] = int(time.time()) 38 | 39 | def response(self, item, name): 40 | ct = item.meta[TYPE] 41 | dispo = self.content_disposition 42 | if dispo != 'attachment': 43 | # no simple download, so we must be careful about XSS 44 | if ct.startswith("text/"): 45 | ct = 'text/plain' # only send simple plain text 46 | 47 | ret = Response(stream_with_context(self.stream(item, 0, item.data.size))) 48 | ret.headers['Content-Disposition'] = '{}; filename="{}"'.format( 49 | dispo, item.meta[FILENAME]) 50 | ret.headers['Content-Length'] = item.meta[SIZE] 51 | ret.headers['Content-Type'] = ct 52 | ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it 53 | return ret 54 | 55 | def get(self, name): 56 | if not may(READ): 57 | raise Forbidden() 58 | try: 59 | item = current_app.storage.openwrite(name) 60 | except OSError as e: 61 | if e.errno == errno.ENOENT: 62 | raise NotFound() 63 | raise 64 | 65 | try: 66 | need_close = True 67 | if not item.meta[COMPLETE]: 68 | return self.err_incomplete(item, 'Upload incomplete. Try again later.') 69 | 70 | if item.meta[LOCKED] and not may(ADMIN): 71 | raise Forbidden() 72 | 73 | if delete_if_lifetime_over(item, name): 74 | raise NotFound() 75 | need_close = False 76 | finally: 77 | if need_close: 78 | item.close() 79 | 80 | return self.response(item, name) 81 | 82 | 83 | class InlineView(DownloadView): 84 | content_disposition = 'inline' # to trigger viewing in browser, for some types 85 | 86 | 87 | class ThumbnailView(InlineView): 88 | thumbnail_size = 192, 108 89 | thumbnail_data = """\ 90 | 91 | 92 | 93 | 94 | 95 | """.strip().encode() 96 | 97 | def err_incomplete(self, item, error): 98 | return b'', 409 # conflict 99 | 100 | def response(self, item, name): 101 | sz = item.meta[SIZE] 102 | fn = item.meta[FILENAME] 103 | ct = item.meta[TYPE] 104 | unsupported = PIL is None or ct not in {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} 105 | if unsupported: 106 | # return a placeholder thumbnail for unsupported item types 107 | ret = Response(self.thumbnail_data) 108 | ret.headers['Content-Length'] = len(self.thumbnail_data) 109 | ret.headers['Content-Type'] = 'image/svg+xml' 110 | ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it 111 | return ret 112 | 113 | if ct in ('image/jpeg', ): 114 | thumbnail_type = 'jpeg' 115 | elif ct in ('image/png', 'image/gif'): 116 | thumbnail_type = 'png' 117 | elif ct in ('image/webp', ): 118 | thumbnail_type = 'webp' 119 | else: 120 | raise ValueError('unrecognized image content type') 121 | 122 | # compute thumbnail data "on the fly" 123 | with BytesIO(item.data.read(sz, 0)) as img_bio, BytesIO() as thumbnail_bio: 124 | with Image.open(img_bio) as img: 125 | img.thumbnail(self.thumbnail_size) 126 | img.save(thumbnail_bio, thumbnail_type) 127 | thumbnail_data = thumbnail_bio.getvalue() 128 | 129 | name, ext = os.path.splitext(fn) 130 | thumbnail_fn = '{}-thumb.{}'.format(name, thumbnail_type) 131 | 132 | ret = Response(thumbnail_data) 133 | ret.headers['Content-Disposition'] = '{}; filename="{}"'.format( 134 | self.content_disposition, thumbnail_fn) 135 | ret.headers['Content-Length'] = len(thumbnail_data) 136 | ret.headers['Content-Type'] = 'image/%s' % thumbnail_type 137 | ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it 138 | return ret 139 | -------------------------------------------------------------------------------- /src/bepasty/views/filelist.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import pickle 3 | 4 | from flask import current_app, render_template 5 | from flask.views import MethodView 6 | from werkzeug.exceptions import Forbidden 7 | 8 | from ..constants import ID, TIMESTAMP_UPLOAD 9 | from ..utils.date_funcs import delete_if_lifetime_over 10 | from ..utils.permissions import LIST, may 11 | 12 | 13 | def file_infos(names=None): 14 | """ 15 | iterates over storage files metadata. 16 | note: we put the storage name into the metadata as ID 17 | 18 | :param names: None means "all items" 19 | otherwise give a list of storage item names 20 | """ 21 | storage = current_app.storage 22 | if names is None: 23 | names = list(storage) 24 | for name in names: 25 | try: 26 | with storage.open(name) as item: 27 | meta = dict(item.meta) 28 | if not meta: 29 | # we got empty metadata, this happens for 0-byte .meta files. 30 | # ignore it for now. 31 | continue 32 | if delete_if_lifetime_over(item, name): 33 | continue 34 | meta[ID] = name 35 | yield meta 36 | except OSError as e: 37 | if e.errno != errno.ENOENT: 38 | raise 39 | except pickle.UnpicklingError: 40 | # corrupted meta file, just ignore it for now 41 | pass 42 | 43 | 44 | class FileListView(MethodView): 45 | def get(self): 46 | if not may(LIST): 47 | raise Forbidden() 48 | files = sorted(file_infos(), key=lambda f: f[TIMESTAMP_UPLOAD], reverse=True) 49 | return render_template('filelist.html', files=files) 50 | -------------------------------------------------------------------------------- /src/bepasty/views/index.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | 3 | from pygments.lexers import get_all_lexers 4 | 5 | 6 | def contenttypes_list(): 7 | contenttypes = [ 8 | 'text/x-bepasty-redirect', # redirect / link shortener service 9 | ] 10 | for lexer_info in get_all_lexers(): 11 | contenttypes.extend(lexer_info[3]) 12 | return contenttypes 13 | 14 | 15 | def index(): 16 | return render_template('index.html', contenttypes=contenttypes_list()) 17 | -------------------------------------------------------------------------------- /src/bepasty/views/login.py: -------------------------------------------------------------------------------- 1 | from flask import request, session 2 | from flask.views import MethodView 3 | 4 | from ..utils.http import redirect_next_referrer 5 | from ..utils.permissions import LOGGEDIN, PERMISSIONS, lookup_permissions 6 | 7 | 8 | class LoginView(MethodView): 9 | def post(self): 10 | token = request.form.get('token') 11 | if token is not None: 12 | permissions_for_token = lookup_permissions(token) 13 | if permissions_for_token is not None: 14 | session[PERMISSIONS] = permissions_for_token 15 | session[LOGGEDIN] = True 16 | return redirect_next_referrer('bepasty.index') 17 | 18 | 19 | class LogoutView(MethodView): 20 | def post(self): 21 | # note: remove all session entries that are not needed for logged-out 22 | # state (because the code has defaults for them if they are missing). 23 | # if the session is empty. flask will automatically remove the cookie. 24 | session.pop(LOGGEDIN, None) 25 | session.pop(PERMISSIONS, None) 26 | return redirect_next_referrer('bepasty.index') 27 | -------------------------------------------------------------------------------- /src/bepasty/views/modify.py: -------------------------------------------------------------------------------- 1 | import errno 2 | 3 | from flask import current_app, request, render_template 4 | from flask.views import MethodView 5 | from werkzeug.exceptions import Forbidden, NotFound 6 | 7 | from ..constants import COMPLETE, FILENAME, LOCKED, TYPE 8 | from ..utils.date_funcs import delete_if_lifetime_over 9 | from ..utils.http import redirect_next_referrer 10 | from ..utils.permissions import ADMIN, CREATE, may 11 | from ..utils.upload import Upload 12 | 13 | 14 | class ModifyView(MethodView): 15 | def error(self, item, error): 16 | return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 17 | 18 | def response(self, name): 19 | return redirect_next_referrer('bepasty.display', name=name) 20 | 21 | def get_params(self): 22 | return { 23 | FILENAME: request.form.get('filename'), 24 | TYPE: request.form.get('contenttype'), 25 | } 26 | 27 | def post(self, name): 28 | if not may(CREATE): 29 | raise Forbidden() 30 | 31 | try: 32 | with current_app.storage.openwrite(name) as item: 33 | if not item.meta[COMPLETE] and not may(ADMIN): 34 | error = 'Upload incomplete. Try again later.' 35 | return self.error(item, error) 36 | 37 | if item.meta[LOCKED] and not may(ADMIN): 38 | raise Forbidden() 39 | 40 | if delete_if_lifetime_over(item, name): 41 | raise NotFound() 42 | 43 | params = self.get_params() 44 | if params[FILENAME]: 45 | item.meta[FILENAME] = Upload.filter_filename( 46 | params[FILENAME], name, params[TYPE], item.meta[TYPE] 47 | ) 48 | if params[TYPE]: 49 | item.meta[TYPE], _ = Upload.filter_type( 50 | params[TYPE], item.meta[TYPE] 51 | ) 52 | 53 | return self.response(name) 54 | 55 | except OSError as e: 56 | if e.errno == errno.ENOENT: 57 | raise NotFound() 58 | raise 59 | -------------------------------------------------------------------------------- /src/bepasty/views/qr.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, url_for 2 | from flask.views import MethodView 3 | 4 | 5 | class QRView(MethodView): 6 | def get(self, name): 7 | target = url_for('bepasty.display', name=name, _external=True) 8 | return render_template('qr.html', text=target) 9 | -------------------------------------------------------------------------------- /src/bepasty/views/setkv.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set metadata keys to specific values 3 | """ 4 | 5 | import errno 6 | 7 | from flask import current_app, render_template 8 | from flask.views import MethodView 9 | from werkzeug.exceptions import NotFound, Forbidden 10 | 11 | from ..constants import COMPLETE, FILENAME, LOCKED 12 | from ..utils.http import redirect_next_referrer 13 | from ..utils.permissions import ADMIN, may 14 | 15 | 16 | class SetKeyValueView(MethodView): 17 | # overwrite these in subclasses: 18 | REQUIRED_PERMISSION = None 19 | KEY = None 20 | NEXT_VALUE = None 21 | 22 | def error(self, item, error): 23 | return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 24 | 25 | def response(self, name): 26 | return redirect_next_referrer('bepasty.display', name=name) 27 | 28 | def post(self, name): 29 | if self.REQUIRED_PERMISSION is not None and not may(self.REQUIRED_PERMISSION): 30 | raise Forbidden() 31 | try: 32 | with current_app.storage.openwrite(name) as item: 33 | if item.meta[self.KEY] == self.NEXT_VALUE: 34 | error = f'{self.KEY} already is {self.NEXT_VALUE!r}.' 35 | elif not item.meta[COMPLETE]: 36 | error = 'Upload incomplete. Try again later.' 37 | else: 38 | error = None 39 | if error: 40 | return self.error(item, error) 41 | item.meta[self.KEY] = self.NEXT_VALUE 42 | return self.response(name) 43 | 44 | except OSError as e: 45 | if e.errno == errno.ENOENT: 46 | raise NotFound() 47 | raise 48 | 49 | 50 | class LockView(SetKeyValueView): 51 | REQUIRED_PERMISSION = ADMIN 52 | KEY = LOCKED 53 | NEXT_VALUE = True 54 | 55 | 56 | class UnlockView(SetKeyValueView): 57 | REQUIRED_PERMISSION = ADMIN 58 | KEY = LOCKED 59 | NEXT_VALUE = False 60 | -------------------------------------------------------------------------------- /src/bepasty/views/upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import errno 3 | from io import BytesIO 4 | import time 5 | import urllib 6 | 7 | from flask import abort, current_app, jsonify, request, url_for 8 | from flask.views import MethodView 9 | from werkzeug.exceptions import NotFound, Forbidden 10 | 11 | from ..constants import COMPLETE, FILENAME, SIZE 12 | from ..utils.date_funcs import get_maxlife 13 | from ..utils.http import ContentRange, redirect_next 14 | from ..utils.name import ItemName 15 | from ..utils.permissions import CREATE, may 16 | from ..utils.upload import Upload, create_item, background_compute_hash 17 | 18 | 19 | class UploadView(MethodView): 20 | def post(self): 21 | if not may(CREATE): 22 | raise Forbidden() 23 | f = request.files.get('file') 24 | t = request.form.get('text') 25 | # note: "and f.filename" is needed due to missing __bool__ method in 26 | # werkzeug.datastructures.FileStorage, to work around it crashing 27 | # on Python 3.x. 28 | if f and f.filename: 29 | # Check Content-Range, disallow its usage 30 | if ContentRange.from_request(): 31 | abort(416) 32 | 33 | # Check Content-Type, default to application/octet-stream 34 | content_type = ( 35 | f.headers.get('Content-Type') or 36 | request.headers.get('Content-Type')) 37 | content_type_hint = 'application/octet-stream' 38 | filename = f.filename 39 | 40 | # Get size of temporary file 41 | f.seek(0, os.SEEK_END) 42 | size = f.tell() 43 | f.seek(0) 44 | elif t is not None: 45 | # t is already unicode, but we want utf-8 for storage 46 | t = t.encode('utf-8') 47 | content_type = request.form.get('contenttype') # TODO: add coding 48 | content_type_hint = 'text/plain' 49 | size = len(t) 50 | f = BytesIO(t) 51 | filename = request.form.get('filename') 52 | else: 53 | raise NotImplementedError 54 | # set max lifetime 55 | maxtime = get_maxlife(request.form, underscore=False) 56 | maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime 57 | name = create_item(f, filename, size, content_type, content_type_hint, maxlife_stamp=maxlife_timestamp) 58 | kw = {} 59 | kw['_anchor'] = urllib.parse.quote(filename) 60 | if content_type == 'text/x-bepasty-redirect': 61 | # after creating a redirect, we want to stay on the bepasty 62 | # redirect display, so the user can copy the URL. 63 | kw['delay'] = '9999' 64 | return redirect_next('bepasty.display', name=name, **kw) 65 | 66 | 67 | class UploadNewView(MethodView): 68 | def post(self): 69 | if not may(CREATE): 70 | raise Forbidden() 71 | 72 | data = request.get_json() 73 | 74 | data_filename = data['filename'] 75 | data_size = int(data['size']) 76 | data_type = data['type'] 77 | 78 | # set max lifetime 79 | maxtime = get_maxlife(data, underscore=True) 80 | maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime 81 | 82 | name = ItemName.create(current_app.storage) 83 | with current_app.storage.create(name, data_size) as item: 84 | # Save meta-data 85 | Upload.meta_new(item, data_size, data_filename, data_type, 86 | 'application/octet-stream', name, maxlife_stamp=maxlife_timestamp) 87 | 88 | return jsonify({'url': url_for('bepasty.upload_continue', name=name), 89 | 'name': name}) 90 | 91 | 92 | class UploadContinueView(MethodView): 93 | def post(self, name): 94 | if not may(CREATE): 95 | raise Forbidden() 96 | 97 | f = request.files['file'] 98 | if not f: 99 | raise NotImplementedError 100 | 101 | # Check Content-Range 102 | content_range = ContentRange.from_request() 103 | 104 | with current_app.storage.openwrite(name) as item: 105 | if content_range: 106 | # note: we ignore the hash as it is only for 1 chunk, not for the whole upload. 107 | # also, we can not continue computing the hash as we can't save the internal 108 | # state of the hash object 109 | size_written, _ = Upload.data(item, f, content_range.size, content_range.begin) 110 | file_hash = '' 111 | is_complete = content_range.is_complete 112 | 113 | else: 114 | # Get size of temporary file 115 | f.seek(0, os.SEEK_END) 116 | size = f.tell() 117 | f.seek(0) 118 | 119 | size_written, file_hash = Upload.data(item, f, size) 120 | is_complete = True 121 | 122 | if is_complete: 123 | Upload.meta_complete(item, file_hash) 124 | 125 | result = jsonify({'files': [{ 126 | 'name': name, 127 | 'filename': item.meta[FILENAME], 128 | 'size': item.meta[SIZE], 129 | 'url': "{}#{}".format(url_for('bepasty.display', name=name), item.meta[FILENAME]), 130 | }]}) 131 | 132 | if is_complete and not file_hash: 133 | background_compute_hash(current_app.storage, name) 134 | 135 | return result 136 | 137 | 138 | class UploadAbortView(MethodView): 139 | def get(self, name): 140 | if not may(CREATE): 141 | raise Forbidden() 142 | 143 | try: 144 | item = current_app.storage.open(name) 145 | except OSError as e: 146 | if e.errno == errno.ENOENT: 147 | return 'No file found.', 404 148 | raise 149 | 150 | if item.meta[COMPLETE]: 151 | error = 'Upload complete. Cannot delete fileupload garbage.' 152 | else: 153 | error = None 154 | if error: 155 | return error, 409 156 | 157 | try: 158 | item = current_app.storage.remove(name) 159 | except OSError as e: 160 | if e.errno == errno.ENOENT: 161 | raise NotFound() 162 | raise 163 | return 'Upload aborted' 164 | -------------------------------------------------------------------------------- /src/bepasty/views/xstatic.py: -------------------------------------------------------------------------------- 1 | from flask import send_from_directory 2 | from werkzeug.exceptions import NotFound 3 | 4 | from ..bepasty_xstatic import serve_files 5 | 6 | 7 | def xstatic(name, filename): 8 | """Route to serve the xstatic files (from serve_files)""" 9 | try: 10 | base_path = serve_files[name] 11 | except KeyError: 12 | raise NotFound() 13 | 14 | if not filename: 15 | raise NotFound() 16 | 17 | return send_from_directory(base_path, filename) 18 | -------------------------------------------------------------------------------- /src/bepasty/wsgi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from .app import create_app 4 | 5 | application = create_app() 6 | 7 | if __name__ == '__main__': 8 | application.run(debug=True) 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{310,311,312,313},flake8 3 | 4 | [testenv] 5 | deps = -r{toxinidir}/requirements.d/dev.txt 6 | setenv = PYTHONPATH = {toxinidir} 7 | # as we do not start the server that serves the selenium tests using the misc. 8 | # python versions configured above (but manually with 1 specific python 9 | # version), there is no point in running these tests with tox: 10 | commands = pytest -m "not needs_server" --cov=bepasty --pyargs {posargs:bepasty.tests} 11 | 12 | [testenv:flake8] 13 | changedir = 14 | deps = 15 | flake8 16 | flake8-pyproject 17 | commands = flake8 src/ 18 | --------------------------------------------------------------------------------