././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4765358 bepasty-1.2.0/0000755000076500000240000000000000000000000011477 5ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4602842 bepasty-1.2.0/.github/0000755000076500000240000000000000000000000013037 5ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4631467 bepasty-1.2.0/.github/workflows/0000755000076500000240000000000000000000000015074 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693234595.0 bepasty-1.2.0/.github/workflows/ci.yml0000644000076500000240000000377500000000000016226 0ustar00twstaff# badge: https://github.com/bepasty/bepasty-server/workflows/CI/badge.svg?branch=master name: CI on: push: branches: [ master ] paths: - '**.py' - '**.yml' - '**.cfg' - '**.ini' - 'requirements.d/*' - '!docs/**' pull_request: branches: [ master ] paths: - '**.py' - '**.yml' - '**.cfg' - '**.ini' - 'requirements.d/*' - '!docs/**' jobs: lint: runs-on: ubuntu-22.04 timeout-minutes: 10 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.8 - name: Lint with flake8 run: | pip install flake8-pyproject flake8 flake8 src scripts pytest: needs: lint strategy: matrix: include: - os: ubuntu-22.04 python-version: '3.8' toxenv: py38 - os: ubuntu-22.04 python-version: '3.9' toxenv: py39 - os: ubuntu-22.04 python-version: '3.10' toxenv: py310 - os: ubuntu-22.04 python-version: '3.11' toxenv: py311 env: TOXENV: ${{ matrix.toxenv }} runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - uses: actions/checkout@v3 with: # just fetching 1 commit is not enough for setuptools-scm, so we fetch all fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Python requirements run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.d/dev.txt - name: run pytest via tox run: | tox --skip-missing-interpreters - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 env: OS: ${{ runner.os }} python: ${{ matrix.python-version }} with: env_vars: OS, python ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674424855.0 bepasty-1.2.0/.readthedocs.yml0000644000076500000240000000035200000000000014565 0ustar00twstaffversion: 2 build: os: "ubuntu-22.04" tools: python: "3.11" sphinx: configuration: docs/source/conf.py python: install: - method: pip path: . - requirements: requirements.d/rtd.txt system_packages: false ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/AUTHORS0000644000076500000240000000071500000000000012552 0ustar00twstaffBastian Blank Christian Fischer Thomas Waldmann Dennis Schmalacker Ana Balica Daniel Gonzalez Valentin Pratz Darko Ronic OGAWA Hirofumi Janne Heß (add your name/email above this line if you contributed to this project) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693234622.0 bepasty-1.2.0/CHANGES.rst0000644000076500000240000002427100000000000013307 0ustar00twstaffChangeLog ========= Release 1.2.0 (2023-08-28) -------------------------- Fixes: - use xstatic-fontawesome < 5, minimal workaround for #305 New features: - upload: guess file content type also using pygments, #303 Other changes: - drop support for python 3.7 - use pyproject.toml file, remove setup.* - fix readthedocs build, #298 - misc code cleanups related to deprecations in flask and werkzeug - adapt http response code in test for werkzeug >= 2.3 - docs: REST POST response returns file URI in Content-Location - CI: run tests on Ubuntu 22.04 Release 1.1.0 (2023-01-22) -------------------------- Fixes: - adapt to Pygments >= 2.12.0 (and also require it), #281 - adapt to recent Flask/Werkzeug releases New features: - add a carousel view for lists of images, #148 - list item view: add thumbnail columns (needs "Pillow" extra) - add a thumbnail view that dynamically computes thumbnails for image items (for unsupported item types, return a placeholder svg thumbnail) Other changes: - remove Python 3.5 and 3.6 support, add support for Python 3.10 and 3.11 - use setup.cfg for most metadata, #276 - github actions CI: fixes and updates - fix rest_server testing failures / adapt to recent Flask/Werkzeug - docs: how to authenticate with http basic auth / for the REST api, #273 - source: pyupgrade --py37-plus *.py Release 1.0.0 (2021-03-14) -------------------------- Compatibility: * require Python >= 3.5, drop Python 2.x support Fixes: * pygments compatibility fix, #258. don't crash on ``('JSONBareObject', (), (), ())``. Other changes: * move away from Travis-CI, use github workflow for CI Release 0.6.0 (2020-11-14) -------------------------- Compatibility: * drop python 3.4 support, #195 * note: this will likely be the last bepasty release supporting Python 2.x (2.7) and 3.5 (both are not supported by Python development any more). Fixes: * fix bad types for b64(en|de)code, #200 * use simple links in list/display view instead of
tags * security fix: if PERMISSIONS in config are changed, we invalidate old clientside cookies now. * fix creating empty file in storage/* * fix dealing with expired items * fix item.meta.get() in ItemDownloadView(), must be called with an argument. * fix typo in get_maxlife (MONTH => MONTHS). New features: * add support for asciinema recordings, #175 * show QR code with link to bepasty item, #176 * support text/x-bepasty-redirect for URL redirects: just paste the target URL and choose this as mimetype to create a redirect. you may use the delay= url argument to adjust the delay, default is 3s. * add "modify" operation to modify uploaded data (web UI and REST), this is controlled by "modify" permissions entry. * add optional python-magic support for application/octet-stream. Disabled by default, you can enable via: USE_PYTHON_MAGIC = True * REST api: - add delete/lock/unlock REST api - use json for error response of REST api - use application/json for upload REST api Other changes: * support / test on py38, py39, #223 * move development section from README to project docs, #192 * use twine to upload releases, qubes gpg support, #197 * add config for readthedocs, #191 * code: some cleanups, fix warnings, fix minor errors * theme: - upgrade to use bootstrap 4 - use font-awesome everywhere, remove font-glyphicon, #232 - use xstatic package for local font delivery - sort permission icons - misc. cosmetic fixes * robustness / consistency improvements: - handle bad Transaction-ID, bad Content-Length, bad Maxlife-{Value,Unit}, bad Range/Content-Range header - add exception handler for REST api to get consistent behaviour. * tests: - add test for APP_BASE_PATH (for our prefix middleware) - add screen shots test for UI - add REST api tests Release 0.5.0 (2018-10-15) -------------------------- Compatibility: * drop support for python 2.6 and 3.3 * add support for python 3.5, 3.6 and 3.7 * thus, you now need python 2.7 or python >= 3.4 * changes in source code layout: package bepasty is below src/ now * thus, you need to install bepasty now: pip install -e . * changed maxlife default from FOREVER to 1 MONTH. this avoids creating an ever-growing pastebin. users can still give other value if they like. Fixes: * REST api: fix off-by-one error in range calculations, #124 * config: reduce default body size by a 8kiB safety margin, #155 * multiple abort buttons for multiple file uploads, #29 * progress bar fixes, #131 * fix display of "undefined", should be "never", #129 * abort button now works w/ multiple files, #111 * upload form: don't linebreak in numbers, #122 * +list: work around 0-byte .meta files breaking the view, #147 New features: * run bepasty at non-root URLs, see APP_BASE_PATH in the config. * use icons instead of text for permissions (with hover-text) * REST api: GET /apis/rest/items returns the list of all items Other changes: * re-style upload form * add a favicon.ico (plus svg source) * docs updates * docs/config: clarify config updating, credentials/secrets, #151 * lots of cleanups for packaging, testing, source code * upgrade xstatic package requirements, #171 Release 0.4.0 (2014-11-11) -------------------------- New features: * shorter, easy-to-read URLs / filenames (old uuid4 style URLs still supported, but not generated any more for new items) * "list" permission separated from "admin" permission. - list: be able to list (discover) all pastebins - admin: be able to lock/unlock files, do actions even if upload is not completed or item is locked Make sure you update your PERMISSIONS configuration (you likely want to give "list" to the site administrator). By giving "list" (and/or "delete") permission to more users, you could operate your bepasty site in a rather public way (users seeing stuff from other users, maybe even being able to delete stuff they see). Fixes: * give configured limits to JS also, so stuff has not to be kept in sync manually, fixes #109 * highlighted text file views: set fixed width to line number column, fixes #108 * fixed crash for inline and download views when item was already deleted Other changes: * support Python 3.3+ additionally to 2.6+ * improved documentation, esp. about REST api * improve sample configs Release 0.3.0 (2014-08-22) -------------------------- New features: * support http basic auth header (it just reads the password from there, the user name is ignored). this is useful for scripting, e.g. you can do now: $ curl -F 'file=@somefile;type=text/plain' http://user:password@localhost:5000/+upload * you can give the filename for the list items now * do not use paste.txt as default filename, but .txt or .bin (this is less pretty, but avoids collisions if you download multiple files) * allow uploading of multiple files via the fileselector of the browser * display download (view) timestamp * sorting of file lists * use iso-8859-1 if decoding with utf-8 fails * let admin directly delete locked files, without having to unlock first * new bepasty-object cli command * added REST api for bepasty-client-cli * MAX_RENDER_SIZE can be used to set up maximum sizes for items of misc. types, so bepasty e.g. won't try to render a 1 GB text file with highlighting. * offer a "max. lifetime" when creating a pastebin * if you link to some specific text line, it will highlight that line now * add filename to the pastebin url (as anchor) Removed features: * removed ceph-storage implementation due to bugs, missing features and general lack of maintenance. it is still in the repo in branch ceph-storage, waiting to be merged back after these issues have been fixed: https://github.com/bepasty/bepasty-server/issues/13 https://github.com/bepasty/bepasty-server/issues/38 Fixes: * security fix: when showing potentially dangerous text/* types, force the content-type to be text/plain and also turn the browser's sniffer off. * security fix: prevent disclosure of locked item's metadata * use POST for delete/lock actions * application/x-pdf content-type items are offer for in-browser rendering, too * fix typo in cli command bepasty-object set --incomplete (not: uncomplete) * quite some UI / UX and other bug fixes * filesystem storage: check if the configured directory is actually writeable Other changes: * using xstatic packages now for all 3rd party static files * docs updated / enhanced No release 0.2.0 ---------------- We made quite quick progress due to many contributions from EuroPython 2014 sprint participants, so there was no 0.2.0 release and we directly jumped to 0.3.0. Release 0.1.0 (2014-06-29) -------------------------- New features: * add a textarea so one now actually can paste (not just upload) * simple login/logout and permissions system - see PERMISSIONS in config.py. * add lock/unlock functionality to web UI (admin) * add "List all items" on web UI (admin) * add link to online documentation * support inline viewing of PDFs * support Python 2.6 * after upload of multiple files, offer creation of list item * file uploads can be aborted (partially uploaded file will get deleted) * store upload timestamp into metadata * Compute hash of chunked uploads in a background thread directly after upload has finished. * new migrate cli subcommand to upgrade stored metadata (see --help for details) * new purge cli subcommand (see --help for details). you can use this to purge by age (since upload), inactivity (since last download), size or (mime)type. BEWARE: giving no criteria (like age, size, ...) means: purge all. Giving multiple criteria means they all must apply for files to get purged (AND - if you need OR, just run the command multiple times). * new consistency cli subcommand (see --help for details). you can check consistency of hash/size in metadata against what you have in storage. Optionally, you can compute hashes (if an empty hash was stored) or fix the metadata with the computed hash/size values. also, you can remove files with inconsistent hash / size. Fixes: * for chunked upload, a wrong hash was computed. Fixed. * misc. cosmetic UI fixes / misc. minor bug fixes * add project docs * use monospace font for textarea * now correctly positions to linenumber anchors Release 0.0.1 (2014-02-09) -------------------------- * first pypi release. release early, release often! :) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674420667.0 bepasty-1.2.0/LICENSE0000644000076500000240000000247100000000000012510 0ustar00twstaffCopyright (c) 2014-2023 by the Bepasty Team, see the AUTHORS file. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/MANIFEST.in0000644000076500000240000000036400000000000013240 0ustar00twstaff# stuff we need to include into the sdist is handled automatically by # setuptools_scm - it includes all git-committed files. # but we want to exclude some committed files/dirs not needed in the sdist: exclude .gitattributes .gitignore .github ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4763694 bepasty-1.2.0/PKG-INFO0000644000076500000240000000657300000000000012607 0ustar00twstaffMetadata-Version: 2.1 Name: bepasty Version: 1.2.0 Summary: a binary pastebin / file upload service Author: The Bepasty Team (see AUTHORS file) Maintainer-email: Thomas Waldmann License: BSD 2-clause Project-URL: Homepage, https://github.com/bepasty/bepasty-server/ Project-URL: Documentation, https://bepasty-server.readthedocs.org/ Project-URL: Changelog, https://github.com/bepasty/bepasty-server/blob/master/CHANGES.rst Keywords: text,image,audio,video,binary,pastebin,upload,download,service,wsgi,flask Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Framework :: Flask Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Requires-Python: >=3.8 Description-Content-Type: text/x-rst Provides-Extra: magic Provides-Extra: pillow License-File: LICENSE License-File: AUTHORS bepasty ======= bepasty is like a pastebin for all kinds of files (text, image, audio, video, documents, ..., binary). The documentation is there: https://bepasty-server.readthedocs.org/en/latest/ Features -------- * Generic: - you can upload multiple files at once, simply by drag and drop - after upload, you get a unique link to a view of each file - on that view, we show actions you can do with the file, metadata of the file and, if possible, we also render the file contents - if you uploaded multiple files, you can create a pastebin with the list of all these files - with a single click! - Set an expiration date for your files * Text files: - we highlight all text file types supported by pygments (a lot!) - we display line numbers - we link from line numbers to their anchors, so you can easily get a link to a specific line * Image files: - we show the image (format support depends on browser) - for image list items, we can show a slide show ("carousel" view) - in the items list, a thumbnail of images is shown * Audio and video files: - we show the html5 player for it (format support depends on browser) * asciinema recordings: - we show the asciinema player for .cast files * URLs: - we support linking to / redirecting to external URLs, you can use this as a link shortener (avoiding privacy / data protection issues that may exist with other link shorteners) * PDFs: - we support rendering PDFs in your browser (if your browser is able to) * Storage: we use a storage backend api, currently we have backends for: - filesystem storage (just use a filesystem directory to store .meta and .data files) - currently there are no other storage implementations in master branch and releases. The "ceph cluster" storage implementation has issues and currently lives in branch "ceph-storage" until these issues are fixed. * Keeping some control: - flexible permissions: read, create, modify, delete, list, admin - assign permissions to users of login secrets - assign default permissions to not-logged-in users - you can purge files from storage by age, inactivity, size, type, ... - you can do consistency checks on the storage ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674420667.0 bepasty-1.2.0/README.rst0000644000076500000240000000431300000000000013167 0ustar00twstaffbepasty ======= bepasty is like a pastebin for all kinds of files (text, image, audio, video, documents, ..., binary). The documentation is there: https://bepasty-server.readthedocs.org/en/latest/ Features -------- * Generic: - you can upload multiple files at once, simply by drag and drop - after upload, you get a unique link to a view of each file - on that view, we show actions you can do with the file, metadata of the file and, if possible, we also render the file contents - if you uploaded multiple files, you can create a pastebin with the list of all these files - with a single click! - Set an expiration date for your files * Text files: - we highlight all text file types supported by pygments (a lot!) - we display line numbers - we link from line numbers to their anchors, so you can easily get a link to a specific line * Image files: - we show the image (format support depends on browser) - for image list items, we can show a slide show ("carousel" view) - in the items list, a thumbnail of images is shown * Audio and video files: - we show the html5 player for it (format support depends on browser) * asciinema recordings: - we show the asciinema player for .cast files * URLs: - we support linking to / redirecting to external URLs, you can use this as a link shortener (avoiding privacy / data protection issues that may exist with other link shorteners) * PDFs: - we support rendering PDFs in your browser (if your browser is able to) * Storage: we use a storage backend api, currently we have backends for: - filesystem storage (just use a filesystem directory to store .meta and .data files) - currently there are no other storage implementations in master branch and releases. The "ceph cluster" storage implementation has issues and currently lives in branch "ceph-storage" until these issues are fixed. * Keeping some control: - flexible permissions: read, create, modify, delete, list, admin - assign permissions to users of login secrets - assign default permissions to not-logged-in users - you can purge files from storage by age, inactivity, size, type, ... - you can do consistency checks on the storage ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4632847 bepasty-1.2.0/docs/0000755000076500000240000000000000000000000012427 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/Makefile0000644000076500000240000001516700000000000014101 0ustar00twstaff# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(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/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/bepasty.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/bepasty.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/bepasty" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/bepasty" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4634142 bepasty-1.2.0/docs/build/0000755000076500000240000000000000000000000013526 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/build/.keep0000644000076500000240000000000000000000000014441 0ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4651687 bepasty-1.2.0/docs/source/0000755000076500000240000000000000000000000013727 5ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4653094 bepasty-1.2.0/docs/source/_static/0000755000076500000240000000000000000000000015355 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/_static/.keep0000644000076500000240000000000000000000000016270 0ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4654179 bepasty-1.2.0/docs/source/_templates/0000755000076500000240000000000000000000000016064 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/_templates/.keep0000644000076500000240000000000000000000000016777 0ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/changelog.rst0000644000076500000240000000003700000000000016410 0ustar00twstaff.. include:: ../../CHANGES.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674420667.0 bepasty-1.2.0/docs/source/conf.py0000644000076500000240000002030300000000000015224 0ustar00twstaff# -*- coding: utf-8 -*- # # bepasty documentation build configuration file, created by # sphinx-quickstart on Fri Jan 24 22:36:13 2014. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # on rtfd, the project gets installed via pip and scm_version generates _version: from bepasty._version import version # General information about the project. project = 'bepasty' author = 'The %s Team' % project copyright = '2020-2023, %s' % author description = 'a binary pastebin / file upload service' # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = version # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'bepastydoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'bepasty.tex', u'bepasty Documentation', author, 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'bepasty', u'bepasty Documentation', [author], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'bepasty', u'bepasty Documentation', author, 'bepasty', description, 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/index.rst0000644000076500000240000000060500000000000015571 0ustar00twstaffWelcome to bepasty's documentation! =================================== bepasty is like a pastebin for every kind of file (text, image, audio, video, documents, ...). You can upload multiple files at once, simply by drag and drop. Contents -------- .. toctree:: :maxdepth: 2 intro user rest user-cli quickstart install-tutorial changelog project license ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/install-tutorial.rst0000644000076500000240000000573200000000000017777 0ustar00twstaff ===================================================== Installation tutorial with Debian, NGinx and gunicorn ===================================================== preliminary packages: :: apt-get install build-essential nginx supervisor python-dev git-core python-pip python-virtualenv commands to run :: # add user bepasty to system adduser bepasty # change to user bepasty sudo su - bepasty # clone repository from github git clone https://github.com/bepasty/bepasty-server.git repo # create folder for storage mkdir storage # create folder for logs mkdir logs # create virtualenv virtualenv . # activate virtualenv . bin/activate cd repo # install bepasty and requirements pip install -e . # add gunicorn and gevent for hosting pip install gunicorn gevent config file for bepasty -- ``/home/bepasty/bepasty.conf``: Copy ``src/bepasty/config.py`` to ``/home/bepasty/bepasty.conf`` first, remove the ``class Config`` and remove all indents in the file. The comments can be removed too, if you feel the need to. At last modify these two configs variables: :: STORAGE = 'filesystem' STORAGE_FILESYSTEM_DIRECTORY = '/home/bepasty/storage/' add this content to ``/home/bepasty/bin/gunicorn_bepasty``: :: #!/bin/bash NAME="bepasty" HOME=/home/bepasty SOCKFILE=$HOME/gunicorn.sock # we will communicate using this unix socket PIDFILE=$HOME/gunicorn.pid NUM_WORKERS=3 # how many worker processes should Gunicorn spawn export BEPASTY_CONFIG=$HOME/bepasty.conf source $HOME/bin/activate cd $HOME/repo exec gunicorn bepasty.wsgi \ --name $NAME \ --workers $NUM_WORKERS \ --log-level=info \ --bind=unix:$SOCKFILE \ --pid $PIDFILE \ -k gevent Make it executable: ``chmod +x ~/bin/gunicorn_bepasty`` A nginx configuration i.e. in ``/etc/nginx/conf.d/bepasty.conf``: :: upstream pasty_server { server unix:/home/bepasty/gunicorn.sock fail_timeout=0; } server { listen 80; #listen [::]:80; #uncomment this if your server supports IPv6 server_name paste.example.org; # <-- add your domainname here access_log /home/bepasty/logs/nginx-access.log; error_log /home/bepasty/logs/nginx-error.log; client_max_body_size 32M; location / { proxy_set_header Host $http_host; proxy_pass http://pasty_server; } location /static/ { alias /home/bepasty/repo/src/bepasty/static/; } } Now reload your nginx configuration: `service nginx reload`. Supervisord config i.e. in ``/etc/supervisor/conf.d/bepasty.conf``: :: [program:bepasty] command = /home/bepasty/bin/gunicorn_bepasty ; Command to start app user = bepasty ; User to run as stdout_logfile = /home/bepasty/logs/gunicorn_supervisor.log ; Where to write log messages redirect_stderr = true ; Save stderr in the same log Finally reload supervisor: `service supervisor reload` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/intro.rst0000644000076500000240000000003600000000000015613 0ustar00twstaff.. include:: ../../README.rst ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/license.rst0000644000076500000240000000013200000000000016077 0ustar00twstaffLicense ======= .. include:: ../../LICENSE Authors ======= .. include:: ../../AUTHORS ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/project.rst0000644000076500000240000000273400000000000016135 0ustar00twstaff============================ The bepasty software Project ============================ History ======= The initial version of the bepasty(-server) software was developed in 48h in the WSGI Wrestle 2013 contest by: * `Bastian Blank `_ * `Christian Fischer `_ Project site ============ Source code repository, issue tracker (bugs, ideas about enhancements, todo, feedback, ...), link to documentation is all there: https://github.com/bepasty/ Contributing ============ Feedback is welcome. If you find some issue, have some idea or some patch, please submit them via the issue tracker. Or even better: if you use git, fork our repo, make your changes and submit a pull request. For small fixes, you can even just edit the files on github (github will then fork, change and submit a pull request automatically). Development =========== :: # Create a new virtualenv virtualenv bepasty-server-env # Activate the virtualenv source bepasty-server-env/bin/activate # Clone the official bepasty-server (or your fork, if you want to send PULL requests) git clone https://github.com/bepasty/bepasty-server.git cd bepasty-server # This will use the current directory for the installed package. # Very useful during development! It will also autoreload when files are changed pip install -e . # Run the bepasty-server in debug mode. The server is reachable in http://127.0.0.1:5000 bepasty-server --debug ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/quickstart.rst0000644000076500000240000000733200000000000016660 0ustar00twstaffQuickstart ========== Installing bepasty ------------------ You can install bepasty either from PyPi (latest release) or from the git repository (latest available code). :: # from PyPi: pip install bepasty # if you'ld like to have python-magic to help determining files' mime types, use: pip install bepasty[magic] # from git repo pip install -e git+https://github.com/bepasty/bepasty-server.git#egg=bepasty-server Configuring bepasty ------------------- Before you can use bepasty, you need to carefully configure it (it won't work in default configuration and most of the configuration settings need your attention). When setting up permissions and giving out login secrets, carefully think about whom you give which permissions, especially when setting up the ``DEFAULT_PERMISSIONS`` (which apply to not-logged-in users). Here is the documentation straight from its config: .. autoclass:: bepasty.config.Config :members: To create a local and non-default configuration, copy ``bepasty/config.py`` to e.g. ``/srv/bepasty/bepasty.conf`` first, remove the ``class Config`` and remove all indents in the file. The comments can be removed too, if you feel the need to. At last modify these two configs variables: then modify it: :: # Note: no Config class required, just simple KEY = value lines: SECRET_KEY = '........................' STORAGE = 'filesystem' STORAGE_FILESYSTEM_DIRECTORY = '/srv/bepasty/storage/' # ... Important notes: * if you copied the file from the ``bepasty/config.py`` it will have a "class Config" in it and all the settings are inside that class. This is **not** what you need. Due to how flask config files work, you need to remove the class statement and outdent all the settings, so you just have global KEY = VALUE statements left on the top level of the config file. * if you run over http (like for trying it locally / for development), you need to change the configuration to use SESSION_SECURE_COOKIE = False (otherwise you can not login as it won't transmit the cookie over unsecure http). Starting bepasty server ----------------------- You can run the bepasty server with your local configuration by pointing to it via the BEPASTY_CONFIG environment variable like this: :: BEPASTY_CONFIG=/srv/bepasty/bepasty.conf bepasty-server Important note: * Use an absolute path as value for BEPASTY_CONFIG. The builtin WSGI server is recommended only for development and non-production use. For production, you should use a WSGI server like gunicorn, apache+mod-wsgi, nginx+uwsgi, etc. :: gunicorn bepasty.wsgi Invoking CLI commands --------------------- All bepasty commands expect either a --config argument or that the BEPASTY_CONFIG environment variable points to your configuration file. The "object" command operates on objects stored in the storage. You can get infos about them ("info" subcommand), you can set some flags on them ("set"), you can remove all or some ("purge"), you can check the consistency ("consistency"), etc... To get help about the object command, use: :: bepasty-object --help To get help about the object purge subcommand, use: :: bepasty-object purge --help To run the object purge subcommand (here: dry-run == do not remove anything, files >= 10MiB AND age >= 14 days), use something like: :: bepasty-object purge --dry-run --size 10 --age 14 '*' If you upgraded bepasty, you might need to upgrade the stored metadata to the current bepasty metadata schema: :: bepasty-object migrate '*' Note: the '*' needs to be quoted with single-quotes so the shell does not expand it. it tells the command to operate on all names in the storage (you could also give some specific names instead of '*'). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684788027.0 bepasty-1.2.0/docs/source/rest.rst0000644000076500000240000002345100000000000015443 0ustar00twstaff======================== Using bepasty's REST-API ======================== The Rest-API enables you to upload and download files, as well as retrieve informations about the file on the server. Currently the REST API provides the following API Endpoints:: GET /apis/rest GET /apis/rest/items POST /apis/rest/items GET /apis/rest/items/ GET /apis/rest/items//download POST /apis/rest/items//modify POST /apis/rest/items//delete POST /apis/rest/items//lock POST /apis/rest/items//unlock Authentication ============== For endpoints that require non-default permissions, authentication can be done through http basic authentication. The user is ignored and the password is the key for the intended permissions. Errors ====== The error response from REST-API will set ``Content-Type: application/json``, and body as JSON format like the following example. (it was previously ``Content-Type: text/html; charset=utf-8`` and partial HTML page or plain string) Example:: { "error": { "code": , "message": "" } } Retrieving information for uploading ==================================== API Interface: :: GET /apis/rest GET Response by the server: Example Response:: { MAX_ALLOWED_FILE_SIZE: 5000000000, MAX_BODY_SIZE: 1048576 } This interface will give you important infos for uploading and downloading files to your bepasty server. By now only the MAX_BODY_SIZE will be delivered to you, as no more info is available. MAX_BODY_SIZE The maximum size of a post request's body. This is limited by the webserver and other middleware. See the documentation for more information. This also gives you the maximum size for the chunked upload. MAX_ALLOWED_FILE_SIZE The maximum allowed filesize that can be stored on the server. Files uploads bigger than this limit will be aborted and the file on the server will be deleted. Uploading a file ================ API Interface: :: POST /apis/rest/items When uploading a file, chunked upload is mandatory. Check the MAX_BODY_SIZE for the maximum chunk size that can be sent to the server. The body of the post request contains the base64 encoded binary of the file to be uploaded. (required permission: :ref:`create `) POST Request by the client: Post Request Body Contains the base64 encoded binary of the file to be uploaded. The following headers *can (cursive)* or **must (bold)** be delivered by every post request to the server: **Content-Range** The content-range header follows the specification by the w3c (http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16). It has to be provided consistently and can resume a aborted file upload, together with the transaction-ID. **Transaction-ID** The transaction-ID will be provided by the server after the first upload chunk. After that first chunk, the transaction-id has to be provided by the client, to continue uploading the file. *Content-Type* The content-type of the file uploaded to the server. If the content-type is not given, the server will guess the content-type by the filename. If this fails the content-type will be 'application/octet-stream' *Content-Length* The content-length is mostly ignored by the server. It can be used to indicate the final file size. If your final file size is bigger than the maximum allowed size on the server, the upload will be aborted. The real filesize will be calculated by the server while uploading. *Content-Filename* The content-filename header can be used to name the file on the server. If no content-filename is passed, the server will generate a name from scratch. Maximum filename size is 50 characters. *Maxlife-Unit* The maxlife-unit can be used with the maxlife-value header to define a lifetime for the file that is uploaded. The unit has to be one of these:: ['MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS', 'FOREVER'] If this header is omitted the unit will be forever *Maxlife-Value* The maxlife-value header defines the value of the maxlife-unit. POST Response by the server: *Transaction-ID* Transaction-ID provided for continued upload in a chunked upload process. *Content-Location* The URI of the newly uploaded file on the server. Will only be provided when upload is finished and successful. Retrieving information about a file =================================== API Interface: :: GET /apis/rest/items/ (required permission: :ref:`read `) GET Request by the client: **itemname** The itemname of the file requested. GET Response by the server: Example Response:: { file-meta: { complete: true, filename: "Wallpaper Work.7z", hash: "dded24ba6f1d953bedb9d2745635a6f7462817061763b0d70f68b7952722f275", locked: false, size: 150225567, timestamp-download: 1414483078, timestamp-max-life: -1, timestamp-upload: 1414443534, type: "application/x-7z-compressed" }, uri: "/apis/rest/items/N24bFRZm" } *URI* The URI of the file on the server. Used to link to the download. *File-Meta* *Filename* The Filename of the uploaded file. *Size* The calculated size of the file on the server. *Timestamp-Upload* The timestamp of the moment the file was uploaded. *Timestamp-Download* The timestamp of the last download. *Timestamp-Max_life* The lifetime timestamp of the file in seconds. -1 means to keep the file forever. *Complete* True if the file upload is completed. False if it isn't *Locked* Whether the file is locked or not. *Hash* The sha256 hash of the file uploaded. Calculated by the server. *Type* Mimetype of the file uploaded. If no filetype is provided this will be set to 'application/octet-stream'. Retrieving Item List ==================== API Interface: :: GET /apis/rest/items (required permission: :ref:`list `) GET Request by the client: No Parameters GET Response by the server: Example Response:: { "N24bFRZm": { file-meta: { complete: true, filename: "Wallpaper Work.7z", hash: "dded24ba6f1d953bedb9d2745635a6f7462817061763b0d70f68b7952722f275", locked: false, size: 150225567, timestamp-download: 1414483078, timestamp-max-life: -1, timestamp-upload: 1414443534, type: "application/x-7z-compressed" }, uri: "/apis/rest/items/N24bFRZm" }, ... } Parameters are the same as in *Retrieving information about a file*. Downloading a file ================== API Interface: :: GET /apis/rest/items//download (required permission: :ref:`read `) GET Response by the server: Example Response:: Content-Type: application/x-7z-compressed Content-Length: 150225568 Content-Disposition: attachment; filename="Wallpaper Work.7z" Content-Range: bytes 0-150225567/150225567 Opens up a stream and delivers the binary data directly. The above headers can be found in the HTTP Response. Modifying metadata ================== API Interface: :: POST /apis/rest/items//modify Modify metadata specified by ````. (required permission: :ref:`modify `) POST Request by the client: **itemname** The itemname of the target file. **Content-Type** The content-type header must be ``application/json`` New metadata is specified by JSON in the request body. Currently this API is supporting to modify ``filename`` and ``type``. For example, if you want to modify the filename:: {"filename": "new-filename.txt"} if you want to modify both filename and type:: {"filename": "new-filename.txt", "type": "new-mimetype"} POST Response by the server: On success, status code == 200. Otherwise status code != 200. Deleting a file =============== API Interface: :: POST /apis/rest/items//delete Delete a file specified by ````. (required permission: :ref:`delete `) POST Request by the client: **itemname** The itemname of the target file. POST Response by the server: On success, status code == 200. Otherwise status code != 200. Locking a file ============== API Interface: :: POST /apis/rest/items//lock Lock a file specified by ````. (required permission: :ref:`admin `) POST Request by the client: **itemname** The itemname of the target file. POST Response by the server: On success, status code == 200. Otherwise status code != 200. Unlocking a file ================ API Interface: :: POST /apis/rest/items//unlock Lock a file specified by ````. (required permission: :ref:`admin `) POST Request by the client: **itemname** The itemname of the target file. POST Response by the server: On success, status code == 200. Otherwise status code != 200. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/user-cli.rst0000644000076500000240000000347100000000000016211 0ustar00twstaff================================== Using bepasty with non-web clients ================================== pastebinit ========== pastebinit is a popular pastebin client (included in debian, ubuntu and maybe elsewhere) that can be configured to work with bepasty: Configuration ------------- ~/.pastebinit.xml:: https://bepasty.example.org Notes: * we set an empty default format so pastebinit will transmit this (and not its internal format default [which is "text" and completely useless for us as it is not a valid contenttype]) ~/.pastebin.d/bepasty.conf:: [pastebin] basename = bepasty.example.org regexp = https://bepasty.example.org [format] content = text title = filename format = contenttype page = page password = token [defaults] page = +upload Usage ----- Simplest:: echo "test" | pastebinit More advanced:: # give title (filename), password, input file pastebinit -t example.py -p yourpassword -i example.py # read from stdin, give title (filename), give format (contenttype) cat README | pastebinit -t README -f text/plain Notes: * we use -t ("title") to transmit the desired filename (we do not have a "title", but the filename that is used for downloading the pastebin is prominently displayed above the content, so can be considered as title also). * bepasty guesses the contenttype from the filename given with -t. if you do not give a filename there or the contenttype is not guessable from it, you may need to give -f also (e.g. -f text/plain). * if you give the contenttype, but not the filename, bepasty will make up a filename. * you need to use -p if the bepasty instance you use requires you to log in before you can create pastebins. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/docs/source/user.rst0000644000076500000240000001263000000000000015441 0ustar00twstaff============================= Using bepasty's web interface ============================= .. _permissions: Logging in and Permissions ========================== You may need to log in to get enough permissions required for the misc. functions of bepasty. Your current permissions are shown on the web interface (in the navigation bar). To log in, you need to know credentials - ask the admin who runs the site. Bepasty does **not** use the usual user/password scheme, but **only** uses passwords (or passphrases) as credentials - there are no separate user names. The site admin can assign permissions to login credentials (and also to the anonymous, not logged-in user): * create: be able to create pastebins * modify: be able to modify pastebins * read: be able to read / download pastebins * delete: be able to delete pastebins * list: be able to list (discover) all pastebins * admin: be able to lock/unlock files, do actions even if upload is not completed or item is locked Be careful about who you give what permissions - especially "admin" and "list" are rather critical ones. If you want good privacy, do not give "list" to anybody (except site administrator maybe). If you want to do everything rather in the public, you may give "list" to users (or even use it by default for not-logged-in users). "admin" likely should be given only to very trusted people, like site administrator. Pasting text ============ Just paste it into that big upper text input box. Content-Type: Below the box you can optionally choose the type of your content (if you don't, it will become plain text). Just start typing some letters of the type, e.g. if you pasted some python code, type pyt and see how it offers you some choices based on that. Based on that type, we will highlight your text (using the Pygments library, which supports a lot of text formats). File name: You can optionally give a filename for your paste. If someone later downloads it, the browser will use the filename you gave. If you don't give a filename, bepasty will make something up. Maximum lifetime: The file will be automatically deleted after this time is over When finished, click on "Submit". bepasty will save your text using a unique ID and redirect you to the URL where you can view or download your pasted text. Uploading files =============== See that big box below the text input box - you can: * click it to upload files via the file selection dialogue of your browser * drag files from your desktop and drop them there Note: some features require a modern browser, like a current Firefox or Chrome/Chromium with Javascript enabled. It will show you a progress indication while the files upload. After the files are uploaded, bepasty will show you a list of links to the individual views of each uploaded file. Make sure you keep all the links (open the links in new tabs / new windows) - they are the only way to access the files. Additionally, bepasty prepared a file list for you (that has all the unique IDs of your uploaded files). If you create a list item by hitting the respective button, bepasty will store that list in yet another pastebin item, so 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 communicate that one URL (not each individual file's URL). Viewing / Downloading files =========================== Just visit the file's unique URL to view, download or delete it. bepasty will show you metadata like: * file name * precise file size * upload date/time (UTC) * (last) download date/time (UTC) - viewing the data also counts as download * expiration date, if set * sha256 hash of the file contents bepasty also supports directly displaying the data, for these content types: * lists of files (if a list item was created at upload time) * text files (highlighted depending on the content-type) * PDFs (if you browser can render PDFs or has a plugin doing that) * asciinema cast files * image files, like jpeg, png and svg * audio/video files (using the html5 player widget, format support might depend on your browser and OS) * for other file types, you need to download them and open them with the appropriate application File hashes =========== If you're unfamiliar with hashes like SHA256, you might wonder what they are good for and why we show them. A hash is something like a checksum or fingerprint of the file contents. We compute the hash while or directly after the file upload. If you have 2 files at different places and they have the same SHA256 hash, you can be pretty sure that they have completely identical contents. If you are looking at a file you just uploaded yourself, it might be interesting to compare the size and hash with the values you see for your local file to make sure the upload worked correctly. Same after you download a file: check whether the hash matches for the file on bepasty and your downloaded file. If you transfer a file from location A via bepasty (B) to location C, you can also compare the file hashes at locations A and C to make sure the file was not modified or corrupted while being transferred. Important stuff to remember =========================== * if you get credentials from an admin, do not make them available to other persons except if explicitly allowed * files may go away at any time, always remember that a pastebin site is only for short-term temporary storage (how long this is depends on the site's / site admin's policy and available disk space) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693233598.0 bepasty-1.2.0/pyproject.toml0000644000076500000240000000367000000000000014421 0ustar00twstaff[build-system] requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "src/bepasty/_version.py" [project] name = "bepasty" dynamic = ["version"] license = {text="BSD 2-clause"} authors = [{name="The Bepasty Team (see AUTHORS file)"}] maintainers = [{name="Thomas Waldmann", email="tw@waldmann-edv.de"}] description = "a binary pastebin / file upload service" readme = "README.rst" keywords = ["text", "image", "audio", "video", "binary", "pastebin", "upload", "download", "service", "wsgi", "flask"] requires-python = ">=3.8" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Framework :: Flask", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] dependencies = [ "Flask", "markupsafe", "Pygments>=2.12.0", "xstatic", "XStatic-asciinema-player", "xstatic-bootbox>=5.4.0", "xstatic-bootstrap>=4.0.0.0,<5.0.0.0", "xstatic-font-awesome<5.0", "xstatic-jquery", "xstatic-jquery-ui", "xstatic-jquery-file-upload", "xstatic-pygments", ] [project.scripts] bepasty-server = "bepasty.cli.server:main" bepasty-object = "bepasty.cli.object:main" [project.optional-dependencies] magic = ["python-magic"] pillow = ["Pillow"] [project.urls] Homepage = "https://github.com/bepasty/bepasty-server/" Documentation = "https://bepasty-server.readthedocs.org/" Changelog = "https://github.com/bepasty/bepasty-server/blob/master/CHANGES.rst" [tool.pytest.ini_options] norecursedirs = [".eggs", ".git", ".tox", "build", ] markers = [ "slow", "needs_server", ] [tool.flake8] max-line-length = 120 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4656723 bepasty-1.2.0/requirements.d/0000755000076500000240000000000000000000000014444 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693234051.0 bepasty-1.2.0/requirements.d/dev.txt0000644000076500000240000000011300000000000015756 0ustar00twstaffbuild tox flake8-pyproject flake8 pytest pytest-cov selenium codecov twine ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674424259.0 bepasty-1.2.0/requirements.d/rtd.txt0000644000076500000240000000020600000000000015774 0ustar00twstaff# Defining the exact version will make sure things don't break sphinx==5.3.0 sphinx_rtd_theme==1.1.1 readthedocs-sphinx-search==0.1.1 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1693235434.465966 bepasty-1.2.0/scripts/0000755000076500000240000000000000000000000013166 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693234051.0 bepasty-1.2.0/scripts/sdist-sign0000755000076500000240000000046600000000000015206 0ustar00twstaff#!/bin/bash R=$1 if [ "$R" = "" ]; then echo "Usage: sdist-sign 1.2.3" exit fi if [ "$QUBES_GPG_DOMAIN" = "" ]; then GPG=gpg else GPG=qubes-gpg-client-wrapper fi python -m build --sdist D=dist/bepasty-$R.tar.gz $GPG --detach-sign --local-user "Thomas Waldmann" --armor --output $D.asc $D ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674420667.0 bepasty-1.2.0/scripts/upload-pypi0000755000076500000240000000040100000000000015352 0ustar00twstaff#!/bin/bash R=$1 if [ "$R" = "" ]; then echo "Usage: upload-pypi 1.2.3 [test]" exit fi if [ "$2" = "test" ]; then export TWINE_REPOSITORY=testpypi else export TWINE_REPOSITORY=pypi fi D=dist/bepasty-$R.tar.gz twine upload "$D.asc" "$D" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4767332 bepasty-1.2.0/setup.cfg0000644000076500000240000000004600000000000013320 0ustar00twstaff[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4610095 bepasty-1.2.0/src/0000755000076500000240000000000000000000000012266 5ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4670568 bepasty-1.2.0/src/bepasty/0000755000076500000240000000000000000000000013735 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/__init__.py0000644000076500000240000000000000000000000016034 0ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty/_version.py0000644000076500000240000000024000000000000016127 0ustar00twstaff# file generated by setuptools_scm # don't change, don't track in version control __version__ = version = '1.2.0' __version_tuple__ = version_tuple = (1, 2, 0) ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1693235434.46834 bepasty-1.2.0/src/bepasty/apis/0000755000076500000240000000000000000000000014671 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/apis/__init__.py0000644000076500000240000000222400000000000017002 0ustar00twstafffrom flask import Blueprint from .lodgeit import LodgeitUpload from .rest import ItemDetailView, ItemDownloadView, ItemModifyView, \ ItemUploadView, InfoView, ItemDeleteView, ItemLockView, ItemUnlockView blueprint = Blueprint('bepasty_apis', __name__, url_prefix='/apis') blueprint.add_url_rule('/lodgeit/', view_func=LodgeitUpload.as_view('lodgeit')) blueprint.add_url_rule('/rest', view_func=InfoView.as_view('api_info')) blueprint.add_url_rule('/rest/items', view_func=ItemUploadView.as_view('items')) blueprint.add_url_rule('/rest/items/', view_func=ItemDetailView.as_view('items_detail')) blueprint.add_url_rule('/rest/items//download', view_func=ItemDownloadView.as_view('items_download')) blueprint.add_url_rule('/rest/items//delete', view_func=ItemDeleteView.as_view('items_delete')) blueprint.add_url_rule('/rest/items//modify', view_func=ItemModifyView.as_view('items_modify')) blueprint.add_url_rule('/rest/items//lock', view_func=ItemLockView.as_view('items_lock')) blueprint.add_url_rule('/rest/items//unlock', view_func=ItemUnlockView.as_view('items_unlock')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/apis/lodgeit.py0000644000076500000240000000340700000000000016676 0ustar00twstafffrom io import BytesIO from flask import request from flask.views import MethodView from pygments.lexers import get_all_lexers from werkzeug.exceptions import Forbidden from werkzeug.urls import url_quote from ..constants import FOREVER from ..utils.http import redirect_next from ..utils.permissions import CREATE, may from ..utils.upload import create_item class LodgeitUpload(MethodView): """ lodgeit paste form """ # most stuff lodgeit support comes directly from pygments # for all other stuff we fall back to text/plain. TRANS = {} for lexer in get_all_lexers(): # (name, aliases, filetypes, mimetypes) # e.g. ('Diff', ('diff',), ('*.diff', '*.patch'), ('text/x-diff', 'text/x-patch')) if len(lexer[1]) == 0: continue name = lexer[1][0] cts = lexer[3] # find a content-type, preferably one with text/* for ct in cts: if ct.startswith("text/"): break else: if cts: ct = cts[0] else: ct = None if ct: TRANS[name] = ct def post(self): if not may(CREATE): raise Forbidden() lang = request.form.get('language') content_type = self.TRANS.get(lang) content_type_hint = 'text/plain' filename = None t = request.form['code'] # t is already unicode, but we want utf-8 for storage t = t.encode('utf-8') size = len(t) f = BytesIO(t) maxlife_timestamp = FOREVER name = create_item(f, filename, size, content_type, content_type_hint, maxlife_stamp=maxlife_timestamp) return redirect_next('bepasty.display', name=name, _anchor=url_quote(filename)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/apis/rest.py0000644000076500000240000002733200000000000016227 0ustar00twstaffimport errno import base64 import time from io import BytesIO from flask import Response, make_response, url_for, jsonify, stream_with_context, request, current_app from flask.views import MethodView from werkzeug.exceptions import HTTPException, BadRequest, Conflict, Forbidden, InternalServerError, MethodNotAllowed from ..constants import FILENAME, ID, SIZE, TYPE, TRANSACTION_ID from ..utils.date_funcs import get_maxlife from ..utils.http import ContentRange, DownloadRange from ..utils.name import ItemName from ..utils.permissions import CREATE, LIST, may from ..utils.upload import Upload, filter_internal, background_compute_hash from ..views.filelist import file_infos from ..views.delete import DeleteView from ..views.download import DownloadView from ..views.modify import ModifyView from ..views.setkv import LockView, UnlockView # This wrappper handles exceptions in the REST api implementation. # # The @blueprint.add_errorhandler decorator could do this, but we have # "/lodgeit/" in the same blueprint and there is no way to exclude # from using the same error handler ("/lodgeit/" is not REST api). def rest_errorhandler(func): def error_message(description, code): return jsonify({ 'error': {'code': code, 'message': description}, }), code def handler(*args, **kwargs): try: return func(*args, **kwargs) except HTTPException as exc: return error_message(exc.description, exc.code) except Exception: if current_app.propagate_exceptions: # if testing/debug mode, re-raise raise exc = InternalServerError() return error_message(exc.description, exc.code) return handler # Default handlers for REST api to handle error class RestBase(MethodView): @rest_errorhandler def get(self, *args, **kwargs): raise MethodNotAllowed() @rest_errorhandler def post(self, *args, **kwargs): raise MethodNotAllowed() class ItemUploadView(RestBase): def update_item(self, item, name): # Check the actual size of the file on the server against limit # Either 0 if new file or n bytes of already uploaded file Upload.filter_size(item.data.size) # Check Content-Range. Needs to be specified, even if only one chunk if not request.headers.get("Content-Range"): raise BadRequest(description='Content-Range not specified') # Get Content-Range and check if Range is consistent with server state file_range = ContentRange.from_request() if not item.data.size == file_range.begin: raise Conflict(description='Content-Range inconsistent. Last byte on Server: %d' % item.data.size) # Decode Base64 encoded request data try: raw_data = base64.b64decode(request.data) file_data = BytesIO(raw_data) except (base64.binascii.Error, TypeError): raise BadRequest(description='Could not decode data body') # Write data chunk to item Upload.data(item, file_data, len(raw_data), file_range.begin) # Make a Response and create Transaction-ID from ItemName response = make_response() response.headers['Content-Type'] = 'application/json' response.data = '{}' name_b = name if isinstance(name, bytes) else name.encode() trans_id_b = base64.b64encode(name_b) trans_id_s = trans_id_b if isinstance(trans_id_b, str) else trans_id_b.decode() response.headers[TRANSACTION_ID] = trans_id_s # Check if file is completely uploaded and set meta if file_range.is_complete: Upload.meta_complete(item, '') item.meta[SIZE] = item.data.size item.close() background_compute_hash(current_app.storage, name) # Set status 'successful' and return the new URL for the uploaded file response.status = '201' response.headers["Content-Location"] = url_for('bepasty_apis.items_detail', name=name) else: item.close() response.status = '200' return response @rest_errorhandler def post(self): """ Upload file via REST-API. Chunked Upload is supported. HTTP Headers that need to be given: * Content-Type: The type of the file that is being uploaded. If this is not given filetype will be 'application/octet-stream' * Content-Length: The total size of the file to be uploaded. * Content-Filename: The filename of the file. This will be used when downloading. * Content-Range: The Content-Range of the Chunk that is currently being uploaded. Follows the HTTP-Header Specifications. * Transaction-ID: The Transaction-ID for Chunked Uploads. Needs to be delivered when uploading in chunks (after the first chunk). To start an upload, the HTTP Headers need to be delivered. The body of the request needs to be the base64 encoded file contents. Content-Length is the original file size before base64 encoding. Content-Range follows the same logic. After the first chunk is uploaded, bepasty will return the Transaction-ID to continue the upload. Deliver the Transaction-ID and the correct Content-Range to continue upload. After the file is completely uploaded, the file will be marked as complete and a 201 HTTP Status will be returned. The Content-Location Header will contain the api url to the uploaded Item. If the file size exceeds the permitted size, the upload will be aborted. This will be checked twice. The first check is the provided Content-Length. The second is the actual file size on the server. """ if not may(CREATE): raise Forbidden() # Collect all expected data from the Request file_type = request.headers.get("Content-Type") file_size = request.headers.get("Content-Length") file_name = request.headers.get("Content-Filename") # Check the file size from Request Upload.filter_size(file_size) # Check if Transaction-ID is available for continued upload if not request.headers.get(TRANSACTION_ID): # Create ItemName and empty file in Storage name = ItemName.create(current_app.storage) item = current_app.storage.create(name, 0) # set max lifetime maxtime = get_maxlife(request.headers, underscore=False) maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime # Fill meta with data from Request Upload.meta_new(item, 0, file_name, file_type, 'application/octet-stream', name, maxlife_stamp=maxlife_timestamp) new_item = True else: # Get file name from Transaction-ID and open from Storage trans_id_s = request.headers.get(TRANSACTION_ID) trans_id_b = trans_id_s if isinstance(trans_id_s, bytes) else trans_id_s.encode() try: name_b = base64.b64decode(trans_id_b) except (base64.binascii.Error, TypeError): raise BadRequest(description=f'Could not decode {TRANSACTION_ID}') name = name_b if isinstance(name_b, str) else name_b.decode() try: item = current_app.storage.openwrite(name) except OSError as e: if e.errno == errno.ENOENT: raise BadRequest(description='Could not find storage item for transaction id') raise new_item = False response = None try: response = self.update_item(item, name) return response finally: # If error response or exception on a new item path, remove item if new_item and not isinstance(response, Response): current_app.storage.remove(name) @rest_errorhandler def get(self): """ Return the list of all files in bepasty, including metadata in the form: { "": { "file-meta": { }, "uri" : "/apis/rest/items/" }, ... } """ if not may(LIST): raise Forbidden() ret = {} for meta in file_infos(): name = meta.pop(ID) ret[name] = {'uri': url_for('bepasty_apis.items_detail', name=name), 'file-meta': filter_internal(meta)} return jsonify(ret) class ItemDetailView(DownloadView, RestBase): def err_incomplete(self, item, error): raise Conflict(description=error) def response(self, item, name): return jsonify({'uri': url_for('bepasty_apis.items_detail', name=name), 'file-meta': filter_internal(item.meta)}) @rest_errorhandler def get(self, name): return super().get(name) class ItemDownloadView(ItemDetailView): def response(self, item, name): request_range = DownloadRange.from_request() if not request_range: range_end = item.data.size - 1 range_begin = 0 else: if request_range.end == -1: range_end = item.data.size - 1 else: range_end = min(request_range.end, item.data.size - 1) range_begin = request_range.begin ret = Response(stream_with_context(self.stream(item, range_begin, range_end + 1))) ret.headers['Content-Disposition'] = '{}; filename="{}"'.format( self.content_disposition, item.meta[FILENAME]) ret.headers['Content-Length'] = (range_end - range_begin) + 1 ret.headers['Content-Type'] = item.meta[TYPE] # 'application/octet-stream' ret.status = '200' ret.headers['Content-Range'] = ('bytes %d-%d/%d' % (range_begin, range_end, item.data.size)) return ret @rest_errorhandler def get(self, name): return super(ItemDetailView, self).get(name) class ItemModifyView(ModifyView, RestBase): def error(self, item, error): raise Conflict(description=error) def response(self, name): return make_response('{}', {'Content-Type': 'application/json'}) def get_params(self): json = request.json if json is None: raise BadRequest(description='Content-Type or JSON format is invalid') return { FILENAME: json.get(FILENAME), TYPE: json.get(TYPE), } @rest_errorhandler def post(self, name): return super().post(name) class ItemDeleteView(DeleteView, RestBase): def error(self, item, error): raise Conflict(description=error) def response(self, name): return make_response('{}', {'Content-Type': 'application/json'}) @rest_errorhandler def post(self, name): return super().post(name) class ItemLockView(LockView, RestBase): def error(self, item, error): raise Conflict(description=error) def response(self, name): return make_response('{}', {'Content-Type': 'application/json'}) @rest_errorhandler def post(self, name): return super().post(name) class ItemUnlockView(UnlockView, RestBase): def error(self, item, error): raise Conflict(description=error) def response(self, name): return make_response('{}', {'Content-Type': 'application/json'}) @rest_errorhandler def post(self, name): return super().post(name) class InfoView(RestBase): @rest_errorhandler def get(self): return jsonify({'MAX_BODY_SIZE': current_app.config['MAX_BODY_SIZE'], 'MAX_ALLOWED_FILE_SIZE': current_app.config['MAX_ALLOWED_FILE_SIZE']}) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684790435.0 bepasty-1.2.0/src/bepasty/app.py0000644000076500000240000001116600000000000015074 0ustar00twstaffimport os import time import hashlib from flask import ( Flask, current_app, g as flaskg, # searching for 1 letter name "g" isn't nice, thus we use flaskg render_template, session, ) from markupsafe import Markup from .apis import blueprint as blueprint_apis from .storage import create_storage from .utils.name import setup_werkzeug_routing from .utils.permissions import ( ADMIN, CREATE, DELETE, MODIFY, LIST, READ, get_permission_icons, get_permissions, logged_in, may, ) from .views import blueprint import mimetypes mimetypes.add_type('application/x-asciinema-recording', '.cast') class PrefixMiddleware: def __init__(self, app, prefix=''): self.app = app self.prefix = prefix def __call__(self, environ, start_response): if environ['PATH_INFO'].startswith(self.prefix): environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):] environ['SCRIPT_NAME'] = self.prefix return self.app(environ, start_response) else: start_response('404', [('Content-Type', 'text/plain')]) return [b'This URL does not belong to the bepasty app.'] def setup_secret_key(app): """ The secret key is used to sign cookies and cookies not signed with the current secret key are considered invalid. Here, we amend the configured secret key, so it depends on some other config values. Changing any of these values will change the computed secret key (and thus invalidate all previously made cookies). Currently supported secret-changing config values: PERMISSIONS """ # if app.config['SECRET_KEY'] is empty, keep as NullSession if app.config['SECRET_KEY']: perms = sorted(k + v for k, v in app.config['PERMISSIONS'].items()) perms = ''.join(perms).encode() app.config['SECRET_KEY'] += hashlib.sha256(perms).hexdigest() def create_app(): app = Flask(__name__) app.config.from_object('bepasty.config.Config') if os.environ.get('BEPASTY_CONFIG'): app.config.from_envvar('BEPASTY_CONFIG') setup_secret_key(app) prefix = app.config.get('APP_BASE_PATH') if prefix is not None: app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix=prefix) app.storage = create_storage(app) setup_werkzeug_routing(app) app.register_blueprint(blueprint) app.register_blueprint(blueprint_apis) @app.errorhandler(403) def url_forbidden(e): heading = 'Forbidden' body = Markup("""\

You are not allowed to access the requested URL.

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

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

""") return render_template('error.html', heading=heading, body=body), 403 @app.errorhandler(404) def url_not_found(e): heading = 'Not found' body = Markup("""\

The requested URL was not found on the server.

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

""") return render_template('error.html', heading=heading, body=body), 404 @app.before_request def before_request(): """ before the request is handled (by its view function), we compute some stuff here and make it easily available. """ flaskg.logged_in = logged_in() flaskg.permissions = get_permissions() flaskg.icon_permissions = get_permission_icons() if flaskg.logged_in: session.permanent = current_app.config['PERMANENT_SESSION'] def datetime_format(ts): """ takes a unix timestamp and outputs a iso8601-like formatted string. times are always UTC, but we don't include the TZ here for brevity. it should be made clear (e.g. in the template) that the date/time is UTC. """ if not ts: # we use 0 to indicate undefined time return 'undefined' return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(ts)) app.jinja_env.filters['datetime'] = datetime_format app.jinja_env.globals['flaskg'] = flaskg app.jinja_env.globals['may'] = may app.jinja_env.globals['ADMIN'] = ADMIN app.jinja_env.globals['LIST'] = LIST app.jinja_env.globals['CREATE'] = CREATE app.jinja_env.globals['MODIFY'] = MODIFY app.jinja_env.globals['READ'] = READ app.jinja_env.globals['DELETE'] = DELETE return app ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/bepasty_xstatic.py0000644000076500000240000000074100000000000017517 0ustar00twstafffrom xstatic.main import XStatic # names below must be package names mod_names = [ 'asciinema_player', 'bootbox', 'bootstrap', 'font_awesome', 'jquery', 'jquery_ui', 'jquery_file_upload', 'pygments', ] pkg = __import__('xstatic.pkg', fromlist=mod_names) serve_files = {} for mod_name in mod_names: mod = getattr(pkg, mod_name) xs = XStatic(mod, root_url='/static', provider='local', protocol='http') serve_files[xs.name] = xs.base_dir ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4686956 bepasty-1.2.0/src/bepasty/cli/0000755000076500000240000000000000000000000014504 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/cli/__init__.py0000644000076500000240000000000000000000000016603 0ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/cli/object.py0000644000076500000240000002310100000000000016321 0ustar00twstaff""" bepasty-object commandline interface """ import os import argparse import logging import time from flask import Flask from ..constants import ( COMPLETE, FILENAME, FOREVER, HASH, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TIMESTAMP_MAX_LIFE, TIMESTAMP_UPLOAD, TYPE, ) from ..utils.hashing import compute_hash from ..storage import create_storage class Main: argparser = argparse.ArgumentParser(prog='bepasty-object') _subparsers = argparser.add_subparsers() argparser.add_argument('--config', dest='config', metavar='CONFIG', help='bepasty configuration file') argparser.add_argument('names', metavar='NAME', nargs='+') def do_migrate(self, storage, name, args): tnow = time.time() with storage.openwrite(name) as item: # compatibility support for bepasty 0.0.1 and pre-0.1.0 # old items might have a 'timestamp' value which is not used any more # (superseded by 'timestamp-*') - delete it: item.meta.pop('timestamp', None) # old items might miss some of the timestamps we require, # just initialize them with the current time: for ts_key in [TIMESTAMP_UPLOAD, TIMESTAMP_DOWNLOAD, ]: if ts_key not in item.meta: item.meta[ts_key] = tnow if LOCKED not in item.meta: unlocked = item.meta.pop('unlocked', None) if unlocked is not None: locked = not unlocked else: locked = False item.meta[LOCKED] = locked if COMPLETE not in item.meta: item.meta[COMPLETE] = True if FILENAME not in item.meta: item.meta[FILENAME] = 'missing' if TYPE not in item.meta: item.meta[TYPE] = 'application/octet-stream' if SIZE not in item.meta: item.meta[SIZE] = item.data.size if HASH not in item.meta: item.meta[HASH] = '' # see do_consistency if TIMESTAMP_MAX_LIFE not in item.meta: item.meta[TIMESTAMP_MAX_LIFE] = FOREVER _parser = _subparsers.add_parser('migrate', help='Migrate metadata to current schema') _parser.set_defaults(func=do_migrate) def do_purge(self, storage, name, args): tnow = time.time() with storage.openwrite(name) as item: file_name = item.meta[FILENAME] file_size = item.meta[SIZE] t_upload = item.meta[TIMESTAMP_UPLOAD] t_download = item.meta[TIMESTAMP_DOWNLOAD] file_type = item.meta[TYPE] max_lifetime = item.meta.get(TIMESTAMP_MAX_LIFE, FOREVER) purge = True # be careful: we start from True, then AND the specified criteria if args.purge_age is not None: dt = args.purge_age * 24 * 3600 # n days since upload purge = purge and t_upload < tnow - dt if args.purge_inactivity is not None: dt = args.purge_inactivity * 24 * 3600 # n days inactivity (no download) purge = purge and t_download < tnow - dt if args.purge_size is not None: max_size = args.purge_size * 1024 * 1024 # size in MiB purge = purge and file_size > max_size if args.purge_type is not None: purge = purge and file_type.startswith(args.purge_type) if max_lifetime is not None: purge = purge and tnow > max_lifetime > 0 if purge: print('removing: %s (%s %dB %s)' % (name, file_name, file_size, file_type)) if not args.purge_dry_run: storage.remove(name) _parser = _subparsers.add_parser('purge', help='Purge objects') _parser.set_defaults(func=do_purge) _parser.add_argument('-D', '--dry-run', dest='purge_dry_run', action='store_true', help='do not remove anything, just display what would happen') _parser.add_argument('-A', '--age', dest='purge_age', type=int, default=None, help='only remove if upload older than PURGE_AGE days') _parser.add_argument('-I', '--inactivity', dest='purge_inactivity', type=int, default=None, help='only remove if latest download older than PURGE_INACTIVITY days') _parser.add_argument('-S', '--size', dest='purge_size', type=int, default=None, help='only remove if file size > PURGE_SIZE MiB') _parser.add_argument('-T', '--type', dest='purge_type', default=None, help='only remove if file mimetype starts with PURGE_TYPE') def do_consistency(self, storage, name, args): with storage.openwrite(name) as item: file_name = item.meta[FILENAME] meta_size = item.meta[SIZE] meta_type = item.meta[TYPE] meta_hash = item.meta[HASH] print('checking: %s (%s %dB %s)' % (name, file_name, meta_size, meta_type)) size = item.data.size size_consistent = size == meta_size if not size_consistent: print("Inconsistent file size: meta: %d file: %d" % (meta_size, size)) if args.consistency_fix: print("Writing computed file size into metadata...") item.meta[SIZE] = size size_consistent = True file_hash = compute_hash(item.data, size) hash_consistent = meta_hash == file_hash if not hash_consistent: if meta_hash: print("Inconsistent hashes:") print("meta: %s" % meta_hash) print("file: %s" % file_hash) else: # the upload code can not compute hashes for chunked uploads and thus stores an empty hash. # we can fix that empty hash with the computed hash from the file we have in storage. print("Empty hash in metadata.") if args.consistency_fix or args.consistency_compute and not meta_hash: print("Writing computed file hash into metadata...") item.meta[HASH] = file_hash hash_consistent = True if args.consistency_remove and not (size_consistent and hash_consistent): print('REMOVING inconsistent file!') storage.remove(name) _parser = _subparsers.add_parser('consistency', help='Consistency-related functions') _parser.set_defaults(func=do_consistency) _parser.add_argument('-C', '--compute', dest='consistency_compute', action='store_true', help='compute missing hashes and write into metadata') _parser.add_argument('-F', '--fix', dest='consistency_fix', action='store_true', help='write computed hash/size into metadata') _parser.add_argument('-R', '--remove', dest='consistency_remove', action='store_true', help='remove files with inconsistent hash/size') def do_info(self, storage, name, args): with storage.open(name) as item: print(name) for key, value in sorted(item.meta.items()): print(' ', key, value) _parser = _subparsers.add_parser('info', help='Display information about objects') _parser.set_defaults(func=do_info) def do_set(self, storage, name, args): with storage.openwrite(name) as item: print(name) if args.flag_complete is not None: if args.flag_complete: print(' set complete') else: print(' set not complete') item.meta[COMPLETE] = args.flag_complete if args.flag_locked is not None: if args.flag_locked: print(' set locked') else: print(' set not locked') item.meta[LOCKED] = args.flag_locked _parser = _subparsers.add_parser('set', help='Set flags on objects') _parser.set_defaults(func=do_set) _group = _parser.add_mutually_exclusive_group() _group.add_argument('-L', '--lock', dest='flag_locked', action='store_true', default=None) _group.add_argument('-l', '--unlock', dest='flag_locked', action='store_false', default=None) _group = _parser.add_mutually_exclusive_group() _group.add_argument('-C', '--incomplete', dest='flag_complete', action='store_false', default=None) _group.add_argument('-c', '--complete', dest='flag_complete', action='store_true', default=None) def __call__(self): args = Main.argparser.parse_args() # Setup minimal application app = Flask(__name__) app.config.from_object('bepasty.config.Config') if os.environ.get('BEPASTY_CONFIG'): app.config.from_envvar('BEPASTY_CONFIG') if args.config is not None: cfg_path = os.path.abspath(args.config) app.config.from_pyfile(cfg_path) app.storage = storage = create_storage(app) # Setup application context with app.app_context(): # Run all before request functions by hand for i in app.before_request_funcs.get(None, ()): i() if len(args.names) == 1 and args.names[0] == '*': names = list(storage) else: names = args.names for name in names: try: args.func(self, storage, name, args) except Exception: logging.exception('Failed to handle item %s', name) def main(): logging.basicConfig() Main()() if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/cli/server.py0000644000076500000240000000112300000000000016361 0ustar00twstaff""" bepasty-server commandline interface """ import argparse from ..app import create_app def main(): argparser = argparse.ArgumentParser(prog='bepasty-server') argparser.add_argument('--host', help='Host to listen on') argparser.add_argument('--port', type=int, help='Port to listen on') argparser.add_argument('--debug', help='Activate debug mode', action='store_true') args = argparser.parse_args() app = create_app() print(" * Starting bepasty server...") app.run(host=args.host, port=args.port, debug=args.debug) if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/config.py0000644000076500000240000001322400000000000015556 0ustar00twstaffclass Config: """ This is the basic configuration class for bepasty. IMPORTANT: The config is only loaded at startup time of the app, so if you change it, you need to restart the wsgi app process(es) to make it load the updated config. """ #: name of this site (put YOUR bepasty fqdn here) SITENAME = 'bepasty.example.org' #: base URL path of app (if not served on root URL, but e.g. on http://example.org/bepasty ). #: setting this to a non-None value will activate the PrefixMiddleware that splits PATH_INFO #: into SCRIPT_NAME (== APP_BASE_PATH) and the rest of PATH_INFO. APP_BASE_PATH = None # '/bepasty' #: Whether files are automatically locked after upload. #: #: If you want to require admin approval and manual unlocking for each #: uploaded file, set this to True. UPLOAD_LOCKED = False #: The asciinema player theme (one of asciinema, tango, solarized-dark, #: solarized-light, monokai) ASCIINEMA_THEME = 'asciinema' #: The site admin can set some maximum allowed file size he wants to #: accept here. This is the maximum size an uploaded file may have. MAX_ALLOWED_FILE_SIZE = 5 * 1000 * 1000 * 1000 #: The maximum http request body size. #: This is an information given to rest api clients so they can adjust #: their chunk size accordingly. #: #: This needs to be in sync with (or at least not beyond) the web server #: settings: #: apache: LimitRequestBody 1048576 # apache default is 0 (unlimited) #: nginx: client_max_body_size 1m; # nginx default (== 1048576) MAX_BODY_SIZE = 1 * 1024 * 1024 - 8192 # 8kiB safety margin, issue #155 #: Setup maximum file sizes for specific content-types. If an item is #: beyond the limit set for its type, it will not be rendered, but just #: offered for download. Lookup within MAX_RENDER_SIZE is done by #: first-match and it is automatically sorted for longer content-type- #: prefixes first. #: #: Format of entries: content-type-prefix: max_size MAX_RENDER_SIZE = { # each list entry has 38 bytes, do not render > 1000 items 'text/x-bepasty-list': 1000 * 38, # stuff rendered with syntax highlighting (expensive for server and # client) and also used for other text/* types as we use same code to # get a (non-highlighted) display with line numbers: 'HIGHLIGHT_TYPES': 100 * 1000, # the in-browser pdf reader is sometimes rather slow and should # rather not be used for big PDFs: 'application/pdf': 10 * 1000 * 1000, 'application/x-pdf': 10 * 1000 * 1000, # images / audio / video can be rather big, we do not process them: 'image/': 10 * 1000 * 1000, 'audio/': 1 * 1000 * 1000 * 1000, 'video/': 5 * 1000 * 1000 * 1000, # DEFAULT - matches everything not matched otherwise. # As we have catched everything we are able to render already, # this maybe should be a medium size, just for the case we forget # something: '': 1 * 1000 * 1000, } # Whether to use the python-magic module to guess a file's mime # type by looking into its content (if the mime type can not be # determined from the filename extension). # NOTE: # libmagic may have security issues, so maybe you should only use # it if you trust all users with upload permissions ('create'). USE_PYTHON_MAGIC = False #: Define storage backend, choose from: #: #: - 'filesystem' #: STORAGE = 'filesystem' #: Filesystem storage path STORAGE_FILESYSTEM_DIRECTORY = '/tmp/' #: server secret key needed for safe session cookies. #: you must set a very long, very random, very secret string here, #: otherwise bepasty will not work (and crash when trying to log in)! SECRET_KEY = '' #: transmit cookie only over https (if you use http, set this to False) SESSION_COOKIE_SECURE = True #: use a permanent session (True, cookie will expire after the given #: time, see below) or not (False, cookie will get removed when browser #: is closed) PERMANENT_SESSION = False #: lifetime of the permanent session (in seconds) PERMANENT_SESSION_LIFETIME = 31 * 24 * 3600 #: Bepasty does **not** use the usual user/password scheme, but **only** #: uses passwords (or passphrases - we'll call both "a secret" below) as #: log-in credentials - there are no separate user names. #: #: People who log-in using such a secret may get more permissions than #: those who do not log-in (who just get DEFAULT_PERMISSIONS). #: #: Depending on the secret used to log-in, they will get the permissions #: configured here, see below. #: #: You can use same secret / same permissions for all privileged users or #: set up different secrets / different permissions for each user. #: #: If you want to be able to revoke permissions for some user / some group #: of users, it might be a good idea to remember to whom you gave which #: secret (and also handle it in a rather fine-grained way). #: #: PERMISSIONS is a dict that maps secrets to permissions, use it like: #: #: :: #: #: PERMISSIONS = { #: 'myadminsecret_1.21d-3!wdar34': 'admin,list,create,modify,read,delete', #: 'uploadersecret_rtghtrbrrrfsd': 'create,read', #: 'joe_doe_89359299887711335537': 'create,read,delete', #: } PERMISSIONS = { # 'foo': 'admin,list,create,modify,read,delete', } #: not-logged-in users get these permissions - #: usually they are either no permissions ('') or read-only ('read'). DEFAULT_PERMISSIONS = '' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/constants.py0000644000076500000240000000064300000000000016326 0ustar00twstaffFILENAME = 'filename' TYPE = 'type' TYPE_HINT = 'type-hint' LOCKED = 'locked' SIZE = 'size' COMPLETE = 'complete' HASH = 'hash' TIMESTAMP_UPLOAD = 'timestamp-upload' TIMESTAMP_DOWNLOAD = 'timestamp-download' TIMESTAMP_MAX_LIFE = 'timestamp-max-life' ID = 'id' # storage name FOREVER = -1 # headers TRANSACTION_ID = 'Transaction-ID' # keep in sync with bepasty-cli # used internally only internal_meta = [TYPE_HINT] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4612863 bepasty-1.2.0/src/bepasty/static/0000755000076500000240000000000000000000000015224 5ustar00twstaff././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4689581 bepasty-1.2.0/src/bepasty/static/app/0000755000076500000240000000000000000000000016004 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/static/app/bepasty.svg0000644000076500000240000001217000000000000020175 0ustar00twstaff image/svg+xml B pasty ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4690804 bepasty-1.2.0/src/bepasty/static/app/css/0000755000076500000240000000000000000000000016574 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/static/app/css/style.css0000644000076500000240000000400300000000000020443 0ustar00twstaff/* base styles -------------------------------------------------- */ html, body { height: 100%; max-width: 100%; margin: 0; -webkit-font-smoothing: antialiased; font-size: 100%; background-color: #fff; } #footer { height: 30px; position: relative; } #formupload { height: 400px; width: 100%; font-family: monospace; } #files p { word-break: break-all; } #files p .break-word { word-break: break-word; font-weight: normal !important; } #filelist { height: auto; width: 100%; font-family: monospace; } .fileupload-abort { float: right; margin-left: 10px; } .dropzone { width: 100%; padding: 3em; } #wrapper { min-height: 100%; margin: 0 auto -30px; padding: 0 0 60px; } #wrapper > .container { padding: 0 15px 60px; } .jumbotron .fa { font-size: 80px; } .jumbotron { font-size: 100%; text-align: center; } .alert { padding: 5px 10px; } .alert-processing { color: #464646; background-color: #f0f0f0; border-color: #eeeeee; } .alert-processing hr { border-top-color: #e6e6e6; } .alert-processing .alert-link { color: #959595; } /* if data is too wide, show scrollbars */ .data { width: 100%; overflow: auto; } .line-highlight { background-color: #ffc; } /* override pygments table highlighting style */ table.highlighttable { width: 100%; border: 1px solid #ccc; background-color: #f5f5f5; } .linenodiv pre, .highlight pre { margin-bottom: 0; -webkit-border-radius: 0; -moz-border-radius: 0; border-radius: 0; padding: 9.5px; } .highlight p { margin: 0; padding: 0 0.7em; } .linenodiv pre { border-right-style: none; } .linenodiv pre a:hover { text-decoration: none; } .highlight pre { padding-left: 0; padding-right: 0; border-left: 1px solid #ccc; } /* Set minimal width and let the contents push the block width */ .linenos { width: 1px; } /* autocomplete for modify dialog */ .ui-autocomplete { z-index: 1065; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/static/app/favicon.ico0000644000076500000240000001027600000000000020133 0ustar00twstaff  ( @ OOzx( B600|v0)(|0?~0A0D&qo0}0,(Q)@h?Y0*K%F60t"WXXXXXc0a09%01$+00K%4Z*0(4rN.H|.4#"444/eEmO*??  '''ggf?././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4695199 bepasty-1.2.0/src/bepasty/static/app/js/0000755000076500000240000000000000000000000016420 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/static/app/js/fileuploader.js0000644000076500000240000001205000000000000021427 0ustar00twstaffjqXHR = {}; $(function () { 'use strict'; // Generate human readable file size function humansize (size) { var suffix = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"], tier = 0; while (size >= 1024) { size = size / 1024; tier++; } return Math.round(size * 10) / 10 + " " + suffix[tier]; } $('#fileupload') .fileupload({ dataType: 'json', autoUpload: true, singleFileUploads: true, maxChunkSize: MAX_BODY_SIZE, maxFileSize: MAX_ALLOWED_FILE_SIZE }) .on('fileuploadadd', function (e, data) { }) .on('fileuploadsubmit', function (e, data) { var $this = $(this); var file = data.files[0] // Create new item $.ajax({ type: 'POST', url: UPLOAD_NEW_URL, data: JSON.stringify({ filename: file.name, size: file.size, type: file.type, maxlife_unit: $("select[name=maxlife-unit] option:selected").val(), maxlife_value: $("input[name=maxlife-value]").val() }), contentType: 'application/json', success: function (result) { data.url = result.url; data.context = $('
') .appendTo('#files'); var abortButton = $('
{% block content %}{% endblock %}
{% block extra_script %}{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/_utils.html0000644000076500000240000001030300000000000020115 0ustar00twstaff{% macro filelist(files) %} {% for file in files %} {% endfor %}
Filename
Type
Size [B]
Thumbnail Upload [UTC]
Download [UTC]
Actions
{{ file['filename'] }}
{{ file['type'] }}
{{ file['size'] }}
{{ file['timestamp-upload'] | datetime }}
{% if file['timestamp-download'] %} {{ file['timestamp-download'] | datetime }} {% else %} never {% endif %}
display download inline {% if may(DELETE) %}
{% endif %} {% if may(ADMIN) %} {% if not file['locked'] %}
{% else %}
{% endif %} {% endif %}
{% endmacro %} {% macro input_filename(value) -%} {%- endmacro %} {% macro input_contenttype(value) -%} {%- endmacro %} {% macro contenttype_autocomplete(selector, contenttypes) -%} var availableTypes = ["{{ contenttypes | join('","') | safe}}"]; {{ selector|safe }}.autocomplete({source: availableTypes}); {%- endmacro %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674405649.0 bepasty-1.2.0/src/bepasty/templates/carousel.html0000644000076500000240000000315100000000000020436 0ustar00twstaff{% extends "_layout.html" %} {% block extra_link %} {% endblock %} {% block content %} {% endblock content %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674405649.0 bepasty-1.2.0/src/bepasty/templates/display.html0000644000076500000240000001262100000000000020270 0ustar00twstaff{% extends "_layout.html" %} {%- import '_utils.html' as utils -%} {% block content %}

{{ item.meta['filename'] }}

{% if not item.meta['locked'] or may(ADMIN) %} {% if is_list_item %} Carousel {% endif %} QR Download Inline {% endif %} {% if may(MODIFY) %}
{{ utils.input_filename(item.meta['filename']) }}
{{ utils.input_contenttype(item.meta['type']) }}
{% endif %} {% if may(DELETE) %}
{% endif %} {% if may(ADMIN) %} {% if not item.meta['locked'] %}
{% else %}
{% endif %} {% endif %}

Type: {{ item.meta['type'] }}, Size: {{ item.meta['size'] }} bytes, SHA256: {{ item.meta['hash'] }}.
UTC timestamps: upload: {{ item.meta['timestamp-upload'] | datetime }}, download: {{ item.meta['timestamp-download'] | datetime }}, {% if item.meta['timestamp-max-life'] > 0 %} max lifetime: {{ item.meta['timestamp-max-life'] | datetime }}. {% else %} max lifetime: forever. {% endif %}

{{ rendered_content }}
{% endblock content %} {% block extra_link %} {% endblock %} {% block extra_script %} {% if may(MODIFY) %} {% endif %} {% endblock extra_script %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/error.html0000644000076500000240000000046700000000000017761 0ustar00twstaff{% extends "_layout.html" %} {% block content %}

{{ heading }}

{{ body }}
{% endblock content %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/filelist.html0000644000076500000240000000032100000000000020430 0ustar00twstaff{% import "_utils.html" as utils %} {% extends "_layout.html" %} {% block content %}
{{ utils.filelist(files) }}
{% endblock content %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/filelist_tableonly.html0000644000076500000240000000010000000000000022474 0ustar00twstaff{% import "_utils.html" as utils %} {{ utils.filelist(files) }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/index.html0000644000076500000240000001544200000000000017736 0ustar00twstaff{% extends "_layout.html" %} {%- import '_utils.html' as utils %} {% macro maximum_lifetime() -%}

{% endmacro %} {% block content %} {% if may(CREATE) %}
{{ utils.input_contenttype() }}
{{ utils.input_filename() }}

{{ maximum_lifetime() }} drag and drop files here - or click to select files
{% else %}

bepasty, the Binary File Upload Site

Free and Nice

A pastebin for all the stuff,
not just for text.

Free and Open Source

bepasty is free and open source software.
bepasty project on github

Awesome Code

Powered by Python and Flask.

{% endif %} {% endblock content %} {% block extra_link %} {% endblock %} {% block extra_script %} {% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/qr.html0000644000076500000240000001170300000000000017245 0ustar00twstaff{% extends "_layout.html" %} {% block extra_link %} {% endblock %} {% block content %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/templates/redirect.html0000644000076500000240000000104700000000000020424 0ustar00twstaff

Redirecting ...

If you do not get redirected automatically in {{ delay }}s, feel free to click the link:

{{ url }}

Warning

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

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

././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4731104 bepasty-1.2.0/src/bepasty/tests/0000755000076500000240000000000000000000000015077 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/__init__.py0000644000076500000240000000000000000000000017176 0ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674406372.0 bepasty-1.2.0/src/bepasty/tests/conftest.py0000644000076500000240000000152700000000000017303 0ustar00twstafffrom os import close, unlink from random import random from tempfile import mkstemp import pytest from bepasty.app import create_app @pytest.fixture(scope='module') def app(request): """ creates a bepasty App-Instance """ app = create_app() yield app unlink(app.config['DATABASE']) @pytest.fixture(scope='module') def testclient(request, app): """ creates a Flask-testclient instance for bepasty """ db_file, app.config['DATABASE'] = mkstemp() # reset default permissions app.config['DEFAULT_PERMISSIONS'] = '' # setup a secret key app.config['SECRET_KEY'] = str(random()) # setup permissions app.config['PERMISSIONS'] = { 'l': 'list', 'c': 'create', 'r': 'read', 'd': 'delete', 'a': 'admin' } yield app.test_client() close(db_file) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/screenshots.py0000644000076500000240000001505100000000000020013 0ustar00twstafffrom selenium.webdriver import Firefox from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import ElementNotInteractableException import pytest import os import time import tempfile @pytest.mark.needs_server class TestScreenShots: url_base = 'http://localhost:5000' # bootstrap4 breakpoints screenshot_dir = 'screenshots' screen_sizes = [(450, 700), (576, 800), (768, 600), (992, 768), (1200, 1024)] screenshot_seq = 1 def setup_class(self): """ Setup: Open a mozilla browser, login """ self.browser = Firefox() self.browser.get(self.url_base + '/invalid') def teardown_class(self): """ Tear down: Close the browser """ self.browser.quit() def wait_present(self, xpath, timeout=10): cond = expected_conditions.presence_of_element_located((By.XPATH, xpath)) WebDriverWait(self.browser, timeout).until(cond) def screen_shot(self, name, w, h): if not os.path.isdir(self.screenshot_dir): os.mkdir(self.screenshot_dir) self.browser.save_screenshot( '{}/{:02d}-{}-{}x{}.png'.format(self.screenshot_dir, self.screenshot_seq, name, w, h) ) def screen_shots(self, name): for w, h in self.screen_sizes: self.browser.set_window_size(w, h) time.sleep(.1) self.screen_shot(name, w, h) def scroll_to_bottom(self): self.set_smallest_window_size() self.browser.execute_script('window.scrollTo(0, document.body.scrollHeight);') def set_smallest_window_size(self): # change smallest screen size w, h = self.screen_sizes[0] self.browser.set_window_size(w, h) time.sleep(.1) def set_largest_window_size(self): # change largest screen size w, h = self.screen_sizes[-1] self.browser.set_window_size(w, h) time.sleep(.1) def toggle_hamburger(self): self.set_smallest_window_size() # toggle hamburger menu try: self.browser.find_element_by_xpath('//button[@class="navbar-toggler"]').click() except ElementNotInteractableException: pass time.sleep(.5) self.set_largest_window_size() def top_screen_shots(self, name): self.screen_shots(f'{name}1') # open hamburger self.toggle_hamburger() self.screen_shots(f'{name}2') # close hamburger self.toggle_hamburger() def error_404(self): # NOTE: 404 error self.screen_shots("error404") self.screenshot_seq += 1 def login(self): self.browser.get(self.url_base) # NOTE: login screen, 1 - close hamburger, 2 - open hamburger self.top_screen_shots("top") self.screenshot_seq += 1 token = self.browser.find_element_by_name("token") password = "foo" # login token.send_keys(password) token.submit() self.wait_present("//input[@value='Logout']") # NOTE: upload screen, 1 - close hamburger, 2 - open hamburger self.top_screen_shots("upload") self.screenshot_seq += 1 try: self.browser.find_element_by_xpath("//input[@value='Logout']") except NoSuchElementException: raise ValueError("Can't login! Please edit your config, go to PERMISSIONS setting " "and add a new secret 'foo' with all permissions.") def upload_file(self, path): # set file path fileupload = self.browser.find_element_by_id('fileupload') fileupload.send_keys(path) form = self.browser.find_element_by_xpath('//form[@action="/+upload"]') form.click() def upload_view(self): # small files for i in (1, 2, 3): with tempfile.NamedTemporaryFile(suffix=".sh") as fp: fp.write(b"""\ #!/bin/sh if [ $# -le 0 ]; then echo "no argument" 2>&1 exit 1 fi echo "hello, world!" """) fp.flush() self.upload_file(fp.name) self.scroll_to_bottom() # NOTE: uploaded screen self.screen_shots("uploading1") # big file with tempfile.NamedTemporaryFile(suffix=".bin") as fp: os.truncate(fp.name, 1024 * 1024 * 1024) self.upload_file(fp.name) self.scroll_to_bottom() # NOTE: in-progress uploading screen self.screen_shots("uploading2") self.screenshot_seq += 1 # click abort abort = self.browser.find_element_by_id('fileupload-abort') abort.click() time.sleep(.5) # NOTE: abort bootbox self.screen_shots("abort") self.screenshot_seq += 1 ok = self.browser.find_element_by_class_name('bootbox-accept') ok.click() self.scroll_to_bottom() # NOTE: aborted upload screen self.screen_shots("uploading3") self.screenshot_seq += 1 def list_view(self): self.browser.get(self.url_base + '/+list') # NOTE: list screen self.screen_shots("list") self.screenshot_seq += 1 def display_view(self): self.browser.get(self.url_base + '/+list') list_link = self.browser.find_elements_by_xpath('//tr/td/a') list_link[0].click() # highlight line self.browser.get(self.browser.current_url + '#L-4') # NOTE: display screen self.screen_shots("display") self.screenshot_seq += 1 modify = self.browser.find_element_by_id('modify-btn') modify.click() time.sleep(.5) # NOTE: modify bootbox self.screen_shots("modify") self.screenshot_seq += 1 modify_cancel = self.browser.find_element_by_class_name('bootbox-cancel') modify_cancel.click() time.sleep(.5) lock = self.browser.find_element_by_id('lock-btn') lock.click() # NOTE: display with lock screen self.screen_shots("lock") self.screenshot_seq += 1 qr = self.browser.find_element_by_id('qr-btn') qr.click() # NOTE: QR code screen self.screen_shots("qr") self.screenshot_seq += 1 def test(self): self.error_404() self.login() self.upload_view() self.list_view() self.display_view() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_app.py0000644000076500000240000000374000000000000017274 0ustar00twstaff# # app tests # from flask import request, url_for from flask.views import MethodView from ..app import create_app from ..config import Config def test_secret_key(monkeypatch): monkeypatch.setattr(Config, 'PERMISSIONS', { 'admin': 'admin,list,create,read,delete', 'full': 'list,create,read,delete', 'none': '', }) monkeypatch.setattr(Config, 'SECRET_KEY', 'secret') app = create_app() secret_key = app.config['SECRET_KEY'] assert len(secret_key) > len(Config.SECRET_KEY) Config.PERMISSIONS = { 'admin': 'admin,list,create,read,delete', 'none': '', } app = create_app() assert app.config['SECRET_KEY'] != secret_key class TestView(MethodView): callback = None def get(self): TestView.callback() return 'done' def prepare(callback): app = create_app() app.add_url_rule('/test_call', view_func=TestView.as_view('test.test_call')) TestView.callback = staticmethod(callback) client = app.test_client() assert app.config['APP_BASE_PATH'] == Config.APP_BASE_PATH return app, client def test_none(monkeypatch): monkeypatch.setattr(Config, 'APP_BASE_PATH', None) def none_callback(): url = url_for('test.test_call') assert url == request.path app, client = prepare(none_callback) response = client.get('/bepasty/test_call') assert response.status_code == 404 response = client.get('/test_call') assert response.status_code == 200 assert response.data == b'done' def test_prefix(monkeypatch): monkeypatch.setattr(Config, 'APP_BASE_PATH', '/bepasty') def prefix_callback(): url = url_for('test.test_call') assert url == Config.APP_BASE_PATH + request.path app, client = prepare(prefix_callback) response = client.get('/test_call') assert response.status_code == 404 response = client.get('/bepasty/test_call') assert response.status_code == 200 assert response.data == b'done' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_data.py0000644000076500000240000000056600000000000017430 0ustar00twstafffrom bepasty.storage.filesystem import Data def test(tmpdir): p = tmpdir.join('test.data') d = Data(p.open('w+b')) assert d.size == 0 d.write(b'a' * 1024, 0) assert d.size == 1024 d.write(b'a' * 1024, 1024 * 3) assert d.size == 1024 * 4 assert d.read(1024, 0) == b'a' * 1024 assert d.read(1024, 1024) == b'\0' * 1024 d.close() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_date_funcs.py0000644000076500000240000000116100000000000020622 0ustar00twstaffimport pytest from bepasty.constants import FOREVER from bepasty.utils.date_funcs import get_maxlife, time_unit_to_sec def test_get_maxlife(): assert get_maxlife({}, underscore=False) == 60 * 60 * 24 * 30 assert get_maxlife({}, underscore=True) == 60 * 60 * 24 * 30 @pytest.mark.parametrize('unit,expectation', [ ('MINUTES', 60), ('HOURS', 60 * 60), ('DAYS', 60 * 60 * 24), ('WEEKS', 60 * 60 * 24 * 7), ('MONTHS', 60 * 60 * 24 * 30), ('YEARS', 60 * 60 * 24 * 365), ('FOREVER', FOREVER), ]) def test_unit_to_secs(unit, expectation): assert time_unit_to_sec(1, unit) == expectation ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_http.py0000644000076500000240000000127600000000000017475 0ustar00twstaffimport pytest from werkzeug.exceptions import BadRequest from bepasty.utils.http import ContentRange def test_contentrange_parse(): r = ContentRange.parse('bytes 0-0/2') assert r.begin == 0 assert r.end == 0 assert r.complete == 2 assert r.size == 1 assert not r.is_complete r = ContentRange.parse('bytes 0-1/2') assert r.begin == 0 assert r.end == 1 assert r.complete == 2 assert r.size == 2 assert r.is_complete with pytest.raises(BadRequest): ContentRange.parse('test 0-1/2') with pytest.raises(BadRequest): ContentRange.parse('bytes 1-0/2') with pytest.raises(BadRequest): ContentRange.parse('bytes 0-2/2') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_item.py0000644000076500000240000000037500000000000017453 0ustar00twstafffrom bepasty.storage.filesystem import Item def test(tmpdir): pm = tmpdir.join("test.meta") pd = tmpdir.join("test.data") with Item(pm.open('w+b'), pd.open('w+b')) as i: assert i.data is not None assert i.meta is not None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_meta.py0000644000076500000240000000156600000000000017446 0ustar00twstafffrom bepasty.storage.filesystem import Meta def test(tmpdir): p = tmpdir.join("test.meta") m = Meta(p.open('w+b')) assert len(m) == 0 m.close() m = Meta(p.open('r+b')) m['flag'] = True assert len(m) == 1 m.close() m = Meta(p.open('r+b')) assert len(m) == 1 assert m['flag'] is True m.close() def test_iter(tmpdir): p = tmpdir.join("test.meta") m = Meta(p.open('w+b')) keys = ["foo", "bar", "baz", ] for key in keys: m[key] = True m.close() m = Meta(p.open('r+b')) assert set(list(m)) == set(keys) m.close() def test_del(tmpdir): p = tmpdir.join("test.meta") key = "foo" m = Meta(p.open('w+b')) m[key] = True m.close() m = Meta(p.open('r+b')) del m[key] m.close() m = Meta(p.open('r+b')) assert len(m) == 0 assert key not in m m.close() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_name.py0000644000076500000240000000571600000000000017441 0ustar00twstaffimport pytest from bepasty.utils.name import ItemName, encode, make_id def test_create(): fake_storage = {} n = ItemName.create(fake_storage) assert n def test_create_many(): fake_storage = {} length = 1 count = 400 # way more than we can do with this name length max_seen_length = 0 for i in range(count): name = ItemName.create(fake_storage, length=length, max_length=length * 4, max_tries=10) # use the name in storage, so it is not available any more fake_storage[name] = None max_seen_length = max(max_seen_length, len(name)) # it should automatically use longer names, if it runs out of unique names: assert max_seen_length > length # we should get all unique names we wanted, no duplicates: assert len(list(fake_storage)) == count def test_make_id_type(): assert isinstance(make_id(2), str) def test_make_id_length(): for length in range(10): assert len(make_id(length)) == length def test_make_id_alphabet(): # id must contain alphabet chars ONLY assert set(make_id(10, alphabet="abc")) <= {'a', 'b', 'c'} def test_make_id_unique(): length, count = 6, 10000 ids = {make_id(length) for i in range(count)} # if we did not encounter duplicates, set size must be # of course, in extremely rare cases, this test might fail assert len(ids) == count def test_encode_length(): length = 42 assert len(encode(12345, length)) == length def test_encode_binary(): assert encode(0, 0, "01") == [] # zero length assert encode(1, 0, "01") == [] # zero length assert encode(0, 1, "01") == ['0'] # length match assert encode(1, 1, "01") == ['1'] # length match assert encode(0, 2, "01") == ['0', '0'] # leading zeroes assert encode(1, 2, "01") == ['0', '1'] # leading zeroes assert encode(2, 2, "01") == ['1', '0'] # length match assert encode(3, 2, "01") == ['1', '1'] # length match assert encode(4, 2, "01") == ['0', '0'] # overflow truncated def test_encode_special(): # equivalent to binary, but we see the code is rather flexible assert encode(0, 2, ".+") == ['.', '.'] assert encode(1, 2, ".+") == ['.', '+'] assert encode(2, 2, ".+") == ['+', '.'] assert encode(3, 2, ".+") == ['+', '+'] def test_encode_decimal(): assert encode(123, 3, "0123456789") == ['1', '2', '3'] # length match assert encode(456, 4, "0123456789") == ['0', '4', '5', '6'] # leading zeroes assert encode(7890, 3, "0123456789") == ['8', '9', '0'] # overflow truncated def test_encode_hex(): assert encode(31, 2, "0123456789ABCDEF") == ['1', 'F'] def test_encode_errors(): with pytest.raises(ValueError): # ValueError: alphabet must be at least 2 elements long encode(1, 1, "0") with pytest.raises(ValueError): # ValueError: length must be >= 0 encode(1, -1, "0") with pytest.raises(ValueError): # ValueError: negative values are not supported encode(-1, 1, "0") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684790435.0 bepasty-1.2.0/src/bepasty/tests/test_rest_server.py0000644000076500000240000010732400000000000021062 0ustar00twstaff# # REST api tests # import os import base64 import threading import time import copy import hashlib import re from requests.auth import _basic_auth_str from flask import current_app, url_for, json import pytest from ..app import create_app from ..config import Config from ..constants import FILENAME, TYPE, LOCKED, SIZE, COMPLETE, HASH, \ TIMESTAMP_DOWNLOAD, TIMESTAMP_UPLOAD, TIMESTAMP_MAX_LIFE, TRANSACTION_ID from ..utils.date_funcs import get_maxlife UPLOAD_DATA = b"""\ #!/usr/bin/python3 print('hello, world') """ class FakeTime: """Overwrite time.time to control the timestamp that is used in server""" def __init__(self, now=None): self.orig = time.time self.now = now def set_time(self, now=None): self.now = now if now and time.time != self.get_time: time.time = self.get_time elif now is None and time.time == self.get_time: time.time = self.orig def get_time(self): return self.now def __enter__(self): self.set_time(self.now) return self def __exit__(self, exc_type, exc_value, traceback): self.set_time(None) def wait_background(): """Wait until background threads terminate.""" main_thread = threading.current_thread() for t in threading.enumerate(): if t is not main_thread: t.join() @pytest.fixture def client_fixture(tmp_path): with FakeTime() as faketime: Config.PERMISSIONS = { 'admin': 'admin,list,create,modify,read,delete', 'full': 'list,create,modify,read,delete', 'none': '', } Config.STORAGE_FILESYSTEM_DIRECTORY = str(tmp_path) app = create_app() app.config['TESTING'] = True with app.test_client() as client: with app.app_context(): yield app, client, faketime wait_background() class TmpConfig: def __init__(self, app, tmp_config=None): self.config = app.config self.orig_config = copy.deepcopy(app.config) self.tmp_config = {} if tmp_config is None else tmp_config def __enter__(self): self.config.update(self.tmp_config) def __exit__(self, exc_type, exc_value, traceback): self.config.update(self.orig_config) class RestUrl: def __init__(self, item_id=None): self.item_id = item_id @property def config(self): with current_app.test_request_context(): return url_for('bepasty_apis.api_info') @property def upload(self): with current_app.test_request_context(): return url_for('bepasty_apis.items') @property def list(self): return self.upload @property def detail(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_detail', name=self.item_id) @property def download(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_download', name=self.item_id) @property def delete(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_delete', name=self.item_id) @property def modify(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_modify', name=self.item_id) @property def lock(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_lock', name=self.item_id) @property def unlock(self): with current_app.test_request_context(): return url_for('bepasty_apis.items_unlock', name=self.item_id) def add_auth(user, password, headers=None): headers = headers if headers is not None else {} headers['Authorization'] = _basic_auth_str(user, password) return headers def test_invalid_url(client_fixture): _, client, _ = client_fixture with client.get('/apis/rest/invalid') as response: assert response.status_code == 404 def check_response(response, code, ftype='application/json', check_data=True): assert response.status_code == code assert response.headers['Content-Type'] == ftype if check_data: assert int(response.headers['Content-Length']) == len(response.data) def check_err_response(response, code, check_data=True): check_response(response, code, check_data=check_data) if check_data: assert code == response.json['error']['code'] # check if doesn't have html tag assert not re.match(r'<.+>', response.data.decode()) def check_data_response(response, meta, data, offset=0, total_size=None, check_data=True): ftype = meta['file-meta'][TYPE] filename = meta['file-meta'][FILENAME] if total_size is None: total_size = len(data) disposition = f'attachment; filename="{filename}"' range_str = 'bytes {}-{}/{}'.format(offset, offset + len(data) - 1, total_size) check_response(response, 200, ftype, check_data) assert response.headers['Content-Disposition'] == disposition assert response.headers['Content-Range'] == range_str if check_data: assert data == response.data def check_json_response(response, metas, code=200, check_data=True): check_response(response, code, check_data=check_data) if check_data: assert metas == response.json def check_upload_response(response, code=201, check_data=True): check_json_response(response, {}, code=code, check_data=check_data) if code == 200: assert len(response.headers[TRANSACTION_ID]) > 0 return None url_prefix = RestUrl().upload + '/' assert response.headers['Content-Location'].startswith(url_prefix) return response.headers['Content-Location'] def test_auth(client_fixture): _, client, _ = client_fixture url = RestUrl() # basic auth (unknown user) with client.get(url.list, headers=add_auth('user', 'invalid')) as response: check_err_response(response, 403) # basic auth (valid user) with client.get(url.list, headers=add_auth('user', 'full')) as response: check_response(response, 200) # token auth (unknown user) with client.get(url.list + '?token=invalid') as response: check_err_response(response, 403) # token auth (valid user) with client.get(url.list + '?token=full') as response: check_response(response, 200) def test_config(client_fixture): app, client, _ = client_fixture url = RestUrl() # get server config (post should fail) with client.post(url.config) as response: check_err_response(response, 405) # get server config with client.get(url.config) as response: check_response(response, 200) assert len(response.json) == 2 assert response.json['MAX_ALLOWED_FILE_SIZE'] == app.config['MAX_ALLOWED_FILE_SIZE'] assert response.json['MAX_BODY_SIZE'] == app.config['MAX_BODY_SIZE'] # get server config (head) with client.head(url.config) as response: check_response(response, 200, check_data=False) def _upload(client, data, token=None, filename=None, ftype=None, lifetime=None, range_str=None, trans_id=None, encode=True, set_range=True): if data: payload = base64.b64encode(data) if encode else data payload_len = len(payload) else: payload = None payload_len = 0 headers = { 'Content-Length': str(payload_len), } if set_range: if range_str is None: range_str = f'bytes 0-{payload_len - 1}/{payload_len}' headers['Content-Range'] = range_str if filename is not None: headers['Content-Filename'] = filename if ftype is not None: headers['Content-Type'] = ftype if lifetime: headers['Maxlife-Value'] = str(lifetime[0]) headers['Maxlife-Unit'] = lifetime[1] if trans_id is not None: headers[TRANSACTION_ID] = trans_id if token is not None: add_auth('user', token, headers) response = client.post(RestUrl().upload, headers=headers, data=payload) # FIXME: without waiting for background hash compute, following # detail request may not have hash yet wait_background() return response def make_meta(data, filename=None, ftype=None, lifetime=None, uri=None): h = hashlib.sha256() h.update(data) if lifetime is None: # default maxlife is 1 MONTHS lifetime = [1, 'MONTHS'] maxtime = get_maxlife({ 'maxlife_value': lifetime[0], 'maxlife_unit': lifetime[1] }, True) maxlife = int(time.time()) + maxtime if maxtime > 0 else maxtime meta = { 'file-meta': { COMPLETE: True, FILENAME: filename, HASH: h.hexdigest(), LOCKED: False, SIZE: len(data), TIMESTAMP_DOWNLOAD: 0, TIMESTAMP_MAX_LIFE: maxlife, TIMESTAMP_UPLOAD: int(time.time()), TYPE: ftype, }, 'uri': uri, } return meta def upload(client, data, token=None, filename=None, ftype=None, lifetime=None): with _upload(client, data, token, filename, ftype, lifetime) as response: uri = check_upload_response(response) return make_meta(data, filename, ftype, lifetime, uri) def upload_files(client): datas = {} metas = {} for i in (1, 2): data = """\ #!/usr/bin/python3 print('hello,world {}') """.format(i).encode() filename = f'{i}-test.py' lifetime = [1, 'YEARS'] ftype = 'text/x-python' with FakeTime(int(time.time()) + i): meta = upload(client, data, token='full', filename=filename, ftype=ftype, lifetime=lifetime) item_id = os.path.basename(meta['uri']) datas[item_id] = data metas[item_id] = meta assert len(datas) == 2 assert len(metas) == 2 return datas, metas def check_detail_or_download(app, client, item_id, meta, data, download=False): url = RestUrl(item_id=item_id) url = url.download if download else url.detail # post should fail with client.post(url) as response: check_err_response(response, 405) # get with default (no permission) with client.get(url) as response: check_err_response(response, 403) # head with default (no permission) with client.head(url) as response: check_err_response(response, 403, check_data=False) with TmpConfig(app, {'DEFAULT_PERMISSIONS': 'read'}): # head with default (has permission) with client.head(url) as response: if download: check_data_response(response, meta, data, check_data=False) else: check_json_response(response, meta, check_data=False) # get with default (has permission) with client.get(url) as response: if download: check_data_response(response, meta, data) else: check_json_response(response, meta) # get with no permission with client.get(url, headers=add_auth('user', 'none')) as response: check_err_response(response, 403) # head with no permission with client.head(url, headers=add_auth('user', 'none')) as response: check_err_response(response, 403, check_data=False) # head with permission with client.head(url, headers=add_auth('user', 'full')) as response: if download: check_data_response(response, meta, data, check_data=False) else: check_json_response(response, meta, check_data=False) # get with permission with client.get(url, headers=add_auth('user', 'full')) as response: if download: check_data_response(response, meta, data) else: check_json_response(response, meta) def check_expired(client, item_id, download=False): url = RestUrl(item_id=item_id) url = url.download if download else url.detail # expired items with client.get(url, headers=add_auth('user', 'full')) as response: check_err_response(response, 404) def test_upload_basic(client_fixture): _, client, _ = client_fixture # simple upload without permission with _upload(client, UPLOAD_DATA, token='none', filename='test.py', ftype='text/x-python', lifetime=[1, 'FOREVER']) as response: check_err_response(response, 403) # simple upload with permission upload(client, UPLOAD_DATA, token='full', filename='test.py', ftype='text/x-python', lifetime=[1, 'FOREVER']) def test_upload_params(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) for filename, ftype in ((None, None), ('test.py', None), (None, 'text/plain')): # upload without filename and/or ftype meta = upload(client, UPLOAD_DATA, token='full', filename=filename, ftype=ftype) item_id = os.path.basename(meta['uri']) url = RestUrl(item_id) # get meta for uploaded item with client.get(url.detail, headers=add_auth('user', 'full')) as response: check_json_response(response, None, check_data=False) assert len(response.json) > 0 # copy generated meta if filename: assert response.json['file-meta'][FILENAME] == filename else: assert len(response.json['file-meta'][FILENAME]) > 0 meta['file-meta'][FILENAME] = response.json['file-meta'][FILENAME] if ftype: assert response.json['file-meta'][TYPE] == ftype else: assert len(response.json['file-meta'][TYPE]) > 0 meta['file-meta'][TYPE] = response.json['file-meta'][TYPE] check_detail_or_download(app, client, item_id, meta, UPLOAD_DATA) check_detail_or_download(app, client, item_id, meta, UPLOAD_DATA, download=True) def test_upload_range(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) filename = 'test.py' ftype = 'text/x-python' sep = 10 data = UPLOAD_DATA[:sep] # (invalid no Content-Range:) with _upload(client, data, token='full', filename=filename, ftype=ftype, set_range=False) as response: check_err_response(response, 400) # Content-Range: invalid (invalid format) range_str = 'invalid' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes invalid (invalid format) range_str = 'bytes invalid' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: other 0-/ (invalid unit) range_str = f'other {0}-{sep - 1}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes invalid-/ (invalid first) range_str = f'bytes invalid-{sep - 1}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes 0-invalid/ (invalid last) range_str = f'bytes {0}-invalid/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes -0/ (invalid first > last) range_str = f'bytes {sep - 1}-{0}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes 0-/* (not supported) range_str = f'bytes {0}-{sep - 1}/*' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes */ (not supported) range_str = f'bytes */{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # Content-Range: bytes */* (not supported) range_str = 'bytes */*' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_err_response(response, 400) # upload first part of data range_str = f'bytes {0}-{sep - 1}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_upload_response(response, 200) # upload remainder of data data = UPLOAD_DATA[sep:] range_str = 'bytes {}-{}/{}'.format(sep, len(UPLOAD_DATA) - 1, len(UPLOAD_DATA)) with _upload(client, data, token='full', range_str=range_str, trans_id=response.headers[TRANSACTION_ID]) as response: uri = check_upload_response(response) item_id = os.path.basename(uri) # check a uploaded item meta = make_meta(UPLOAD_DATA, filename=filename, ftype=ftype, uri=uri) check_detail_or_download(app, client, item_id, meta, UPLOAD_DATA) check_detail_or_download(app, client, item_id, meta, UPLOAD_DATA, download=True) # upload again with item.data.size != start position (invalid position) data = UPLOAD_DATA[sep + 1:] range_str = 'bytes {}-{}/{}'.format(sep + 1, len(UPLOAD_DATA) - 1, len(UPLOAD_DATA)) with _upload(client, data, token='full', range_str=range_str, trans_id=response.headers[TRANSACTION_ID]) as response: check_err_response(response, 409) def test_bad_data(client_fixture): app, client, _ = client_fixture filename = 'test.py' ftype = 'text/x-python' # upload without Content-Length range_str = f'bytes 0-{len(UPLOAD_DATA) - 1}/{len(UPLOAD_DATA)}' with _upload(client, None, token='full', filename=filename, ftype=ftype, range_str=range_str, encode=False) as response: check_err_response(response, 400) # upload invalid base64 encode range_str = f'bytes 0-{len(UPLOAD_DATA) - 1}/{len(UPLOAD_DATA)}' with _upload(client, UPLOAD_DATA, token='full', filename=filename, ftype=ftype, range_str=range_str, encode=False) as response: check_err_response(response, 400) # server must not have left garbage files assert len(os.listdir(app.config['STORAGE_FILESYSTEM_DIRECTORY'])) == 0 def test_upload_maxlife(client_fixture): """upload maxlife""" _, client, _ = client_fixture lifetime = ['foo', 'MINUTES'] with _upload(client, UPLOAD_DATA, token='full', lifetime=lifetime) as response: assert response.status_code == 400 lifetime = ['1', 'foo'] with _upload(client, UPLOAD_DATA, token='full', lifetime=lifetime) as response: assert response.status_code == 400 def test_upload_transaction_id(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) sep = 10 data = UPLOAD_DATA[sep:] range_str = 'bytes {}-{}/{}'.format(sep, len(UPLOAD_DATA) - 1, len(UPLOAD_DATA)) # upload with invalid Transaction-ID trans_id = 'invalid' with _upload(client, data, token='full', range_str=range_str, trans_id=trans_id) as response: check_err_response(response, 400) # upload with Transaction-ID for invalid filename trans_id = base64.b64encode(b'invalid').decode() with _upload(client, data, token='full', range_str=range_str, trans_id=trans_id) as response: check_err_response(response, 400) def test_upload_too_big(client_fixture): app, client, _ = client_fixture # upload too big size size = 10000 with TmpConfig(app, {'MAX_ALLOWED_FILE_SIZE': size}): ftype = 'text/x-bepasty-list' data = bytes(b'a' * (size + 10)) with _upload(client, data, token='full', ftype=ftype) as response: check_err_response(response, 413) def test_list_basic(client_fixture): app, client, faketime = client_fixture url = RestUrl() faketime.set_time(100) datas, metas = upload_files(client) # list with default (no permission) with client.get(url.list) as response: check_err_response(response, 403) with client.head(url.list) as response: check_err_response(response, 403, check_data=False) # list with default (has permission) with TmpConfig(app, {'DEFAULT_PERMISSIONS': 'list'}): with client.get(url.list) as response: check_json_response(response, metas) with client.head(url.list) as response: check_json_response(response, metas, check_data=False) # list with no permission with client.get(url.list, headers=add_auth('user', 'none')) as response: check_err_response(response, 403) with client.head(url.list, headers=add_auth('user', 'none')) as response: check_err_response(response, 403, check_data=False) # list with permission with client.get(url.list, headers=add_auth('user', 'full')) as response: check_json_response(response, metas) with client.head(url.list, headers=add_auth('user', 'full')) as response: check_json_response(response, metas, check_data=False) # adjust time to exceed lifetime faketime.set_time(3600 * 24 * 365 * 2) # list for expired items with client.get(url.list, headers=add_auth('user', 'full')) as response: check_json_response(response, {}) def test_detail_basic(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) datas, metas = upload_files(client) for item_id in metas.keys(): data = datas[item_id] meta = metas[item_id] check_detail_or_download(app, client, item_id, meta, data) # detail of expired item faketime.set_time(3600 * 24 * 365 * 2) check_expired(client, item_id) # check again (should be deleted already) faketime.set_time(100) check_expired(client, item_id) def test_download_basic(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) datas, metas = upload_files(client) for item_id in metas.keys(): data = datas[item_id] meta = metas[item_id] check_detail_or_download(app, client, item_id, meta, data, download=True) # download of expired item faketime.set_time(3600 * 24 * 365 * 2) check_expired(client, item_id, download=True) # check again (should be deleted already) faketime.set_time(100) check_expired(client, item_id) def test_download_range(client_fixture): _, client, faketime = client_fixture faketime.set_time(100) datas, metas = upload_files(client) for item_id in metas.keys(): data = datas[item_id] meta = metas[item_id] url = RestUrl(item_id=item_id) headers = add_auth('user', 'full') # Range: other (invalid format) offset = 0 limit = 10 headers['Range'] = 'other' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=invalid (invalid format) offset = 0 limit = 10 headers['Range'] = 'bytes=invalid' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: other=0-10 (invalid unit) offset = 0 limit = 10 headers['Range'] = f'other={offset}-{limit - 1}' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=invalid-10 (invalid first) offset = 0 limit = 10 headers['Range'] = f'bytes=invalid-{limit - 1}' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=0-invalid (invalid last) offset = 0 limit = 10 headers['Range'] = f'other={offset}-invalid' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=10-0 (invalid first > last) offset = 0 limit = 10 headers['Range'] = f'bytes={limit - 1}-{offset}' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=0-9,10- (not supported for now) offset = 0 limit = len(data) headers['Range'] = f'bytes={offset}-9,10-{limit - 1}' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=0-10 offset = 0 limit = 10 headers['Range'] = f'bytes={offset}-{limit - 1}' with client.get(url.download, headers=headers) as response: check_data_response(response, meta, data[offset:limit], offset=offset, total_size=len(data)) # Range: bytes=-9 # FIXME: suffix-byte-range-spec is not supported for now offset = 0 limit = 10 headers['Range'] = f'bytes=-{limit - 1}' with client.get(url.download, headers=headers) as response: check_err_response(response, 400) # Range: bytes=10- offset = 10 limit = len(data) headers['Range'] = f'bytes={offset}-{limit - 1}' with client.get(url.download, headers=headers) as response: check_data_response(response, meta, data[offset:limit], offset=offset, total_size=len(data)) # Range: bytes=10- offset = 10 limit = len(data) headers['Range'] = f'bytes={offset}-' with client.get(url.download, headers=headers) as response: check_data_response(response, meta, data[offset:limit], offset=offset, total_size=len(data)) def test_modify(client_fixture): app, client, _ = client_fixture meta = upload(client, UPLOAD_DATA, token='full', filename='test.py', ftype='text/x-python', lifetime=[1, 'FOREVER']) item_id = os.path.basename(meta['uri']) url = RestUrl(item_id) check_detail_or_download(app, client, item_id, meta, None) headers = {'Content-Type': 'application/json'} # no permission with client.post(url.modify, headers=headers, data='{}') as response: check_err_response(response, 403) headers = add_auth('user', 'full', headers) # invalid name with client.post(RestUrl('abcdefgh').modify, headers=headers, data='{}') as response: check_err_response(response, 404) # invalid Content-Type with client.post(url.modify, headers=add_auth('user', 'full'), data='{}') as response: check_err_response(response, 415) # invalid json with client.post(url.modify, headers=headers, data='') as response: check_err_response(response, 400) # change filename filename = 'test2.py' meta['file-meta'][FILENAME] = filename data = json.dumps({FILENAME: filename}) with client.post(url.modify, headers=headers, data=data) as response: check_json_response(response, {}) check_detail_or_download(app, client, item_id, meta, None) # change type content_type = 'text/plain' meta['file-meta'][TYPE] = content_type data = json.dumps({TYPE: content_type}) with client.post(url.modify, headers=headers, data=data) as response: check_json_response(response, {}) check_detail_or_download(app, client, item_id, meta, None) def test_delete_basic(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) datas, metas = upload_files(client) url = RestUrl('abcdefgh') # delete ENOENT item with client.post(url.delete, headers=add_auth('user', 'admin')) as response: check_err_response(response, 404) for item_id in metas.keys(): url = RestUrl(item_id) # no permission with client.post(url.delete, headers=add_auth('user', 'invalid')) as response: check_err_response(response, 403) # has permission with client.post(url.delete, headers=add_auth('user', 'full')) as response: check_json_response(response, {}) # should already be deleted with client.post(url.delete, headers=add_auth('user', 'full')) as response: check_err_response(response, 404) def test_lock_basic(client_fixture): app, client, faketime = client_fixture faketime.set_time(100) datas, metas = upload_files(client) url = RestUrl('abcdefgh') for u in (url.lock, url.unlock): # lock/unlock ENOENT item with client.post(u, headers=add_auth('user', 'admin')) as response: check_err_response(response, 404) for item_id in metas.keys(): url = RestUrl(item_id) for u in (url.lock, url.unlock): # lock/unlock, no permission (invalid user) with client.post(u, headers=add_auth('user', 'invalid')) as response: check_err_response(response, 403) # lock/unlock, no permission (not admin) with client.post(u, headers=add_auth('user', 'full')) as response: check_err_response(response, 403) # lock/unlock, has permission with client.post(u, headers=add_auth('user', 'admin')) as response: check_json_response(response, {}) # lock item with client.post(url.lock, headers=add_auth('user', 'admin')) as response: check_json_response(response, {}) # download locked item (should fail) with client.get(url.download, headers=add_auth('user', 'full')) as response: check_err_response(response, 403) # download locked item with admin (should succeed) with client.get(url.download, headers=add_auth('user', 'admin')) as response: check_data_response(response, metas[item_id], datas[item_id]) # modify locked item (should fail) headers = add_auth('user', 'full', {'Content-Type': 'application/json'}) with client.post(url.modify, headers=headers, data='{}') as response: check_err_response(response, 403) # modify locked item with admin (should succeed) headers = add_auth('user', 'admin', {'Content-Type': 'application/json'}) with client.post(url.modify, headers=headers, data='{}') as response: check_json_response(response, {}) # delete locked item (should fail) with client.post(url.delete, headers=add_auth('user', 'full')) as response: check_err_response(response, 403) # delete locked item with admin (should succeed) with client.post(url.delete, headers=add_auth('user', 'admin')) as response: check_json_response(response, {}) # deleted item with client.post(url.delete, headers=add_auth('user', 'admin')) as response: check_err_response(response, 404) def test_incomplete(client_fixture): _, client, _ = client_fixture filename = 'test.py' ftype = 'text/x-python' # upload a half of data to make incomplete sep = 10 data = UPLOAD_DATA[:sep] range_str = f'bytes {0}-{sep - 1}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_upload_response(response, 200) assert len(response.headers[TRANSACTION_ID]) > 0 # get incomplete item from list url = RestUrl() headers = add_auth('user', 'full') with client.get(url.list, headers=headers) as response: print(f'{response}') check_response(response, 200) assert len(response.json) == 1 item_id = list(response.json.keys())[0] url = RestUrl(item_id) # detail should error with incomplete with client.get(url.detail, headers=add_auth('user', 'full')) as response: check_err_response(response, 409) # download should error with incomplete with client.get(url.download, headers=add_auth('user', 'full')) as response: check_err_response(response, 409) # modify should error with incomplete headers = add_auth('user', 'full', {'Content-Type': 'application/json'}) with client.post(url.modify, headers=headers, data='{}') as response: check_err_response(response, 409) # lock should error with incomplete with client.post(url.lock, headers=add_auth('user', 'admin')) as response: check_err_response(response, 409) # unlock should error with incomplete with client.post(url.unlock, headers=add_auth('user', 'admin')) as response: check_err_response(response, 409) # delete should error with incomplete with client.post(url.delete, headers=add_auth('user', 'full')) as response: check_err_response(response, 409) # delete by admin with incomplete should succeed with client.post(url.delete, headers=add_auth('user', 'admin')) as response: check_json_response(response, {}) def test_magic(client_fixture): app, client, _ = client_fixture try: import magic assert magic is not None # suppress flake8 warning except ImportError: pytest.skip("skipping test, no python-magic installed") else: with TmpConfig(app, {'USE_PYTHON_MAGIC': True}): filename = None ftype = None # upload a half of data to make incomplete with auto mime # detection (meta has 'type-hint' internally) sep = 10 data = UPLOAD_DATA[:sep] range_str = f'bytes {0}-{sep - 1}/{len(UPLOAD_DATA)}' with _upload(client, data, token='full', filename=filename, ftype=ftype, range_str=range_str) as response: check_upload_response(response, 200) assert len(response.headers[TRANSACTION_ID]) > 0 trans_id = response.headers[TRANSACTION_ID] # get incomplete item from list url = RestUrl() headers = add_auth('user', 'full') with client.get(url.list, headers=headers) as response: check_response(response, 200) assert len(response.json) == 1 fid = list(response.json.keys())[0] meta = list(response.json.values())[0] # 'type-hint' should be invisible assert meta['file-meta'] is not None assert meta['file-meta'].get('type-hint') is None # 'type' is 'application/octet-stream' for now assert meta['file-meta'][TYPE] == 'application/octet-stream' # complete upload data = UPLOAD_DATA[sep:] range_str = 'bytes {}-{}/{}'.format(sep, len(UPLOAD_DATA) - 1, len(UPLOAD_DATA)) with _upload(client, data, token='full', range_str=range_str, trans_id=trans_id) as response: check_upload_response(response) # get detail to check python-magic auto detection url = RestUrl(fid) with client.get(url.detail, headers=add_auth('user', 'full')) as response: check_json_response(response, None, check_data=False) assert response.json['file-meta'] is not None assert response.json['file-meta'][TYPE] in ('text/x-python', 'text/x-script.python') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_storage.py0000644000076500000240000000167700000000000020167 0ustar00twstaffimport pytest from bepasty.storage.filesystem import Storage def test_contains(tmpdir): storage = Storage(str(tmpdir)) name = "foo" # check if it is not there yet assert name not in storage with storage.create(name, 0): # we just want it created, no need to write sth into it pass # check if it is there assert name in storage storage.remove(name) # check if it is gone assert name not in storage def test_iter(tmpdir): storage = Storage(str(tmpdir)) # nothing there yet assert list(storage) == [] names = ["foo", "bar", "baz", ] for name in names: with storage.create(name, 0): # we just want it created, no need to write sth into it pass assert set(list(storage)) == set(names) def test_invalid_name(tmpdir): storage = Storage(str(tmpdir)) name = "../invalid" with pytest.raises(RuntimeError): storage.create(name, 0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/tests/test_website.py0000644000076500000240000001006500000000000020154 0ustar00twstafffrom selenium.webdriver import Firefox from selenium.webdriver.common.keys import Keys from selenium.common.exceptions import NoSuchElementException import pytest import time @pytest.mark.needs_server class TestMaxlifeFeature: """ Checks if the maxlife feature is working """ def setup_class(self): """ Setup: Open a mozilla browser, login """ self.browser = Firefox() self.browser.get('http://localhost:5000/') token = self.browser.find_element_by_name("token") password = "foo" # login token.send_keys(password) token.send_keys(Keys.ENTER) time.sleep(.1) try: self.browser.find_element_by_xpath("//input[@value='Logout']") except NoSuchElementException: raise ValueError("Can't login!!! Create a user 'foo' with the permissions" "'read' and 'create' in your PERMISSIONS in the config") def teardown_class(self): """ Tear down: Close the browser """ self.browser.quit() @property def page_body_lowercase(self): return self.browser.find_element_by_tag_name("body").text.lower() def test_unit_input_exists(self): unit_input = self.browser.find_element_by_name("maxlife-unit") assert unit_input is not None value_input = self.browser.find_element_by_name("maxlife-value") assert value_input is not None def fill_form(self): """ Fills test values to the form and submits it :return: tuple(filename, pasted_text) """ filename = "test.txt" text_to_paste = "This is test" paste_input = self.browser.find_element_by_id("formupload") paste_input.send_keys(text_to_paste) filename_input = self.browser.find_element_by_id("filename") filename_input.send_keys(filename) contenttype_input = self.browser.find_element_by_id("contenttype") contenttype_input.send_keys("text/plain") contenttype_input.send_keys(Keys.ENTER) time.sleep(.2) # give some time to render next view return filename, text_to_paste def delete_current_file(self): self.browser.find_element_by_id("del-btn").click() time.sleep(.2) self.browser.find_element_by_class_name("bootbox-accept").click() def test_paste_keep_forever(self): self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='forever']").click() value_input = self.browser.find_element_by_name("maxlife-value") value_input.clear() value_input.send_keys(1) self.fill_form() assert "max lifetime: forever" in self.page_body_lowercase self.delete_current_file() def test_paste_keep_minutes(self): self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='minutes']").click() value_input = self.browser.find_element_by_name("maxlife-value") value_input.clear() value_input.send_keys(1) self.fill_form() assert "max lifetime: forever" not in self.page_body_lowercase self.delete_current_file() def test_filename_gets_displayed(self): filename, _ = self.fill_form() assert filename.lower() in self.page_body_lowercase self.delete_current_file() def test_pasted_text_gets_displayed(self): _, pasted_text = self.fill_form() self.browser.find_element_by_id("inline-btn").click() assert pasted_text.lower() in self.page_body_lowercase self.browser.back() self.delete_current_file() @pytest.mark.slow def test_file_gets_deleted_after_expiry_time(self): self.browser.find_element_by_xpath("//select[@name='maxlife-unit']/option[@value='minutes']").click() value_input = self.browser.find_element_by_name("maxlife-value") value_input.clear() value_input.send_keys(1) self.fill_form() time.sleep(61) self.browser.find_element_by_id("inline-btn").click() assert "not found" in self.page_body_lowercase ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4746397 bepasty-1.2.0/src/bepasty/utils/0000755000076500000240000000000000000000000015075 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/__init__.py0000644000076500000240000000000000000000000017174 0ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/date_funcs.py0000644000076500000240000000312300000000000017561 0ustar00twstaffimport time from flask import current_app from werkzeug.exceptions import BadRequest from ..constants import FOREVER, TIMESTAMP_MAX_LIFE def get_maxlife(data, underscore): unit_key = 'maxlife_unit' if underscore else 'maxlife-unit' unit_default = 'MONTHS' unit = data.get(unit_key, unit_default).upper() value_key = 'maxlife_value' if underscore else 'maxlife-value' value_default = '1' try: value = int(data.get(value_key, value_default)) except (ValueError, TypeError): raise BadRequest(description=f'{value_key} header is incorrect') try: return time_unit_to_sec(value, unit) except KeyError: raise BadRequest(description=f'{unit_key} header is incorrect') def time_unit_to_sec(value, unit): """ Converts a numeric value and with a string time unit unit to a time in seconds :param value: int :param unit: str in ['MINUTES', 'HOURS', 'DAYS', 'WEEKS', 'MONTHS', 'YEARS', 'FOREVER'] :return: time in seconds """ units = { 'MINUTES': 60, 'HOURS': 60 * 60, 'DAYS': 60 * 60 * 24, 'WEEKS': 60 * 60 * 24 * 7, 'MONTHS': 60 * 60 * 24 * 30, 'YEARS': 60 * 60 * 24 * 365, 'FOREVER': FOREVER, } secs = units[unit] * value if units[unit] > 0 else units[unit] return secs def delete_if_lifetime_over(item, name): """ :return: True if file was deleted """ if 0 < item.meta[TIMESTAMP_MAX_LIFE] < time.time(): try: current_app.storage.remove(name) except OSError: pass return True return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/decorators.py0000644000076500000240000000047100000000000017616 0ustar00twstafffrom threading import Thread def threaded(func): """ decorator to run a function asynchronously (in a thread) be careful: do not access flask threadlocals in f! """ def wrapper(*args, **kwargs): t = Thread(target=func, args=args, kwargs=kwargs) t.start() return wrapper ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674337727.0 bepasty-1.2.0/src/bepasty/utils/formatters.py0000644000076500000240000000476400000000000017650 0ustar00twstafffrom pygments.formatters.html import HtmlFormatter class CustomHtmlFormatter(HtmlFormatter): """Custom HTML formatter. Adds an option to wrap lines into HTML

tags.""" def __init__(self, **options): super().__init__(**options) self.lineparagraphs = options.get('lineparagraphs', '') def _wrap_lineparagraphs(self, inner): """Wrap lines into

tags :param inner: iterator of tuples of format (code, text) :return: iterator of tuples containing updated wrapped lines """ s = self.lineparagraphs i = self.linenostart - 1 for t, line in inner: if t: i += 1 yield 1, '

%s

' % (s, i, line) else: yield 0, line def format_unencoded(self, tokensource, outfile): """ The formatting process uses several nested generators; which of them are used is determined by the user's options. Each generator should take at least one argument, ``inner``, and wrap the pieces of text generated by this. Always yield 2-tuples: (code, text). If "code" is 1, the text is part of the original tokensource being highlighted, if it's 0, the text is some piece of wrapping. This makes it possible to use several different wrappers that process the original source linewise, e.g. line number generators. """ source = self._format_lines(tokensource) # As a special case, we wrap line numbers before line highlighting # so the line numbers get wrapped in the highlighting tag. if not self.nowrap and self.linenos == 2: source = self._wrap_inlinelinenos(source) if self.hl_lines: source = self._highlight_lines(source) if not self.nowrap: if self.lineanchors: source = self._wrap_lineanchors(source) if self.linespans: source = self._wrap_linespans(source) # vvv customization of bepasty start: if self.lineparagraphs: source = self._wrap_lineparagraphs(source) # ^^^ customization of bepasty end. source = self.wrap(source) if self.linenos == 1: source = self._wrap_tablelinenos(source) source = self._wrap_div(source) if self.full: source = self._wrap_full(source, outfile) for t, piece in source: outfile.write(piece) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/hashing.py0000644000076500000240000000054400000000000017073 0ustar00twstafffrom hashlib import sha256 as hash_new SIZE = 1024 * 1024 def compute_hash(data, size): """ compute hash of storage item's data file, return hexdigest """ hasher = hash_new() offset = 0 while offset < size: buf = data.read(SIZE, offset) offset += len(buf) hasher.update(buf) return hasher.hexdigest() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/http.py0000644000076500000240000001242700000000000016434 0ustar00twstaffimport collections from urllib.parse import urlparse, urljoin from flask import request, redirect, url_for from werkzeug.exceptions import BadRequest # safely and comfortably redirect # some stuff taken from / inspired by http://flask.pocoo.org/snippets/62/ def is_safe_url(target): """ check if target will lead to the same server """ ref_url = urlparse(request.host_url) test_url = urlparse(urljoin(request.host_url, target)) return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc def _redirect_target_url(d, use_referrer, endpoint, **values): """ return redirect url to (in that order): - from d - referrer (if use_referrer is True) - the url for endpoint/values """ targets = [d.get('next'), request.referrer, url_for(endpoint, **values)] if not use_referrer: del targets[1] for target in targets: if target and is_safe_url(target): return target # GET - for next 2, you may want to create urls with: # url_for(endpoint, ..., next=something) def get_redirect_target(endpoint, **values): return _redirect_target_url(request.values, False, endpoint, **values) def get_redirect_target_referrer(endpoint, **values): return _redirect_target_url(request.values, True, endpoint, **values) # POST - for next 2, you may want this in the form: # def redirect_next(endpoint, **values): return redirect(_redirect_target_url(request.form, False, endpoint, **values)) def redirect_next_referrer(endpoint, **values): return redirect(_redirect_target_url(request.form, True, endpoint, **values)) class ContentRange(collections.namedtuple('ContentRange', ('begin', 'end', 'complete'))): """ Work with Content-Range headers. """ __slots__ = () @classmethod def parse(cls, content_range): """ Parse Content-Range header. Format is "bytes 0-524287/2000000". """ try: range_type, range_count = content_range.split(' ', 1) except ValueError: raise BadRequest(description='Content-Range Header is incorrect') # There are no other types then "bytes" if range_type != 'bytes': raise BadRequest try: range_count, range_complete = range_count.split('/', 1) except ValueError: raise BadRequest(description='Content-Range Header is incorrect') try: # For now, */2000000 format is not supported range_begin, range_end = range_count.split('-', 1) range_begin = int(range_begin) range_end = int(range_end) except ValueError: raise BadRequest(description='Content-Range Header has invalid range') # For now, 0-10/* format is not supported try: range_complete = int(range_complete) except ValueError: raise BadRequest(description='Content-Range Header has invalid length') if range_begin <= range_end < range_complete: return ContentRange(range_begin, range_end, range_complete) raise BadRequest @classmethod def from_request(cls): """ Read Content-Range from request and parse it """ content_range = request.headers.get('Content-Range') if content_range is not None: return cls.parse(content_range) @property def is_complete(self): return self.complete == self.end + 1 @property def size(self): return self.end - self.begin + 1 class DownloadRange(collections.namedtuple('DownloadRange', ('begin', 'end'))): """ Work with Range headers. """ __slots__ = () @classmethod def parse(cls, content_range): """ Parse Range header. Format is "bytes=0-524287". """ try: range_type, range_count = content_range.split('=', 1) except ValueError: raise BadRequest(description='Range Header is incorrect') # There are no other types than "bytes" if range_type != 'bytes': raise BadRequest(description='Range Header is incorrect') try: range_begin, range_end = range_count.split('-', 1) except ValueError: raise BadRequest(description='Range Header is incorrect') try: range_begin = int(range_begin) except ValueError: raise BadRequest(description='Range Header has invalid first') if range_end: # For now, set of ranges (e.g. 0-1,2-10) is not supported try: range_end = int(range_end) except ValueError: raise BadRequest(description='Range Header has invalid last') else: range_end = -1 if range_begin <= range_end or range_end == -1: return DownloadRange(range_begin, range_end) raise BadRequest(description='Range Header is incorrect') @classmethod def from_request(cls): """ Read Content-Range from request and parse it """ download_range = request.headers.get('Range') if download_range is not None: return cls.parse(download_range) @property def size(self): return self.end - self.begin + 1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/name.py0000644000076500000240000000654700000000000016403 0ustar00twstaffimport re import random from werkzeug.routing import BaseConverter ID_LENGTH = 8 letters_lower = set("abcdefghijklmnopqrstuvwxyz") letters_upper = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ") digits = set("0123456789") # this stuff might be hard to read / differentiate or is otherwise unwanted: unwanted = set( "1lI" "0O" "8B" "5S" "+" # used in URL dispatching for views, e.g. +login "/" # used in URLs and fs paths ) all_chars = letters_lower | letters_upper | digits all_chars -= unwanted all_chars = ''.join(sorted(all_chars)) def encode(x, length, alphabet=all_chars): """ encode x in alphabet (with "leading zeroes") :param x: integer number :param length: length of return sequence :param alphabet: alphabet to choose characters from (default: all_chars) :return: sequence of alphabet members [list] """ if x < 0: raise ValueError("negative values are not supported") if length < 0: raise ValueError("length must be >= 0") n = len(alphabet) if n < 2: raise ValueError("alphabet must be at least 2 elements long") code = [] counter = length while x > 0 and counter > 0: x, r = divmod(x, n) code.append(alphabet[r]) counter -= 1 leading_zeroes = length - len(code) code += list(alphabet[0] * leading_zeroes) return list(reversed(code)) def make_id(length, x=None, alphabet=all_chars): """ generate a id of from value . if x is None, use a random value for x. for the id, use elements from alphabet. """ base = len(alphabet) # e.g. 10 if x is None: x = random.randint( 0, # 000 (length==3) ... base ** length - 1 # 999 (length==3) ) return ''.join(encode(x, length, alphabet)) class ItemName(str): def __new__(cls, uid): return str(uid) @classmethod def create(cls, storage, length=ID_LENGTH, max_length=2 * ID_LENGTH, max_tries=10): """ create a unique item name in storage, wanted name length is . we try at most times to find a unique name of a specific length - if we do not succeed, we increase name length and try again. if we can't find a unique name even for longer lengths up to max_length, we'll raise RuntimeError. """ name = None # avoid false alarm about reference before assignment while length <= max_length: tries = 0 while tries < max_tries: name = make_id(length) if name not in storage: break tries += 1 if tries < max_tries: # we found a name, break out of outer while also break length += 1 if length > max_length: raise RuntimeError("no unique names available") return cls(name) class ItemNameConverter(BaseConverter): """ Accept the names of the style as we generate. """ # for a while, support both old uuid4-style as well as new shorter IDs regex = ( '([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})' '|' '([%(valid)s]{%(length)d})' ) % dict(valid=re.escape(all_chars), length=ID_LENGTH) weight = 200 def setup_werkzeug_routing(app): app.url_map.converters['itemname'] = ItemNameConverter ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/utils/permissions.py0000644000076500000240000000353600000000000020031 0ustar00twstafffrom flask import request, session, current_app from flask import g as flaskg # in the code, please always use this constants for permission values: ADMIN = 'admin' LIST = 'list' CREATE = 'create' MODIFY = 'modify' READ = 'read' DELETE = 'delete' # key in the session: PERMISSIONS = 'permissions' LOGGEDIN = 'loggedin' permission_icons = { 'admin': 'user', 'list': 'list', 'create': 'plus', 'modify': 'edit', 'read': 'book', 'delete': 'trash' } def lookup_permissions(token): """ look up the permissions string for the secret in the configuration. if no such secret is configured, return None """ return current_app.config['PERMISSIONS'].get(token) def get_permissions(): """ get the permissions for the current user (if logged in) or the default permissions (if not logged in). """ auth = request.authorization if auth: # http basic auth header present permissions = lookup_permissions(auth.password) elif 'token' in request.values: # token present in query args or post form (can be used by cli clients) permissions = lookup_permissions(request.values['token']) else: # look into session, login might have put something there permissions = session.get(PERMISSIONS) if permissions is None: permissions = current_app.config['DEFAULT_PERMISSIONS'] permissions = set(permissions.split(',')) return permissions def get_permission_icons(): return [ (permission, permission_icons[permission]) for permission in sorted(get_permissions()) if permission ] def may(permission): """ check whether the current user has the permission """ return permission in flaskg.permissions def logged_in(): """ is the user logged-in? """ return session.get(LOGGEDIN, False) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693226659.0 bepasty-1.2.0/src/bepasty/utils/upload.py0000644000076500000240000001357500000000000016746 0ustar00twstaffimport re import time import mimetypes from pygments.lexers import get_lexer_for_filename from pygments.util import ClassNotFound as NoPygmentsLexer from werkzeug.exceptions import BadRequest, RequestEntityTooLarge from flask import current_app try: import magic as magic_module magic = magic_module.Magic(mime=True) magic_bufsz = magic.getparam(magic_module.MAGIC_PARAM_BYTES_MAX) except ImportError: magic = None magic_bufsz = None from ..constants import ( COMPLETE, FILENAME, FOREVER, HASH, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TIMESTAMP_MAX_LIFE, TIMESTAMP_UPLOAD, TYPE, TYPE_HINT, internal_meta, ) from .name import ItemName from .decorators import threaded from .hashing import compute_hash, hash_new # we limit to 250 characters as we do not want to accept arbitrarily long # filenames. other than that, there is no specific reason we could not # also take more (or less). MAX_FILENAME_LENGTH = 250 class Upload: _filename_re = re.compile(r'[^a-zA-Z0-9 *+:;.,_-]+') _type_re = re.compile(r'[^a-zA-Z0-9/+.-]+') @classmethod def filter_size(cls, i): """ Filter size. Check for advertised size. """ try: i = int(i) except (ValueError, TypeError): raise BadRequest(description='Size is invalid') if i > current_app.config['MAX_ALLOWED_FILE_SIZE']: raise RequestEntityTooLarge() return i @classmethod def filter_filename(cls, filename, storage_name, content_type, content_type_hint): """ Filter filename. Only allow some basic characters and shorten to 50 characters. """ # Make up filename if we don't have one if not filename: if not content_type: content_type = content_type_hint # note: stdlib mimetypes.guess_extension is total crap if content_type.startswith("text/"): ext = ".txt" else: ext = ".bin" filename = storage_name + ext return cls._filename_re.sub('', filename)[:MAX_FILENAME_LENGTH] @classmethod def filter_type(cls, ct, ct_hint, filename=None): """ Filter Content-Type Only allow some basic characters and shorten to 50 characters. Return value: tuple[0] - content-type string tuple[1] - whether tuple[0] is hint or not True: content-type is just a hint False: content-type is not a hint, was specified by user """ if not ct and filename: ct, encoding = mimetypes.guess_type(filename) if not ct: try: lexer = get_lexer_for_filename(filename) except NoPygmentsLexer: pass else: if len(lexer.mimetypes) > 0: ct = lexer.mimetypes[0] if not ct: return ct_hint, True return cls._type_re.sub('', ct)[:50], False @classmethod def meta_new(cls, item, input_size, input_filename, input_type, input_type_hint, storage_name, maxlife_stamp=FOREVER): item.meta[FILENAME] = cls.filter_filename( input_filename, storage_name, input_type, input_type_hint ) item.meta[SIZE] = cls.filter_size(input_size) ct, hint = cls.filter_type(input_type, input_type_hint, input_filename) item.meta[TYPE] = ct item.meta[TYPE_HINT] = hint item.meta[TIMESTAMP_UPLOAD] = int(time.time()) item.meta[TIMESTAMP_DOWNLOAD] = 0 item.meta[LOCKED] = current_app.config['UPLOAD_LOCKED'] item.meta[COMPLETE] = False item.meta[HASH] = '' item.meta[TIMESTAMP_MAX_LIFE] = maxlife_stamp @classmethod def meta_complete(cls, item, file_hash): # update TYPE by python-magic if not decided yet if item.meta.pop(TYPE_HINT, False): if magic and current_app.config.get('USE_PYTHON_MAGIC', False): if item.meta[TYPE] == 'application/octet-stream': item.meta[TYPE] = magic.from_buffer(item.data.read(magic_bufsz, 0)) item.meta[COMPLETE] = True item.meta[HASH] = file_hash @staticmethod def data(item, f, size_input, offset=0): """ Copy data from temp file into storage. """ read_length = 16 * 1024 size_written = 0 hasher = hash_new() while True: read_length = min(read_length, size_input) if size_input == 0: break buf = f.read(read_length) if not buf: # Should not happen, we already checked the size raise RuntimeError item.data.write(buf, offset + size_written) hasher.update(buf) len_buf = len(buf) size_written += len_buf size_input -= len_buf return size_written, hasher.hexdigest() def create_item(f, filename, size, content_type, content_type_hint, maxlife_stamp=FOREVER): """ create an item from open file with the given metadata, return the item name. """ name = ItemName.create(current_app.storage) with current_app.storage.create(name, size) as item: size_written, file_hash = Upload.data(item, f, size) Upload.meta_new(item, size, filename, content_type, content_type_hint, name, maxlife_stamp=maxlife_stamp) Upload.meta_complete(item, file_hash) return name def filter_internal(meta): """ filter internal meta data out. """ return {k: v for k, v in meta.items() if k not in internal_meta} @threaded def background_compute_hash(storage, name): with storage.openwrite(name) as item: size = item.meta[SIZE] file_hash = compute_hash(item.data, size) item.meta[HASH] = file_hash ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4761245 bepasty-1.2.0/src/bepasty/views/0000755000076500000240000000000000000000000015072 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674405649.0 bepasty-1.2.0/src/bepasty/views/__init__.py0000644000076500000240000000430100000000000017201 0ustar00twstafffrom flask import Blueprint from .delete import DeleteView from .display import DisplayView, CarouselView from .download import DownloadView, InlineView, ThumbnailView from .modify import ModifyView from .qr import QRView from .filelist import FileListView from .index import index from .login import LoginView, LogoutView from .setkv import LockView, UnlockView from .upload import UploadAbortView, UploadContinueView, UploadNewView, UploadView from .xstatic import xstatic blueprint = Blueprint('bepasty', __name__) blueprint.add_url_rule('/', view_func=index) blueprint.add_url_rule('/xstatic/', defaults=dict(filename=''), view_func=xstatic) blueprint.add_url_rule('/xstatic//', view_func=xstatic) blueprint.add_url_rule('/+list', view_func=FileListView.as_view('filelist')) blueprint.add_url_rule('/+login', view_func=LoginView.as_view('login')) blueprint.add_url_rule('/+logout', view_func=LogoutView.as_view('logout')) blueprint.add_url_rule('/', view_func=DisplayView.as_view('display')) blueprint.add_url_rule('//+carousel', view_func=CarouselView.as_view('carousel')) blueprint.add_url_rule('//+delete', view_func=DeleteView.as_view('delete')) blueprint.add_url_rule('//+download', view_func=DownloadView.as_view('download')) blueprint.add_url_rule('//+inline', view_func=InlineView.as_view('inline')) blueprint.add_url_rule('//+thumbnail', view_func=ThumbnailView.as_view('thumbnail')) blueprint.add_url_rule('//+modify', view_func=ModifyView.as_view('modify')) blueprint.add_url_rule('//+qr', view_func=QRView.as_view('qr')) blueprint.add_url_rule('//+lock', view_func=LockView.as_view('lock')) blueprint.add_url_rule('//+unlock', view_func=UnlockView.as_view('unlock')) blueprint.add_url_rule('/+upload', view_func=UploadView.as_view('upload')) blueprint.add_url_rule('/+upload/new', view_func=UploadNewView.as_view('upload_new')) blueprint.add_url_rule('/+upload/', view_func=UploadContinueView.as_view('upload_continue')) blueprint.add_url_rule('/+upload//abort', view_func=UploadAbortView.as_view('upload_abort')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/delete.py0000644000076500000240000000222100000000000016703 0ustar00twstaffimport errno from flask import current_app, render_template from flask.views import MethodView from werkzeug.exceptions import NotFound, Forbidden from ..constants import COMPLETE, FILENAME, LOCKED from ..utils.http import redirect_next_referrer from ..utils.permissions import ADMIN, DELETE, may class DeleteView(MethodView): def error(self, item, error): return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 def response(self, name): return redirect_next_referrer('bepasty.index') def post(self, name): if not may(DELETE): raise Forbidden() try: with current_app.storage.open(name) as item: if not item.meta[COMPLETE] and not may(ADMIN): error = 'Upload incomplete. Try again later.' self.error(item, error) if item.meta[LOCKED] and not may(ADMIN): raise Forbidden() current_app.storage.remove(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise return self.response(name) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1684790435.0 bepasty-1.2.0/src/bepasty/views/display.py0000644000076500000240000001651200000000000017116 0ustar00twstaffimport errno import time from flask import current_app, render_template, url_for, request from flask.views import MethodView from markupsafe import Markup from werkzeug.exceptions import NotFound, Forbidden from pygments import highlight from pygments.lexers import get_lexer_for_mimetype from pygments.util import ClassNotFound as NoPygmentsLexer from ..constants import COMPLETE, FILENAME, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TYPE from ..utils.date_funcs import delete_if_lifetime_over from ..utils.formatters import CustomHtmlFormatter from ..utils.permissions import ADMIN, READ, may from .index import contenttypes_list from .filelist import file_infos def rendering_allowed(item_type, item_size, use_pygments, complete): """ check if rendering is allowed, checks for: * whether the item is completely uploaded * whether the size is within the configured limits for the content-type """ if not complete: return False if use_pygments: # if we use pygments, special restrictions apply item_type = 'HIGHLIGHT_TYPES' # create a tuple list [(content_type_prefix, max_size), ...] with long prefixes first ct_size = sorted(current_app.config['MAX_RENDER_SIZE'].items(), key=lambda e: len(e[0]), reverse=True) for ct, size in ct_size: if item_type.startswith(ct): return item_size <= size # there should be one entry with ct == '', so we should never get here: return False class DisplayView(MethodView): def get(self, name, view='normal'): if not may(READ): raise Forbidden() try: item = current_app.storage.openwrite(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise with item as item: complete = item.meta[COMPLETE] if not complete and not may(ADMIN): error = 'Upload incomplete. Try again later.' return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 if item.meta[LOCKED] and not may(ADMIN): raise Forbidden() if delete_if_lifetime_over(item, name): raise NotFound() def read_data(item): # reading the item for rendering is registered like a download data = item.data.read(item.data.size, 0) item.meta[TIMESTAMP_DOWNLOAD] = int(time.time()) return data size = item.meta[SIZE] ct = item.meta[TYPE] try: get_lexer_for_mimetype(ct) use_pygments = True ct_pygments = ct except NoPygmentsLexer: if ct.startswith('text/'): # seems like we found a text type not supported by pygments # use text/plain so we get a display with line numbers use_pygments = True ct_pygments = 'text/plain' else: use_pygments = False is_list_item = False if rendering_allowed(ct, size, use_pygments, complete): if ct.startswith('text/x-bepasty-'): # special bepasty items - must be first, don't feed to pygments if ct == 'text/x-bepasty-list': is_list_item = True names = read_data(item).decode('utf-8').splitlines() files = sorted(file_infos(names), key=lambda f: f[FILENAME]) if view == 'normal': rendered_content = Markup(render_template('filelist_tableonly.html', files=files)) elif view == 'carousel': # this template renders to a complete html page # we only consider image items for this files = [f for f in files if f[TYPE].startswith('image/')] return render_template('carousel.html', files=files) else: raise NotImplementedError elif ct == 'text/x-bepasty-redirect': url = read_data(item).decode('utf-8') delay = request.args.get('delay', '3') return render_template('redirect.html', url=url, delay=delay) else: rendered_content = "Can't render this content type." elif ct.startswith('image/'): src = url_for('bepasty.download', name=name) rendered_content = Markup('the image' % src) elif ct.startswith('audio/'): src = url_for('bepasty.download', name=name) alt_msg = 'html5 audio element not supported by your browser.' rendered_content = Markup(f'') elif ct.startswith('video/'): src = url_for('bepasty.download', name=name) alt_msg = 'html5 video element not supported by your browser.' rendered_content = Markup(f'') elif ct in ['application/pdf', 'application/x-pdf', ]: src = url_for('bepasty.inline', name=name) link_txt = 'Click to see PDF' rendered_content = Markup(f'{link_txt}') elif ct == 'application/x-asciinema-recording': src = url_for('bepasty.download', name=name) rendered_content = Markup('' % (src, item.meta[FILENAME], current_app.config.get('ASCIINEMA_THEME', 'asciinema'))) elif use_pygments: text = read_data(item) # TODO we don't have the coding in metadata try: text = text.decode('utf-8') except UnicodeDecodeError: # well, it is not utf-8 or ascii, so we can only guess... text = text.decode('iso-8859-1') lexer = get_lexer_for_mimetype(ct_pygments) formatter = CustomHtmlFormatter(linenos='table', lineanchors="L", lineparagraphs="L", anchorlinenos=True) rendered_content = Markup(highlight(text, lexer, formatter)) else: rendered_content = "Can't render this content type." else: if not complete: rendered_content = "Rendering not allowed (not complete). Is it still being uploaded?" else: rendered_content = "Rendering not allowed (too big?). Try download" return render_template('display.html', name=name, item=item, rendered_content=rendered_content, contenttypes=contenttypes_list(), is_list_item=is_list_item) class CarouselView(DisplayView): def get(self, name): return super().get(name, view='carousel') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674419465.0 bepasty-1.2.0/src/bepasty/views/download.py0000644000076500000240000001200300000000000017247 0ustar00twstaffimport errno from io import BytesIO import os import time try: import PIL except ImportError: # Pillow / PIL is optional PIL = None else: from PIL import Image from flask import Response, current_app, render_template, stream_with_context from flask.views import MethodView from werkzeug.exceptions import NotFound, Forbidden from ..constants import COMPLETE, FILENAME, LOCKED, SIZE, TIMESTAMP_DOWNLOAD, TYPE from ..utils.date_funcs import delete_if_lifetime_over from ..utils.permissions import ADMIN, READ, may class DownloadView(MethodView): content_disposition = 'attachment' # to trigger download def err_incomplete(self, item, error): return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 def stream(self, item, start, limit): with item as _item: # Stream content from storage offset = max(0, start) while offset < limit: buf = _item.data.read(min(limit - offset, 16 * 1024), offset) offset += len(buf) yield buf item.meta[TIMESTAMP_DOWNLOAD] = int(time.time()) def response(self, item, name): ct = item.meta[TYPE] dispo = self.content_disposition if dispo != 'attachment': # no simple download, so we must be careful about XSS if ct.startswith("text/"): ct = 'text/plain' # only send simple plain text ret = Response(stream_with_context(self.stream(item, 0, item.data.size))) ret.headers['Content-Disposition'] = '{}; filename="{}"'.format( dispo, item.meta[FILENAME]) ret.headers['Content-Length'] = item.meta[SIZE] ret.headers['Content-Type'] = ct ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it return ret def get(self, name): if not may(READ): raise Forbidden() try: item = current_app.storage.openwrite(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise try: need_close = True if not item.meta[COMPLETE]: return self.err_incomplete(item, 'Upload incomplete. Try again later.') if item.meta[LOCKED] and not may(ADMIN): raise Forbidden() if delete_if_lifetime_over(item, name): raise NotFound() need_close = False finally: if need_close: item.close() return self.response(item, name) class InlineView(DownloadView): content_disposition = 'inline' # to trigger viewing in browser, for some types class ThumbnailView(InlineView): thumbnail_size = 192, 108 thumbnail_data = """\ """.strip().encode() def err_incomplete(self, item, error): return b'', 409 # conflict def response(self, item, name): sz = item.meta[SIZE] fn = item.meta[FILENAME] ct = item.meta[TYPE] unsupported = PIL is None or ct not in {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} if unsupported: # return a placeholder thumbnail for unsupported item types ret = Response(self.thumbnail_data) ret.headers['Content-Length'] = len(self.thumbnail_data) ret.headers['Content-Type'] = 'image/svg+xml' ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it return ret if ct in ('image/jpeg', ): thumbnail_type = 'jpeg' elif ct in ('image/png', 'image/gif'): thumbnail_type = 'png' elif ct in ('image/webp', ): thumbnail_type = 'webp' else: raise ValueError('unrecognized image content type') # compute thumbnail data "on the fly" with BytesIO(item.data.read(sz, 0)) as img_bio, BytesIO() as thumbnail_bio: with Image.open(img_bio) as img: img.thumbnail(self.thumbnail_size) img.save(thumbnail_bio, thumbnail_type) thumbnail_data = thumbnail_bio.getvalue() name, ext = os.path.splitext(fn) thumbnail_fn = '{}-thumb.{}'.format(name, thumbnail_type) ret = Response(thumbnail_data) ret.headers['Content-Disposition'] = '{}; filename="{}"'.format( self.content_disposition, thumbnail_fn) ret.headers['Content-Length'] = len(thumbnail_data) ret.headers['Content-Type'] = 'image/%s' % thumbnail_type ret.headers['X-Content-Type-Options'] = 'nosniff' # yes, we really mean it return ret ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/filelist.py0000644000076500000240000000300100000000000017251 0ustar00twstaffimport errno import pickle from flask import current_app, render_template from flask.views import MethodView from werkzeug.exceptions import Forbidden from ..constants import ID, TIMESTAMP_UPLOAD from ..utils.date_funcs import delete_if_lifetime_over from ..utils.permissions import LIST, may def file_infos(names=None): """ iterates over storage files metadata. note: we put the storage name into the metadata as ID :param names: None means "all items" otherwise give a list of storage item names """ storage = current_app.storage if names is None: names = list(storage) for name in names: try: with storage.open(name) as item: meta = dict(item.meta) if not meta: # we got empty metadata, this happens for 0-byte .meta files. # ignore it for now. continue if delete_if_lifetime_over(item, name): continue meta[ID] = name yield meta except OSError as e: if e.errno != errno.ENOENT: raise except pickle.UnpicklingError: # corrupted meta file, just ignore it for now pass class FileListView(MethodView): def get(self): if not may(LIST): raise Forbidden() files = sorted(file_infos(), key=lambda f: f[TIMESTAMP_UPLOAD], reverse=True) return render_template('filelist.html', files=files) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/index.py0000644000076500000240000000062100000000000016552 0ustar00twstafffrom flask import render_template from pygments.lexers import get_all_lexers def contenttypes_list(): contenttypes = [ 'text/x-bepasty-redirect', # redirect / link shortener service ] for lexer_info in get_all_lexers(): contenttypes.extend(lexer_info[3]) return contenttypes def index(): return render_template('index.html', contenttypes=contenttypes_list()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/login.py0000644000076500000240000000175200000000000016561 0ustar00twstafffrom flask import request, session from flask.views import MethodView from ..utils.http import redirect_next_referrer from ..utils.permissions import LOGGEDIN, PERMISSIONS, lookup_permissions class LoginView(MethodView): def post(self): token = request.form.get('token') if token is not None: permissions_for_token = lookup_permissions(token) if permissions_for_token is not None: session[PERMISSIONS] = permissions_for_token session[LOGGEDIN] = True return redirect_next_referrer('bepasty.index') class LogoutView(MethodView): def post(self): # note: remove all session entries that are not needed for logged-out # state (because the code has defaults for them if they are missing). # if the session is empty. flask will automatically remove the cookie. session.pop(LOGGEDIN, None) session.pop(PERMISSIONS, None) return redirect_next_referrer('bepasty.index') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/modify.py0000644000076500000240000000361100000000000016734 0ustar00twstaffimport errno from flask import current_app, request, render_template from flask.views import MethodView from werkzeug.exceptions import Forbidden, NotFound from ..constants import COMPLETE, FILENAME, LOCKED, TYPE from ..utils.date_funcs import delete_if_lifetime_over from ..utils.http import redirect_next_referrer from ..utils.permissions import ADMIN, CREATE, may from ..utils.upload import Upload class ModifyView(MethodView): def error(self, item, error): return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 def response(self, name): return redirect_next_referrer('bepasty.display', name=name) def get_params(self): return { FILENAME: request.form.get('filename'), TYPE: request.form.get('contenttype'), } def post(self, name): if not may(CREATE): raise Forbidden() try: with current_app.storage.openwrite(name) as item: if not item.meta[COMPLETE] and not may(ADMIN): error = 'Upload incomplete. Try again later.' return self.error(item, error) if item.meta[LOCKED] and not may(ADMIN): raise Forbidden() if delete_if_lifetime_over(item, name): raise NotFound() params = self.get_params() if params[FILENAME]: item.meta[FILENAME] = Upload.filter_filename( params[FILENAME], name, params[TYPE], item.meta[TYPE] ) if params[TYPE]: item.meta[TYPE], _ = Upload.filter_type( params[TYPE], item.meta[TYPE] ) return self.response(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/qr.py0000644000076500000240000000040100000000000016061 0ustar00twstafffrom flask import render_template, url_for from flask.views import MethodView class QRView(MethodView): def get(self, name): target = url_for('bepasty.display', name=name, _external=True) return render_template('qr.html', text=target) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/setkv.py0000644000076500000240000000330100000000000016575 0ustar00twstaff""" Set metadata keys to specific values """ import errno from flask import current_app, render_template from flask.views import MethodView from werkzeug.exceptions import NotFound, Forbidden from ..constants import COMPLETE, FILENAME, LOCKED from ..utils.http import redirect_next_referrer from ..utils.permissions import ADMIN, may class SetKeyValueView(MethodView): # overwrite these in subclasses: REQUIRED_PERMISSION = None KEY = None NEXT_VALUE = None def error(self, item, error): return render_template('error.html', heading=item.meta[FILENAME], body=error), 409 def response(self, name): return redirect_next_referrer('bepasty.display', name=name) def post(self, name): if self.REQUIRED_PERMISSION is not None and not may(self.REQUIRED_PERMISSION): raise Forbidden() try: with current_app.storage.openwrite(name) as item: if item.meta[self.KEY] == self.NEXT_VALUE: error = f'{self.KEY} already is {self.NEXT_VALUE!r}.' elif not item.meta[COMPLETE]: error = 'Upload incomplete. Try again later.' else: error = None if error: return self.error(item, error) item.meta[self.KEY] = self.NEXT_VALUE return self.response(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise class LockView(SetKeyValueView): REQUIRED_PERMISSION = ADMIN KEY = LOCKED NEXT_VALUE = True class UnlockView(SetKeyValueView): REQUIRED_PERMISSION = ADMIN KEY = LOCKED NEXT_VALUE = False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/upload.py0000644000076500000240000001304500000000000016733 0ustar00twstaffimport os import errno from io import BytesIO import time from flask import abort, current_app, jsonify, request, url_for from flask.views import MethodView from werkzeug.exceptions import NotFound, Forbidden from werkzeug.urls import url_quote from ..constants import COMPLETE, FILENAME, SIZE from ..utils.date_funcs import get_maxlife from ..utils.http import ContentRange, redirect_next from ..utils.name import ItemName from ..utils.permissions import CREATE, may from ..utils.upload import Upload, create_item, background_compute_hash class UploadView(MethodView): def post(self): if not may(CREATE): raise Forbidden() f = request.files.get('file') t = request.form.get('text') # note: "and f.filename" is needed due to missing __bool__ method in # werkzeug.datastructures.FileStorage, to work around it crashing # on Python 3.x. if f and f.filename: # Check Content-Range, disallow its usage if ContentRange.from_request(): abort(416) # Check Content-Type, default to application/octet-stream content_type = ( f.headers.get('Content-Type') or request.headers.get('Content-Type')) content_type_hint = 'application/octet-stream' filename = f.filename # Get size of temporary file f.seek(0, os.SEEK_END) size = f.tell() f.seek(0) elif t is not None: # t is already unicode, but we want utf-8 for storage t = t.encode('utf-8') content_type = request.form.get('contenttype') # TODO: add coding content_type_hint = 'text/plain' size = len(t) f = BytesIO(t) filename = request.form.get('filename') else: raise NotImplementedError # set max lifetime maxtime = get_maxlife(request.form, underscore=False) maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime name = create_item(f, filename, size, content_type, content_type_hint, maxlife_stamp=maxlife_timestamp) kw = {} kw['_anchor'] = url_quote(filename) if content_type == 'text/x-bepasty-redirect': # after creating a redirect, we want to stay on the bepasty # redirect display, so the user can copy the URL. kw['delay'] = '9999' return redirect_next('bepasty.display', name=name, **kw) class UploadNewView(MethodView): def post(self): if not may(CREATE): raise Forbidden() data = request.get_json() data_filename = data['filename'] data_size = int(data['size']) data_type = data['type'] # set max lifetime maxtime = get_maxlife(data, underscore=True) maxlife_timestamp = int(time.time()) + maxtime if maxtime > 0 else maxtime name = ItemName.create(current_app.storage) with current_app.storage.create(name, data_size) as item: # Save meta-data Upload.meta_new(item, data_size, data_filename, data_type, 'application/octet-stream', name, maxlife_stamp=maxlife_timestamp) return jsonify({'url': url_for('bepasty.upload_continue', name=name), 'name': name}) class UploadContinueView(MethodView): def post(self, name): if not may(CREATE): raise Forbidden() f = request.files['file'] if not f: raise NotImplementedError # Check Content-Range content_range = ContentRange.from_request() with current_app.storage.openwrite(name) as item: if content_range: # note: we ignore the hash as it is only for 1 chunk, not for the whole upload. # also, we can not continue computing the hash as we can't save the internal # state of the hash object size_written, _ = Upload.data(item, f, content_range.size, content_range.begin) file_hash = '' is_complete = content_range.is_complete else: # Get size of temporary file f.seek(0, os.SEEK_END) size = f.tell() f.seek(0) size_written, file_hash = Upload.data(item, f, size) is_complete = True if is_complete: Upload.meta_complete(item, file_hash) result = jsonify({'files': [{ 'name': name, 'filename': item.meta[FILENAME], 'size': item.meta[SIZE], 'url': "{}#{}".format(url_for('bepasty.display', name=name), item.meta[FILENAME]), }]}) if is_complete and not file_hash: background_compute_hash(current_app.storage, name) return result class UploadAbortView(MethodView): def get(self, name): if not may(CREATE): raise Forbidden() try: item = current_app.storage.open(name) except OSError as e: if e.errno == errno.ENOENT: return 'No file found.', 404 raise if item.meta[COMPLETE]: error = 'Upload complete. Cannot delete fileupload garbage.' else: error = None if error: return error, 409 try: item = current_app.storage.remove(name) except OSError as e: if e.errno == errno.ENOENT: raise NotFound() raise return 'Upload aborted' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/views/xstatic.py0000644000076500000240000000063000000000000017122 0ustar00twstafffrom flask import send_from_directory from werkzeug.exceptions import NotFound from ..bepasty_xstatic import serve_files def xstatic(name, filename): """Route to serve the xstatic files (from serve_files)""" try: base_path = serve_files[name] except KeyError: raise NotFound() if not filename: raise NotFound() return send_from_directory(base_path, filename) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1674319972.0 bepasty-1.2.0/src/bepasty/wsgi.py0000644000076500000240000000020700000000000015257 0ustar00twstaff#!/usr/bin/python from .app import create_app application = create_app() if __name__ == '__main__': application.run(debug=True) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1693235434.4679375 bepasty-1.2.0/src/bepasty.egg-info/0000755000076500000240000000000000000000000015427 5ustar00twstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/PKG-INFO0000644000076500000240000000657300000000000016537 0ustar00twstaffMetadata-Version: 2.1 Name: bepasty Version: 1.2.0 Summary: a binary pastebin / file upload service Author: The Bepasty Team (see AUTHORS file) Maintainer-email: Thomas Waldmann License: BSD 2-clause Project-URL: Homepage, https://github.com/bepasty/bepasty-server/ Project-URL: Documentation, https://bepasty-server.readthedocs.org/ Project-URL: Changelog, https://github.com/bepasty/bepasty-server/blob/master/CHANGES.rst Keywords: text,image,audio,video,binary,pastebin,upload,download,service,wsgi,flask Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Framework :: Flask Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Requires-Python: >=3.8 Description-Content-Type: text/x-rst Provides-Extra: magic Provides-Extra: pillow License-File: LICENSE License-File: AUTHORS bepasty ======= bepasty is like a pastebin for all kinds of files (text, image, audio, video, documents, ..., binary). The documentation is there: https://bepasty-server.readthedocs.org/en/latest/ Features -------- * Generic: - you can upload multiple files at once, simply by drag and drop - after upload, you get a unique link to a view of each file - on that view, we show actions you can do with the file, metadata of the file and, if possible, we also render the file contents - if you uploaded multiple files, you can create a pastebin with the list of all these files - with a single click! - Set an expiration date for your files * Text files: - we highlight all text file types supported by pygments (a lot!) - we display line numbers - we link from line numbers to their anchors, so you can easily get a link to a specific line * Image files: - we show the image (format support depends on browser) - for image list items, we can show a slide show ("carousel" view) - in the items list, a thumbnail of images is shown * Audio and video files: - we show the html5 player for it (format support depends on browser) * asciinema recordings: - we show the asciinema player for .cast files * URLs: - we support linking to / redirecting to external URLs, you can use this as a link shortener (avoiding privacy / data protection issues that may exist with other link shorteners) * PDFs: - we support rendering PDFs in your browser (if your browser is able to) * Storage: we use a storage backend api, currently we have backends for: - filesystem storage (just use a filesystem directory to store .meta and .data files) - currently there are no other storage implementations in master branch and releases. The "ceph cluster" storage implementation has issues and currently lives in branch "ceph-storage" until these issues are fixed. * Keeping some control: - flexible permissions: read, create, modify, delete, list, admin - assign permissions to users of login secrets - assign default permissions to not-logged-in users - you can purge files from storage by age, inactivity, size, type, ... - you can do consistency checks on the storage ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/SOURCES.txt0000644000076500000240000000531500000000000017317 0ustar00twstaff.readthedocs.yml AUTHORS CHANGES.rst LICENSE MANIFEST.in README.rst pyproject.toml tox.ini .github/workflows/ci.yml docs/Makefile docs/build/.keep docs/source/changelog.rst docs/source/conf.py docs/source/index.rst docs/source/install-tutorial.rst docs/source/intro.rst docs/source/license.rst docs/source/project.rst docs/source/quickstart.rst docs/source/rest.rst docs/source/user-cli.rst docs/source/user.rst docs/source/_static/.keep docs/source/_templates/.keep requirements.d/dev.txt requirements.d/rtd.txt scripts/sdist-sign scripts/upload-pypi src/bepasty/__init__.py src/bepasty/_version.py src/bepasty/app.py src/bepasty/bepasty_xstatic.py src/bepasty/config.py src/bepasty/constants.py src/bepasty/wsgi.py src/bepasty.egg-info/PKG-INFO src/bepasty.egg-info/SOURCES.txt src/bepasty.egg-info/dependency_links.txt src/bepasty.egg-info/entry_points.txt src/bepasty.egg-info/requires.txt src/bepasty.egg-info/top_level.txt src/bepasty/apis/__init__.py src/bepasty/apis/lodgeit.py src/bepasty/apis/rest.py src/bepasty/cli/__init__.py src/bepasty/cli/object.py src/bepasty/cli/server.py src/bepasty/static/app/bepasty.svg src/bepasty/static/app/favicon.ico src/bepasty/static/app/css/style.css src/bepasty/static/app/js/fileuploader.js src/bepasty/static/app/js/qrcode.js src/bepasty/static/app/js/utils.js src/bepasty/storage/__init__.py src/bepasty/storage/filesystem/__init__.py src/bepasty/templates/_layout.html src/bepasty/templates/_utils.html src/bepasty/templates/carousel.html src/bepasty/templates/display.html src/bepasty/templates/error.html src/bepasty/templates/filelist.html src/bepasty/templates/filelist_tableonly.html src/bepasty/templates/index.html src/bepasty/templates/qr.html src/bepasty/templates/redirect.html src/bepasty/tests/__init__.py src/bepasty/tests/conftest.py src/bepasty/tests/screenshots.py src/bepasty/tests/test_app.py src/bepasty/tests/test_data.py src/bepasty/tests/test_date_funcs.py src/bepasty/tests/test_http.py src/bepasty/tests/test_item.py src/bepasty/tests/test_meta.py src/bepasty/tests/test_name.py src/bepasty/tests/test_rest_server.py src/bepasty/tests/test_storage.py src/bepasty/tests/test_website.py src/bepasty/utils/__init__.py src/bepasty/utils/date_funcs.py src/bepasty/utils/decorators.py src/bepasty/utils/formatters.py src/bepasty/utils/hashing.py src/bepasty/utils/http.py src/bepasty/utils/name.py src/bepasty/utils/permissions.py src/bepasty/utils/upload.py src/bepasty/views/__init__.py src/bepasty/views/delete.py src/bepasty/views/display.py src/bepasty/views/download.py src/bepasty/views/filelist.py src/bepasty/views/index.py src/bepasty/views/login.py src/bepasty/views/modify.py src/bepasty/views/qr.py src/bepasty/views/setkv.py src/bepasty/views/upload.py src/bepasty/views/xstatic.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/dependency_links.txt0000644000076500000240000000000100000000000021475 0ustar00twstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/entry_points.txt0000644000076500000240000000014400000000000020724 0ustar00twstaff[console_scripts] bepasty-object = bepasty.cli.object:main bepasty-server = bepasty.cli.server:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/requires.txt0000644000076500000240000000041300000000000020025 0ustar00twstaffFlask markupsafe Pygments>=2.12.0 xstatic XStatic-asciinema-player xstatic-bootbox>=5.4.0 xstatic-bootstrap<5.0.0.0,>=4.0.0.0 xstatic-font-awesome<5.0 xstatic-jquery xstatic-jquery-ui xstatic-jquery-file-upload xstatic-pygments [magic] python-magic [pillow] Pillow ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693235434.0 bepasty-1.2.0/src/bepasty.egg-info/top_level.txt0000644000076500000240000000001000000000000020150 0ustar00twstaffbepasty ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1693234051.0 bepasty-1.2.0/tox.ini0000644000076500000240000000076000000000000013015 0ustar00twstaff[tox] envlist=py{38,39,310,311},flake8 [testenv] deps = -r{toxinidir}/requirements.d/dev.txt setenv = PYTHONPATH = {toxinidir} # as we do not start the server that serves the selenium tests using the misc. # python versions configured above (but manually with 1 specific python # version), there is no point in running these tests with tox: commands = pytest -m "not needs_server" --cov=bepasty --pyargs {posargs:bepasty.tests} [testenv:flake8] changedir = deps = flake8 commands = flake8 src/