diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..bc01936
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,63 @@
+name: Release & publish
+
+on:
+ release:
+ types: [created]
+
+jobs:
+ publish_pip:
+
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16.x
+ cache: 'npm'
+ cache-dependency-path: webapp/package-lock.json
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.10'
+ cache: 'pip'
+ cache-dependency-path: |
+ api/setup.py
+ cli/setup.py
+ - name: Install dependencies
+ run: pip install wheel twine
+ - name: CLI build
+ run: cd cli && python ./setup.py bdist_wheel sdist
+ - name: API build
+ run: cd api && python ./setup.py bdist_wheel sdist
+ - name: Webapp wheel build
+ run: |
+ cd webapp && npm i
+ npm run build
+ python ./setup.py bdist_wheel
+ - name: Publish to Pypi
+ env:
+ TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+ TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+ run: |
+ echo cli/dist/* api/dist/* webapp/dist/*
+ twine upload cli/dist/* api/dist/* webapp/dist/*
+
+ publish_docker:
+
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v3
+ with:
+ push: true
+ tags: aguinet/secsend:${{ github.event.release.tag_name }}
+ file: Dockerfile.prod
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..2d175b8
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,33 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ tests:
+
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use Node.js 16.x
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16.x
+ cache: 'npm'
+ cache-dependency-path: webapp/package-lock.json
+ - uses: actions/setup-python@v4
+ with:
+ python-version: '3.10'
+ cache: 'pip'
+ cache-dependency-path: |
+ api/setup.py
+ cli/setup.py
+ - name: CLI tests
+ run: cd cli && pip install -e .[dev] && cd tests && python -m unittest
+ - name: API tests
+ run: cd api && pip install -e .[dev] && cd tests && python -m unittest
+ - name: Webapp tests
+ run: cd webapp && npm i && npm run eslint && npm run test
diff --git a/Dockerfile.prod b/Dockerfile.prod
new file mode 100644
index 0000000..c6a1693
--- /dev/null
+++ b/Dockerfile.prod
@@ -0,0 +1,19 @@
+FROM node:18-bullseye-slim as builder
+RUN apt-get update && DEBIAN_FRONTEND=non-interactive apt-get install -yqq \
+ python3 python3-pip && \
+ mkdir /tmp/secsend
+COPY ./api /tmp/secsend/api
+COPY ./webapp /tmp/secsend/webapp
+COPY ./__version__.py /tmp/secsend
+
+RUN cd /tmp/secsend/api && python3 ./setup.py bdist_wheel
+RUN cd /tmp/secsend/webapp && npm install && npm run build && python3 ./setup.py bdist_wheel
+
+FROM python:3.10-slim-bullseye
+
+COPY --from=builder /tmp/secsend/api/dist/*.whl /tmp
+COPY --from=builder /tmp/secsend/webapp/dist/*.whl /tmp
+RUN pip install /tmp/*.whl && rm /tmp/*.whl
+
+ENV SECSEND_BACKEND_FILES_ROOT=/data
+ENTRYPOINT /usr/local/bin/sanic secsend_api.prod.app -p ${SECSEND_LISTEN_PORT:-8000} -H 0.0.0.0
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d180ed7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,209 @@
+# secsend
+
+secsend is a file-sharing app providing end-to-end encryption of data. It provides a web application and a command-line interface (CLI).
+
+https://user-images.githubusercontent.com/1874053/188491291-106a232d-db2f-4622-bf8f-f312e1ee1d38.mp4
+
+It has some unique features:
+
+* on-the-fly encryption and decryption in the browser. For instance,
+ a movie can be directly decrypted in the browser without having to be
+ downloaded first.
+* multi-files upload: on-the-fly creation of Zip archives (without any
+ temporary archive creation - webapp only)
+* pause & resume uploads
+* automatic upload resuming when connection fails or timeouts (webapp only)
+* lightweight web application (HTML/CSS/JS in less than 100kb)
+
+On top of that, it supports more classical features, like file size limitation
+& timeout.
+
+Please also read the [security considerations
+section](#security-considerations) before deployment and usage.
+
+The backend & CLI are written in Python. The web application is written in Typescript.
+
+## Table of contents
+
+* [Server installation & configuration](#server-installation--configuration)
+ * [Quick'n'dirty](#quickndirty)
+ * [Run with Docker](#run-with-docker)
+ * [Run with systemd](#run-with-systemd)
+ * [Configuration](#configuration)
+* [Command line usage](#command-line-usage)
+ * [Installation](#installation)
+ * [Upload a file](#upload-a-file)
+ * [Download a file](#download-a-file)
+ * [Delete an uploaded file](#delete-an-uploaded-file)
+* [Security considerations](#security-considerations)
+
+
+## Server installation & configuration
+
+### Quick'n'dirty
+
+To quickly try secsend, you can run a server directly from your shell:
+
+```
+$ pip install secsend_api secsend_webapp
+$ sanic secsend_api.prod.app -p 8000
+```
+
+You can now access secsend by going to http://127.0.0.1:8000.
+
+Not installing `secsend_webapp` will disable the webapp. Only the [command line
+interface](#command-line-usage) will work.
+
+By default, uploaded files will be saved in the directory `secsend_root`,
+relative to the current directory. See [the configuration
+section](#configuration) on how to change this behavior, among with other
+options (file size & time limit).
+
+### Run with Docker
+
+Copy `docker.env.example` to `docker.env`, and modify its content to configure
+secsend (e.g. file size limit).
+
+Then, run secsend with docker:
+
+```
+# docker run --env-file docker.env -p 8000:80 -v /path/to/data/storage:/data secsend:v1.0.0rc0
+```
+
+`/path/to/data/storage` will contain the uploaded files and associated metadata.
+
+If you have changed `SECSEND_LISTEN_PORT` in `docker.env`, change the `-p`
+option accordingly.
+
+You can now open http://127.0.0.1:8000 to access secsend!
+
+### Run with systemd
+
+Let's say you want to run secsend on a server using systemd, under the user
+`www-send`.
+
+First, create a Python virtualenv and install secsend:
+
+```
+$ virtualenv secsend_venv && . secsend_venv/bin/activate
+$ pip install secsend_api secsend_webapp
+```
+
+Then, declare the secsend service in systemd, by creating the file `/etc/systemd/system/secsend.service` with this content:
+
+```
+[Unit]
+Description=secsend
+
+[Service]
+# Command to execute when the service is started
+ExecStart=/path/to/secsend_venv/bin/sanic secsend_api.prod.app -p 8000 -H 127.0.0.1
+
+# Disable Python's buffering of STDOUT and STDERR, so that output from the
+# service shows up immediately in systemd's logs
+Environment=PYTHONUNBUFFERED=1
+Environment=SECSEND_BACKEND_FILES_ROOT=/path/to/data/storage
+
+Restart=always
+User=www-send
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`/path/to/data/storage` must be writable by the `www-send ` user. See the
+[configuration section](#configuration) for other environment variable you can declare to
+configure secsend.
+
+Finally, enable & run the secsend service:
+
+```
+$ systemctl enable --now secsend.service
+```
+
+secsend is now accessible at http://127.0.0.1:8000.
+
+### Configuration
+
+secsend can be configured through various environment variables:
+
+* `SECSEND_FILESIZE_LIMIT`: maximum file size in bytes. 0 means no limit.
+* `SECSEND_TIMEOUT_S_VALID`: valid time limits, as a comma-separated list of seconds. 0 seconds means no limit.
+* `SECSEND_BACKEND_FILES_ROOT`: path to secsend's data storage
+
+## Command line usage
+
+### Installation
+
+```bash
+$ pip install secsend
+```
+
+### Upload a file
+
+```
+$ secupload myvideo.mp4 https://send.domain.com
+```
+
+`secupload` will generate two links:
+
+* an administration link that can be used to resume or delete this file
+* a download link you can give to the recipients of this file
+
+Use the `-c` flag to resume an upload, using an administration link:
+
+```
+$ secupload -c myvideo.mp4 https://send.domain.com/dl?id=XXXXXX#YYYYY
+```
+
+### Download a file
+
+```
+$ secdownload https://send.domain.com/dl?id=XXXXXX#YYYYY
+```
+
+By default, the original filename will be used as the destination filename. Use
+`-o` to override this.
+
+### Delete an uploaded file
+
+```
+$ secadmin -d https://send.domain.com/dl?id=XXXXXX#YYYYY
+```
+
+You need to use an [administration link](#upload-a-file) for this to work.
+
+## Security considerations
+
+### Attack models
+
+#### Passive attacker
+
+In this attack model, we consider that the attacker has access to the files
+that the server receives.
+
+In this model, end-to-end encryption is efficient, as the server (in theory)
+does not own any secret to decrypt and/or tamper the transmitted files. Also,
+he can't inject malicious Javascript as in the active attacker model described
+below.
+
+#### Active attacker
+
+In this attack model, the attacker has full control over the server, or
+communications between clients and the server. It means that it can, among
+other things, deliver compromised Javascript to clients.
+
+### Web application
+
+In the [active attacker model](#active-attacker), where we consider that the
+server is compromised and/or malicious, compromised javascript can be shipped
+to clients. That Javascript code could thus leak decryption keys to the attacker.
+
+This is a [general and known
+problem](https://www.pageintegrity.net/browsercrypto.php#thebrowsercryptochickenandeggproblem)
+with web application applications that are doing client-side encryption.
+
+For setups that needs a high level of confidentiality and do not want to trust
+the server secsend is deployed onto, it is highly recommended to use the
+[command line interface](#command-line-usage) for both the sending and
+receiving parties.
diff --git a/__version__.py b/__version__.py
new file mode 100644
index 0000000..151a45a
--- /dev/null
+++ b/__version__.py
@@ -0,0 +1,6 @@
+__version__ = "1.0.0rc0"
+__author__ = "Adrien Guinet"
+__author_email__ = "adrien@guinet.me"
+__copyright__ = "Copyright 2022 Adrien Guinet"
+__url__ = "http://github.com/aguinet/secsend"
+__license__ = "GPL"
diff --git a/api/README.md b/api/README.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/api/README.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/api/secsend_api/__init__.py b/api/secsend_api/__init__.py
new file mode 100644
index 0000000..eed550b
--- /dev/null
+++ b/api/secsend_api/__init__.py
@@ -0,0 +1 @@
+from .app import declare_app
diff --git a/api/secsend_api/__version__.py b/api/secsend_api/__version__.py
new file mode 120000
index 0000000..bbba16a
--- /dev/null
+++ b/api/secsend_api/__version__.py
@@ -0,0 +1 @@
+../../__version__.py
\ No newline at end of file
diff --git a/api/secsend_api/app.py b/api/secsend_api/app.py
new file mode 100644
index 0000000..90f72ef
--- /dev/null
+++ b/api/secsend_api/app.py
@@ -0,0 +1,255 @@
+import json
+import jsonschema
+import secrets
+import os
+import sys
+from pathlib import Path
+from aiofiles import os as async_os
+
+from sanic import Sanic, Blueprint, response, exceptions
+from sanic.compat import stat_async
+from sanic.handlers import ContentRangeHandler
+from sanic.exceptions import HeaderNotFound
+
+from .cors import add_cors_headers
+from .options import setup_options
+from .backend import RootID, FileID, BaseID
+from .metadata import EncryptedFileMetadata, ALGOS
+from .backend import BackendErrorIDUnknown, BackendErrorIDExists, BackendErrorIDInvalid, BackendErrorIDWrongType, BackendError, BackendErrorFileLocked, BackendErrorIDUnavailable
+from .backend_files import BackendFiles
+
+encr_metadata_json_schema = {
+ 'type': 'object',
+ 'properties': {
+ 'name': {'type': 'string', 'contentEncoding': 'base64'},
+ 'mime_type': {'type': 'string', 'contentEncoding': 'base64'},
+ 'chunk_size': {'type': 'string', 'contentEncoding': 'base64'},
+ 'iv': {'type': 'string', 'contentEncoding': 'base64'},
+ 'timeout_s': {'type': 'number', 'min': 0},
+ 'version': {
+ 'type': 'number',
+ 'min': 1
+ },
+ 'algo': {
+ 'type': 'string',
+ 'enum': ALGOS
+ }
+ },
+ 'required': ['name','mime_type','iv','chunk_size','version','timeout_s'],
+}
+
+bp = Blueprint("api", version=1)
+
+def get_backend(request):
+ return request.app.ctx.backend
+
+@bp.post("/upload/new")
+async def upload_new(request):
+ try:
+ metadata = request.json
+ jsonschema.validate(instance=metadata, schema=encr_metadata_json_schema)
+ metadata['complete'] = False
+ # Will be set once the upload is finished
+ metadata['timeout_ts'] = 0
+ if metadata['timeout_s'] not in request.app.config.TIMEOUT_S_VALID:
+ raise exceptions.InvalidUsage("invalid timeout value")
+ metadata = EncryptedFileMetadata.from_jsonable(metadata)
+ except jsonschema.exceptions.ValidationError as e:
+ raise exceptions.InvalidUsage("invalid metadata: %s" % str(e))
+ if len(metadata.iv) != 12:
+ raise exceptions.InvalidUsage("IV length must be 12 bytes")
+
+ for i in range(8):
+ try:
+ rid = RootID.generate()
+ fid = rid.file_id()
+ f = get_backend(request).create(fid, metadata)
+ break
+ except BackendErrorIDExists:
+ continue
+ else:
+ raise BackendErrorIDUnavailable()
+ return response.json({"root_id": str(rid)})
+
+@bp.post("/upload/push/", stream=True)
+async def upload_push(request, id_):
+ rid = RootID.from_str(id_)
+ fid = rid.file_id()
+ f = get_backend(request).open(fid)
+ filesize_limit = request.app.config.FILESIZE_LIMIT
+ async with f.lock_write():
+ if filesize_limit is not None:
+ cursize = f.size
+ if f.metadata.complete:
+ raise exceptions.InvalidUsage("ID '%s' is already complete" % id_)
+ async with f.stream_append() as s:
+ while True:
+ body = await request.stream.read()
+ if body is None:
+ break
+ if filesize_limit is not None:
+ cursize += len(body)
+ if cursize >= filesize_limit:
+ f.delete()
+ raise exceptions.InvalidUsage("file limit exceeded")
+ await s.write(body)
+ return response.json({})
+
+@bp.post("/upload/finish/")
+async def upload_finish(request, id_):
+ rid = RootID.from_str(id_)
+ fid = rid.file_id()
+ f = get_backend(request).open(fid)
+ async with f.lock_write():
+ f.set_as_complete()
+ return response.json({})
+
+@bp.get("/metadata/")
+async def metadata(request, id_):
+ fid = FileID.from_str(id_)
+ f = get_backend(request).open(fid)
+ f.check_validity()
+ ret = f.metadata.jsonable()
+ ret = {
+ 'metadata': f.metadata.jsonable(),
+ 'size': f.size
+ }
+ return response.json(ret)
+
+@bp.get("/download/")
+async def download(request, id_):
+ fid = FileID.from_str(id_)
+ f = get_backend(request).open(fid)
+ if not f.metadata.complete:
+ raise exceptions.InvalidUsage("ID '%s' isn't completely uploaded yet" % str(fid))
+ f.check_validity()
+
+ path = f.content_path
+
+ length = f.size
+ _range = None
+ try:
+ stats = await stat_async(path)
+ _range = ContentRangeHandler(request, stats)
+ length -= _range.start
+ except HeaderNotFound:
+ pass
+
+ return await response.file_stream(
+ path,
+ headers={
+ "Content-Type": "application/octet-stream",
+ "Content-Length": length
+ },
+ _range=_range,
+ chunk_size=1024*1024*10,
+ )
+
+@bp.post("/delete/")
+async def upload_finish(request, id_):
+ rid = RootID.from_str(id_)
+ fid = rid.file_id()
+ f = get_backend(request).open(fid)
+ f.check_validity()
+ f.delete()
+ return response.json({})
+
+@bp.get("/config")
+async def config(request):
+ filesize_limit = request.app.config.FILESIZE_LIMIT
+ filesize_limit = 0 if filesize_limit is None else filesize_limit
+ return response.json({'timeout_s_valid': request.app.config.TIMEOUT_S_VALID, 'filesize_limit': filesize_limit})
+
+def declare_app(enable_cors=False, backend_files_root=None, html_root=None, timeout_s_valid=None, filesize_limit=None):
+ app = Sanic("secsend", env_prefix="SECSEND_")
+ app.config.FALLBACK_ERROR_FORMAT = "json"
+
+ if backend_files_root is not None:
+ app.config.BACKEND_FILES_ROOT = backend_files_root
+ if html_root is not None:
+ app.config.HTML_ROOT = html_root
+
+ if filesize_limit is None:
+ try:
+ filesize_limit = int(app.config.FILESIZE_LIMIT)
+ except AttributeError:
+ filesize_limit = None
+ app.config.FILESIZE_LIMIT = filesize_limit
+
+ if enable_cors:
+ # Add OPTIONS handlers to any route that is missing it
+ app.register_listener(setup_options, "before_server_start")
+
+ # Fill in CORS headers
+ app.register_middleware(add_cors_headers, "response")
+
+ if timeout_s_valid is None:
+ try:
+ timeout_s_valid = app.config.TIMEOUT_S_VALID
+ except AttributeError:
+ timeout_s_valid = "0"
+
+ try:
+ timeout_s_valid = [int(v) for v in str(timeout_s_valid).split(",")]
+ if any(v < 0 for v in timeout_s_valid):
+ raise ValueError("negative value")
+ except ValueError as e:
+ raise ValueError("invalid timeout_s_valid value: %s" % e)
+ app.config.TIMEOUT_S_VALID = timeout_s_valid
+
+ try:
+ html_root = app.config.HTML_ROOT
+ except AttributeError:
+ try:
+ import secsend_webapp
+ html_root = secsend_webapp.root
+ except ImportError:
+ html_root = None
+
+ if html_root is not None:
+ app.ctx.html_root = html_root
+ app.static("/", os.path.join(html_root, "index.html"))
+ app.static("/", html_root)
+ app.static("/dl", os.path.join(html_root, "dl.html"))
+ else:
+ print("Warning: no html_root has been specified, sanic won't serve the webapp", file=sys.stderr)
+
+ # Set backend
+ try:
+ backend_files_root = app.config.BACKEND_FILES_ROOT
+ except AttributeError:
+ backend_files_root = os.path.realpath("secsend_root")
+ print("Warning: no backend_files_root has been specified, using the path '%s'" % backend_files_root, file=sys.stderr)
+ app.ctx.backend = BackendFiles(Path(backend_files_root))
+
+ app.blueprint(bp)
+
+ @app.exception(BackendError)
+ async def catch_id_unk(request, exc):
+ raise exceptions.ServerError(str(exc))
+
+ @app.exception(BackendErrorIDUnavailable)
+ async def catch_id_unk(request, exc):
+ raise exceptions.ServerError(str(exc))
+
+ @app.exception(BackendErrorIDUnknown)
+ async def catch_id_unk(request, exc):
+ raise exceptions.NotFound(str(exc))
+
+ @app.exception(BackendErrorIDExists)
+ async def catch_id_unk(request, exc):
+ raise exceptions.InvalidUsage(str(exc))
+
+ @app.exception(BackendErrorIDInvalid)
+ async def catch_id_unk(request, exc):
+ raise exceptions.InvalidUsage(str(exc))
+
+ @app.exception(BackendErrorIDWrongType)
+ async def catch_id_wrong_type(request, exc):
+ raise exceptions.InvalidUsage(str(exc))
+
+ @app.exception(BackendErrorFileLocked)
+ async def catch_file_locked(request, exc):
+ raise exceptions.InvalidUsage(str(exc))
+
+ return app
diff --git a/api/secsend_api/backend.py b/api/secsend_api/backend.py
new file mode 100644
index 0000000..5950f99
--- /dev/null
+++ b/api/secsend_api/backend.py
@@ -0,0 +1,83 @@
+import secrets
+import base64
+import binascii
+import hashlib
+import struct
+
+class BaseID:
+ KIND = None
+ ID_LEN = 10
+
+ def __init__(self, id_: bytes):
+ self.id_ = id_
+
+ @classmethod
+ def from_str(cls, s):
+ try:
+ id_ = base64.urlsafe_b64decode(s + "="*((4-len(s)%4)%4))
+ except binascii.Error:
+ raise BackendErrorIDInvalid(s)
+
+ idkind = id_[0]
+ if cls.KIND is None:
+ cls = RootID if idkind == RootID.KIND else FileID
+ elif idkind != cls.KIND:
+ raise BackendErrorIDWrongType(idkind)
+
+ id_ = id_[1:]
+ if len(id_) != cls.ID_LEN:
+ raise BackendErrorIDInvalid(s)
+ return cls(id_)
+
+ @property
+ def bytes(self):
+ return self.id_
+
+ def __str__(self):
+ v = struct.pack(" BackendFile:
+ paths = self._id_to_paths(id_, create_dir=True)
+ fd_metadata = None
+ try:
+ fd_metadata = open(paths.metadata, "x")
+ except FileExistsError:
+ raise BackendErrorIDExists(id_)
+
+ ret = BackendFile(lambda: metadata, paths.content, paths.metadata, id_)
+ json.dump(metadata.jsonable(), fd_metadata)
+ fd_metadata.close()
+
+ return ret
+
+ def open(self, id_: FileID) -> BackendFile:
+ paths = self._id_to_paths(id_, create_dir=False)
+ return BackendFile(lambda: self.load_metadata(id_, paths.metadata), paths.content, paths.metadata, id_)
+
+ def load_metadata(self, id_: FileID, path: str) -> EncryptedFileMetadata:
+ try:
+ with open(path, "r") as f:
+ return EncryptedFileMetadata.from_jsonable(json.load(f))
+ except FileNotFoundError:
+ raise BackendErrorIDUnknown(id_)
+ except json.JSONDecodeError:
+ raise BackendErrorInvalidMetadata(id_)
+
+ def _id_to_paths(self, id_: FileID, create_dir: bool) -> FilePaths:
+ fdir = self.root / id_to_dir(id_)
+ if create_dir:
+ fdir.mkdir(parents=True,exist_ok=True)
+ return FilePaths(
+ metadata=fdir / ("%s.metadata" % id_.bytes.hex()),
+ content=fdir / ("%s.content" % id_.bytes.hex()))
diff --git a/api/secsend_api/cors.py b/api/secsend_api/cors.py
new file mode 100644
index 0000000..ed543f1
--- /dev/null
+++ b/api/secsend_api/cors.py
@@ -0,0 +1,23 @@
+from typing import Iterable
+
+
+def _add_cors_headers(response, methods: Iterable[str]) -> None:
+ allow_methods = list(set(methods))
+ if "OPTIONS" not in allow_methods:
+ allow_methods.append("OPTIONS")
+ headers = {
+ "Access-Control-Allow-Methods": ",".join(allow_methods),
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Headers": (
+ "origin, content-type, accept, "
+ "authorization, x-xsrf-token, x-request-id"
+ ),
+ }
+ response.headers.update(headers)
+
+
+def add_cors_headers(request, response):
+ if request.method != "OPTIONS":
+ methods = [method for method in request.route.methods]
+ _add_cors_headers(response, methods)
diff --git a/api/secsend_api/metadata.py b/api/secsend_api/metadata.py
new file mode 100644
index 0000000..78d2e91
--- /dev/null
+++ b/api/secsend_api/metadata.py
@@ -0,0 +1,31 @@
+import base64
+import datetime
+from dataclasses import dataclass, asdict
+
+ALGOS = ['aes-gcm']
+
+@dataclass
+class EncryptedFileMetadata:
+ name: bytes
+ mime_type: bytes
+ iv: bytes
+ chunk_size: bytes
+ key_sign: bytes
+ complete: bool = False
+ algo: str = ALGOS[0]
+ version: int = 1
+ timeout_s: int = 0
+ timeout_ts: int = 0
+
+ def jsonable(self):
+ ret = asdict(self)
+ for f in ("name","mime_type","iv","chunk_size","key_sign"):
+ v = base64.b64encode(ret[f]).decode("ascii")
+ ret[f] = v
+ return ret
+
+ @classmethod
+ def from_jsonable(cls, d):
+ for f in ("name","mime_type","iv","chunk_size","key_sign"):
+ d[f] = base64.b64decode(d[f])
+ return cls(**d)
diff --git a/api/secsend_api/options.py b/api/secsend_api/options.py
new file mode 100644
index 0000000..5f30a4a
--- /dev/null
+++ b/api/secsend_api/options.py
@@ -0,0 +1,48 @@
+from collections import defaultdict
+from typing import Dict, FrozenSet
+
+from sanic import Sanic, response
+from sanic.router import Route
+
+from .cors import _add_cors_headers
+
+
+def _compile_routes_needing_options(
+ routes: Dict[str, Route]
+) -> Dict[str, FrozenSet]:
+ needs_options = defaultdict(list)
+ # This is 21.12 and later. You will need to change this for older versions.
+ for route in routes.values():
+ if "OPTIONS" not in route.methods:
+ needs_options[route.uri].extend(route.methods)
+
+ return {
+ uri: frozenset(methods) for uri, methods in dict(needs_options).items()
+ }
+
+
+def _options_wrapper(handler, methods):
+ def wrapped_handler(request, *args, **kwargs):
+ nonlocal methods
+ return handler(request, methods)
+
+ return wrapped_handler
+
+
+async def options_handler(request, methods) -> response.HTTPResponse:
+ resp = response.empty()
+ _add_cors_headers(resp, methods)
+ return resp
+
+
+def setup_options(app: Sanic, _):
+ app.router.reset()
+ needs_options = _compile_routes_needing_options(app.router.routes_all)
+ for uri, methods in needs_options.items():
+ app.add_route(
+ _options_wrapper(options_handler, methods),
+ uri,
+ methods=["OPTIONS"],
+ )
+ app.router.finalize()
+
diff --git a/api/secsend_api/prod.py b/api/secsend_api/prod.py
new file mode 100644
index 0000000..4c901dc
--- /dev/null
+++ b/api/secsend_api/prod.py
@@ -0,0 +1,2 @@
+from .app import declare_app
+app = declare_app()
diff --git a/api/secsend_api/timeout.py b/api/secsend_api/timeout.py
new file mode 100644
index 0000000..891c7be
--- /dev/null
+++ b/api/secsend_api/timeout.py
@@ -0,0 +1,14 @@
+import datetime
+
+def timeout_ts(interval_s):
+ if interval_s == 0:
+ return 0
+ # Always process date as UTC
+ delta = datetime.timedelta(seconds=interval_s)
+ expired = datetime.datetime.now(datetime.timezone.utc) + delta
+ return expired.timestamp()
+
+def ts_has_expired(timestamp):
+ now = datetime.datetime.now(datetime.timezone.utc)
+ expired = datetime.datetime.fromtimestamp(timestamp, datetime.timezone.utc)
+ return now >= expired
diff --git a/api/setup.py b/api/setup.py
new file mode 100644
index 0000000..a5b47ed
--- /dev/null
+++ b/api/setup.py
@@ -0,0 +1,26 @@
+from setuptools import setup
+from pathlib import Path
+
+about = {}
+this_dir = Path(__file__).parent.resolve()
+with open(this_dir / "secsend_api" / "__version__.py", "r") as f:
+ exec(f.read(), about)
+
+setup(name='secsend_api',
+ version=about['__version__'],
+ description='secsend server API',
+ url=about['__url__'],
+ author=about['__author__'],
+ author_email=about['__author_email__'],
+ license=about['__license__'],
+ packages=['secsend_api'],
+ install_requires=[
+ 'jsonschema==4.15.*',
+ 'sanic==21.12.*',
+ ],
+ extras_require={
+ 'dev': [
+ 'sanic-testing==0.8.3',
+ ],
+ }
+)
diff --git a/api/tests/test_api.py b/api/tests/test_api.py
new file mode 100644
index 0000000..94b32cd
--- /dev/null
+++ b/api/tests/test_api.py
@@ -0,0 +1,194 @@
+import unittest
+import tempfile
+import base64
+import time
+
+from secsend_api import declare_app
+from secsend_api.backend import FileID, RootID, BaseID, BackendErrorIDUnavailable
+from secsend_api.backend_files import BackendFiles
+from secsend_api.metadata import EncryptedFileMetadata
+from sanic_testing.reusable import ReusableClient
+from sanic_testing.testing import SanicTestClient
+from sanic_testing import TestManager
+
+METADATA = EncryptedFileMetadata(name=b"ENCRYPTED_NAME", mime_type=b"ENCRYPTED_MIME_TYPE", iv=b"\x00"*12, chunk_size=b"ENCRYPTED_CHUNK_SIZE", key_sign=b"")
+
+class TestBackendFiles(unittest.TestCase):
+ def setUp(self):
+ self.root = tempfile.TemporaryDirectory(prefix="secsend_api")
+ self.app = declare_app(enable_cors=False, backend_files_root=self.root.name, html_root=None, timeout_s_valid=[0,1])
+
+ def tearDown(self):
+ self.root.cleanup()
+
+ def test_api_config(self):
+ _, response = self.app.test_client.get("/v1/config")
+ self.assertEqual(response.status, 200)
+ self.assertEqual(response.json['filesize_limit'], 0)
+
+ def test_api_invalid_metadata(self):
+ _, response = self.app.test_client.post("/v1/upload/new", json={})
+ self.assertEqual(response.status, 400)
+
+ def test_api_unk_id(self):
+ rid = RootID.generate()
+ id_ = str(rid.file_id())
+ for url in ("/metadata", "/download"):
+ _, response = self.app.test_client.get("%s/%s" % (url,id_))
+ self.assertEqual(response.status, 404)
+
+ _, response = self.app.test_client.post("/v1/upload/push/%s" % str(rid), json=METADATA.jsonable())
+ self.assertEqual(response.status, 404)
+
+ def test_api_invalid_id(self):
+ for id_ in ("0", base64.urlsafe_b64encode(b"AA")):
+ for url in ("metadata", "download"):
+ _, response = self.app.test_client.get("/v1/%s/%s" % (url,id_))
+ self.assertEqual(response.status, 400)
+
+ _, response = self.app.test_client.post("/v1/upload/push/%s" % id_, json=METADATA.jsonable())
+ self.assertEqual(response.status, 400)
+
+ def test_api_upload_download_delete(self):
+ client = self.app.test_client
+ _, response = client.post("/v1/upload/new", json=METADATA.jsonable())
+ self.assertEqual(response.status, 200)
+ rid = response.json['root_id']
+ rid = RootID.from_str(rid)
+ rid_s = str(rid)
+
+ data = b"hello world!"
+
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data[:4])
+ self.assertEqual(response.status, 200)
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data[4:])
+ self.assertEqual(response.status, 200)
+ _, response = client.post("/v1/upload/finish/%s" % rid_s)
+ self.assertEqual(response.status, 200)
+
+ id_ = str(rid.file_id())
+ _, response = client.get("/v1/metadata/%s" % id_)
+ self.assertEqual(response.status, 200)
+ d = response.json
+ self.assertEqual(d['size'], len(data))
+ ret_metadata = d['metadata']
+ self.assertTrue(ret_metadata['complete'])
+ del ret_metadata['complete']
+ ref = METADATA.jsonable()
+ del ref['complete']
+ self.assertEqual(ret_metadata, ref)
+
+ _, response = client.get("/v1/download/%s" % id_)
+ self.assertEqual(response.status, 200)
+ self.assertEqual(response.read(), data)
+
+ _, response = client.post("/v1/delete/%s" % id_)
+ self.assertEqual(response.status, 400)
+
+ _, response = client.post("/v1/delete/%s" % rid_s)
+ self.assertEqual(response.status, 200)
+
+ _, response = client.get("/v1/download/%s" % id_)
+ self.assertEqual(response.status, 404)
+
+ def test_api_upload_timeout(self):
+ client = self.app.test_client
+ metadata = METADATA.jsonable()
+ metadata['timeout_s'] = 1
+ _, response = client.post("/v1/upload/new", json=metadata)
+ self.assertEqual(response.status, 200)
+ rid = response.json['root_id']
+ rid = RootID.from_str(rid)
+ rid_s = str(rid)
+
+ data = b"hello world!"
+
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data)
+ self.assertEqual(response.status, 200)
+ time.sleep(2)
+ _, response = client.post("/v1/upload/finish/%s" % rid_s)
+ self.assertEqual(response.status, 200)
+
+ id_ = str(rid.file_id())
+ _, response = client.get("/v1/download/%s" % id_)
+ self.assertEqual(response.status, 200)
+
+ time.sleep(2)
+ _, response = client.get("/v1/download/%s" % id_)
+ self.assertEqual(response.status, 404)
+
+ def test_api_invalid_timeout(self):
+ client = self.app.test_client
+ metadata = METADATA.jsonable()
+ metadata['timeout_s'] = 4
+ _, response = client.post("/v1/upload/new", json=metadata)
+ self.assertEqual(response.status, 400)
+
+class TestBackendFilesFilesizeLimit(unittest.TestCase):
+ def setUp(self):
+ self.root = tempfile.TemporaryDirectory(prefix="secsend_api")
+ self.filesize_limit = 1024
+ self.app = declare_app(enable_cors=False, backend_files_root=self.root.name, html_root=None, timeout_s_valid=[0,1], filesize_limit=self.filesize_limit)
+
+ def test_api_config(self):
+ _, response = self.app.test_client.get("/v1/config")
+ self.assertEqual(response.status, 200)
+ self.assertEqual(response.json['filesize_limit'], self.filesize_limit)
+
+ def test_api_upload_okay(self):
+ client = self.app.test_client
+ _, response = client.post("/v1/upload/new", json=METADATA.jsonable())
+ self.assertEqual(response.status, 200)
+ rid = response.json['root_id']
+ rid = RootID.from_str(rid)
+ rid_s = str(rid)
+
+ data = b"hello world!"
+
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data)
+ self.assertEqual(response.status, 200)
+ _, response = client.post("/v1/upload/finish/%s" % rid_s)
+ self.assertEqual(response.status, 200)
+
+ def test_api_upload_toobig(self):
+ client = self.app.test_client
+ _, response = client.post("/v1/upload/new", json=METADATA.jsonable())
+ self.assertEqual(response.status, 200)
+ rid = response.json['root_id']
+ rid = RootID.from_str(rid)
+ rid_s = str(rid)
+
+ data = b"A"*self.filesize_limit
+
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data[:4])
+ self.assertEqual(response.status, 200)
+ _, response = client.post("/v1/upload/push/%s" % rid_s, data=data[4:])
+ self.assertEqual(response.status, 400)
+ _, response = client.post("/v1/upload/finish/%s" % rid_s)
+ self.assertEqual(response.status, 404)
+
+class TestBackendFilesTinyIDs(unittest.TestCase):
+ def setUp(self):
+ self.root = tempfile.TemporaryDirectory(prefix="secsend_api")
+ self.app = declare_app(enable_cors=False, backend_files_root=self.root.name, html_root=None, timeout_s_valid=[0,1])
+ self.org_ID_LEN = BaseID.ID_LEN
+ BaseID.ID_LEN = 1
+
+ def tearDown(self):
+ self.root.cleanup()
+ BaseID.ID_LEN = self.org_ID_LEN
+
+ def test_api_lots_id(self):
+ client = self.app.test_client
+ # Birthday paradox: after 128 insertions, we'll have a 50% chance to
+ # hit an already existing file. At some point, we should catch a 500
+ # error.
+ for i in range(256):
+ _, response = client.post("/v1/upload/new", json=METADATA.jsonable())
+ if response.status == 200:
+ continue
+ if response.status == 500:
+ self.assertEqual(response.json['message'], str(BackendErrorIDUnavailable()))
+ return
+ # We should had come to a point where we were not able to catch an ID
+ self.assertTrue(False)
diff --git a/api/tests/test_backend_files.py b/api/tests/test_backend_files.py
new file mode 100644
index 0000000..f9a7e7b
--- /dev/null
+++ b/api/tests/test_backend_files.py
@@ -0,0 +1,48 @@
+import unittest
+import tempfile
+import os
+
+from secsend_api.backend import RootID, FileID, BackendErrorIDExists, BackendErrorIDUnknown, BackendErrorFileLocked
+from secsend_api.metadata import EncryptedFileMetadata
+from secsend_api.backend_files import BackendFiles
+
+METADATA = EncryptedFileMetadata(name=b"ENCRYPTED_NAME", mime_type=b"ENCRYPTED_MIME_TYPE", iv=b"\x00"*16, chunk_size=b"ENCRYPTED_CHUNK_SIZE", key_sign=b"")
+
+class TestBackendFiles(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.root = tempfile.TemporaryDirectory(prefix="secsend_api")
+ self.backend = BackendFiles(self.root.name)
+
+ def tearDown(self):
+ self.root.cleanup()
+
+ async def test_create_read(self):
+ fid = RootID.generate().file_id()
+ data = b"coucou"
+ async with self.backend.create(fid, METADATA).stream_append() as s:
+ await s.write(data)
+
+ f = self.backend.open(fid)
+ self.assertEqual(f.metadata, METADATA)
+ async with f.stream_read() as s:
+ self.assertEqual(await s.read(), data)
+
+ async def test_lock(self):
+ fid = RootID.generate().file_id()
+ f = self.backend.create(fid, METADATA)
+ with self.assertRaises(BackendErrorFileLocked):
+ async with f.lock_write():
+ async with f.lock_write(): pass
+
+ def test_create_exists(self):
+ fid = RootID.generate().file_id()
+ self.backend.create(fid, METADATA)
+ with self.assertRaises(BackendErrorIDExists):
+ self.backend.create(fid, METADATA)
+
+ def test_read_unk(self):
+ fid = RootID.generate().file_id()
+ with self.assertRaises(BackendErrorIDUnknown):
+ # Force calling the metadata, otherwise it is lazy loaded and no
+ # exception happens
+ self.backend.open(fid).metadata
diff --git a/cli/README.md b/cli/README.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/cli/bin/secadmin b/cli/bin/secadmin
new file mode 100644
index 0000000..e79c267
--- /dev/null
+++ b/cli/bin/secadmin
@@ -0,0 +1,30 @@
+#!/usr/bin/env python
+import argparse
+import sys
+import requests
+
+from secsend.client import DownloadURL, RootID, ClientAPI
+from secsend.cli import process_error
+
+def main():
+ parser = argparse.ArgumentParser(description="Encrypted files administration")
+ actions = parser.add_mutually_exclusive_group(required=True)
+ actions.add_argument("-d", action='store_true', dest='delete', help="Delete (incomplete) file")
+ parser.add_argument("dest", type=str, help="Admin URL of the uploaded file")
+ args = parser.parse_args()
+
+ url = DownloadURL.from_url(args.dest)
+ if not isinstance(url.id, RootID):
+ print("Error: please use the Admin URL to resume the upload", file=sys.stderr)
+ sys.exit(1)
+
+ client = ClientAPI(requests.Session(), url.server)
+ if args.delete:
+ client.delete(url.id)
+ print("File deleted with success")
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as e:
+ process_error(e)
diff --git a/cli/bin/secdownload b/cli/bin/secdownload
new file mode 100644
index 0000000..d080fbd
--- /dev/null
+++ b/cli/bin/secdownload
@@ -0,0 +1,61 @@
+#!/usr/bin/env python
+import argparse
+import requests
+import sys
+
+from secsend.client import DownloadURL, RootID, ClientAPI
+from secsend.stream import DownloadCtx
+from secsend.utils import sanitize_name, get_nonexistant_file
+from secsend.cli import get_progressbar, ask_password, process_error
+
+def main():
+ parser = argparse.ArgumentParser(description="Upload encrypted files")
+ parser.add_argument("-c", action='store_true', dest='resume', help="Resume download")
+ parser.add_argument("-o", type=str, dest='output', help="Output path")
+ parser.add_argument("source", type=str, help="Download URL")
+ args = parser.parse_args()
+
+ url = DownloadURL.from_url(args.source).file_url()
+ if not url.has_key():
+ ask_password(url)
+ ctx = DownloadCtx.from_url(url)
+ metadata = ctx.get_metadata()
+
+ if args.output and args.output == "-":
+ out = sys.stdout.buffer
+ out_seek = 0
+ name = None
+ if args.resume:
+ print("Error: can't resume a download when writing to stdout")
+ sys.exit(1)
+ else:
+ if args.output:
+ name = args.output
+ else:
+ name = sanitize_name(metadata.name)
+ if not args.resume:
+ name = get_nonexistant_file(name)
+
+ if args.resume:
+ out = open(name, "ab")
+ out_seek = out.tell()
+ print(out_seek)
+ else:
+ out = open(name, "wb")
+ out_seek = 0
+
+ print("[+] File mime: %s" % metadata.mime_type, file=sys.stderr)
+
+ with get_progressbar(name, ctx.decrypted_size()) as bar:
+ done = out_seek
+ bar.update(done)
+ for d in ctx.download(out_seek):
+ out.write(d)
+ done += len(d)
+ bar.update(done)
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as e:
+ process_error(e)
diff --git a/cli/bin/secupload b/cli/bin/secupload
new file mode 100644
index 0000000..8a547f4
--- /dev/null
+++ b/cli/bin/secupload
@@ -0,0 +1,74 @@
+#!/usr/bin/env python
+import argparse
+import sys
+import requests
+import secrets
+import getpass
+from pathlib import Path
+
+from secsend.stream import UploadCtx
+from secsend.client import DownloadURL, RootID
+from secsend.cli import get_progressbar, process_error
+
+def main():
+ parser = argparse.ArgumentParser(description="Upload encrypted files")
+ parser.add_argument("-c", action='store_true', dest='resume', help="Resume upload (if not reading from stdin).")
+ parser.add_argument("--mime", type=str, help="Override mime type.")
+ parser.add_argument("--filename", type=str, help="Override file name. Must be set if upload from stdin.")
+ parser.add_argument("--timeout", type=int, help="Time limit in seconds. Default is the highest value supported by the server. (0 means infinity, if supported)")
+ parser.add_argument("--auth-login", type=str, help="HTTP authentication login")
+ parser.add_argument("--auth-password", type=str, help="HTTP authentication password (prompted if not provided)")
+ parser.add_argument("source", type=str, help="File to upload (- to read from stdin).")
+ parser.add_argument("dest", type=str, help="URL to the server (e.g. https://share.example.com).")
+ args = parser.parse_args()
+
+ if args.resume and args.source == "-":
+ print("Error: can't resume upload from stdin", file=sys.stderr)
+ sys.exit(1)
+
+ auth = None
+ if args.auth_login is not None:
+ password = args.auth_password
+ if password is None:
+ password = getpass.getpass()
+ auth = (args.auth_login, password)
+
+ if args.source == "-":
+ if args.filename is None:
+ print("Error: please use --filename to upload from stdin", file=sys.stderr)
+ sys.exit(1)
+ ctx = UploadCtx.from_stdin(args.filename, args.mime, auth=auth)
+ else:
+ ctx = UploadCtx.from_source_file(args.source, args.mime, auth=auth)
+
+ if args.resume:
+ url = DownloadURL.from_url(args.dest)
+ if not isinstance(url.id, RootID):
+ print("Error: please use the Admin URL to resume the upload", file=sys.stderr)
+ sys.exit(1)
+ ctx.upload_resume(url)
+ else:
+ ctx.upload_new(args.dest, args.timeout)
+ print("[+] File ID: %s" % ctx.id.file_id())
+ print("[+] File key: %s" % ctx.key.hex())
+ print("[+] Admin URL: %s" % ctx.url)
+ print("[+] Download URL: %s" % ctx.url.file_url())
+
+ class Progress:
+ def __init__(self, bar):
+ self.bar = bar
+ self.cur = 0
+
+ def __call__(self, l):
+ self.cur += l
+ self.bar.update(self.cur)
+
+ with get_progressbar(ctx.name, ctx.in_size) as bar:
+ ctx.upload_push(Progress(bar))
+ ctx.upload_finish()
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as e:
+ process_error(e)
diff --git a/cli/secsend/__init__.py b/cli/secsend/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/cli/secsend/__version__.py b/cli/secsend/__version__.py
new file mode 120000
index 0000000..bbba16a
--- /dev/null
+++ b/cli/secsend/__version__.py
@@ -0,0 +1 @@
+../../__version__.py
\ No newline at end of file
diff --git a/cli/secsend/cli.py b/cli/secsend/cli.py
new file mode 100644
index 0000000..57d29a5
--- /dev/null
+++ b/cli/secsend/cli.py
@@ -0,0 +1,30 @@
+import sys
+import progressbar
+
+from secsend.client import DownloadURL
+
+def get_progressbar(name, size):
+ if name is None:
+ name = ''
+ widgets = [
+ name + ' ', progressbar.Bar(),
+ ' ', progressbar.ETA(),
+ ' ', progressbar.FileTransferSpeed(),
+ ]
+ if size is None:
+ size = progressbar.base.UnknownLength
+ return progressbar.ProgressBar(max_value=size,widgets=widgets)
+
+def ask_password(url: DownloadURL):
+ while True:
+ pwd = input("Enter password: ")
+ try:
+ url.key = DownloadURL.key_from_txt(pwd)
+ break
+ except ValueError:
+ print("Invalid value.", file=sys.stderr)
+ continue
+
+def process_error(exc):
+ print("Error: %s" % str(exc), file=sys.stderr)
+ sys.exit(1)
diff --git a/cli/secsend/client.py b/cli/secsend/client.py
new file mode 100644
index 0000000..bf06498
--- /dev/null
+++ b/cli/secsend/client.py
@@ -0,0 +1,172 @@
+import binascii
+import base64
+import struct
+import hashlib
+import secrets
+from dataclasses import dataclass
+from typing import List
+from urllib.parse import urlparse, parse_qsl
+
+from .metadata import EncryptedFileMetadata
+from .utils import to_base_36
+
+class IDWrongType(Exception):
+ def __init__(self, kind):
+ super().__init__("Wrong ID type")
+
+class BaseID:
+ KIND = None
+ ID_LEN = 10
+
+ def __init__(self, id_: bytes):
+ self.id_ = id_
+
+ @classmethod
+ def from_str(cls, s):
+ try:
+ id_ = base64.urlsafe_b64decode(s + "="*((4-len(s)%4)))
+ except binascii.Error:
+ raise IDInvalid(s)
+
+ idkind = id_[0]
+ if cls.KIND == None:
+ cls = RootID if idkind == RootID.KIND else FileID
+ elif idkind != cls.KIND:
+ raise IDWrongType(idkind)
+
+ id_ = id_[1:]
+ if len(id_) != cls.ID_LEN:
+ raise IDInvalid(s)
+ return cls(id_)
+
+ @property
+ def bytes(self):
+ return self.id_
+
+ def __str__(self):
+ v = struct.pack(" 1 and timeout_s_valid[0] == 0:
+ timeout_s_valid.append(0)
+ timeout_s_valid.pop(0)
+ return ServerConfig(timeout_s_valid=timeout_s_valid)
+
+class ClientAPI:
+ def __init__(self, session, server: str):
+ self.server = server.rstrip(" /")
+ self.server = server
+ self.session = session
+
+ def _get_url(self, uri: str) -> str:
+ return "%s/v1/%s" % (self.server,uri)
+
+ def config(self) -> ServerConfig:
+ r = self.session.get(self._get_url("config"))
+ r.raise_for_status()
+ return ServerConfig.from_jsonable(r.json())
+
+ def metadata(self, id_: FileID) -> EncryptedFileMetadata:
+ r = self.session.get(self._get_url("metadata/%s" % str(id_)))
+ r.raise_for_status()
+ d = r.json()
+ metadata = EncryptedFileMetadata.from_jsonable(d['metadata'])
+ size = d['size']
+ return metadata, size
+
+ def delete(self, id_: RootID):
+ r = self.session.post(self._get_url("delete/%s" % str(id_)))
+ r.raise_for_status()
+
+ def download(self, id_: FileID, seek = 0):
+ headers = {}
+ if seek > 0:
+ headers["Range"] = "bytes=%d-" % seek
+ r = self.session.get(self._get_url("download/%s" % str(id_)), headers=headers, stream=True)
+ r.raise_for_status()
+ return r
+
+ def upload_new(self, metadata: EncryptedFileMetadata):
+ r = self.session.post(self._get_url("upload/new"), json=metadata.jsonable())
+ r.raise_for_status()
+ rid = r.json()['root_id']
+ return RootID.from_str(rid)
+
+ def upload_push(self, id_: RootID, data):
+ r = self.session.post(self._get_url("upload/push/%s" % str(id_)), data=data)
+ r.raise_for_status()
+
+ def upload_finish(self, id_: RootID):
+ r = self.session.post(self._get_url("upload/finish/%s" % str(id_)))
+ r.raise_for_status()
+
+ def delete(self, id_: RootID):
+ r = self.session.post(self._get_url("delete/%s" % str(id_)))
+ r.raise_for_status()
+
+class DownloadURL:
+ def __init__(self, server: str, id_: BaseID, key: bytes):
+ self.server = server
+ self.id = id_
+ self.key = key
+
+ def has_key(self):
+ return self.key is not None
+
+ @staticmethod
+ def key_from_txt(s: str) -> bytes:
+ v = int(s, 36)
+ return v.to_bytes(16, "little")
+
+ @staticmethod
+ def key_to_txt(key: bytes) -> str:
+ # Encode key as a little edian number
+ v = int.from_bytes(key, "little")
+ return to_base_36(v)
+
+ @classmethod
+ def from_url(cls, url: str):
+ url = urlparse(url)
+ key = cls.key_from_txt(url.fragment)
+ if len(key) == 0:
+ key = None
+ path = url.path
+ # Support both URL format
+ if path.startswith("/v1/download/"):
+ id_ = path[len("/v1/download/"):]
+ elif path == "/dl":
+ qsl = dict(parse_qsl(url.query))
+ id_ = qsl.get("id", None)
+ if id_ is None:
+ raise ValueError("invalid URL format")
+ else:
+ raise ValueError("invalid URL format")
+ return cls("%s://%s" % (url.scheme, url.netloc), BaseID.from_str(id_), key)
+
+ def file_url(self):
+ if isinstance(self.id, RootID):
+ return DownloadURL(self.server, self.id.file_id(), self.key)
+ return self
+
+ def __str__(self):
+ return "%s/dl?id=%s#%s" % (self.server, self.id, self.key_to_txt(self.key))
diff --git a/cli/secsend/crypto.py b/cli/secsend/crypto.py
new file mode 100644
index 0000000..29d01a5
--- /dev/null
+++ b/cli/secsend/crypto.py
@@ -0,0 +1,96 @@
+import struct
+import hashlib
+import base64
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+
+
+# AES-GCM uses a 4-byte counter. It means we can't process chunks with more
+# than 2**32 bytes (~4GB).
+class GCMIV:
+ IV_LEN = 12
+ def __init__(self, iv_base: bytes):
+ assert(len(iv_base) == self.IV_LEN)
+ self._iv_base = iv_base
+
+ def chunk_iv(self, idx):
+ # We add idx as a 64-bit little endian integer to the first 8 bytes of
+ # iv_base
+ n, = struct.unpack(" int:
+ if self.encrypt:
+ in_chunk_size = decrypted_chunk_size
+ out_chunk_size = decrypted_chunk_size+self.TAG_SIZE
+ else:
+ in_chunk_size = decrypted_chunk_size+self.TAG_SIZE
+ out_chunk_size = decrypted_chunk_size
+
+ nchunks = in_size//in_chunk_size
+ ret = nchunks*out_chunk_size;
+ rem = in_size%in_chunk_size;
+ if (rem > 0):
+ if self.encrypt:
+ rem += self.TAG_SIZE
+ else:
+ rem -= self.TAG_SIZE
+ ret += rem
+ return ret;
+
+ def _chunk_iv(self):
+ return self._iv.chunk_iv(self._chunk_idx)
+
+ def seek_chunk_idx(self, idx):
+ self._chunk_idx = idx
+
+ def process(self, data):
+ assert(len(data) < (1<<32))
+ ret = self._func(self._chunk_iv(), data, None)
+ self._chunk_idx += 1
+ return ret
+
+ def encr_sign_metadata(self, idx: int, toencr: bytes, tosign: bytes) -> bytes:
+ return self._aes_metadata.encrypt(self._iv.chunk_iv(idx), toencr, tosign)
+
+ def decr_verify_metadata(self, idx: int, encr: bytes, signed: bytes) -> bytes:
+ return self._aes_metadata.decrypt(self._iv.chunk_iv(idx), encr, signed)
+
+
+def SignKey(key: bytes, nonce: bytes) -> bytes:
+ H = hashlib.sha256(b"secsend_sign")
+ H.update(nonce)
+ H.update(key)
+ return H.digest()
+
+def VerifyKey(sign: bytes, key: bytes, nonce: bytes) -> bool:
+ return SignKey(key, nonce) == sign
diff --git a/cli/secsend/metadata.py b/cli/secsend/metadata.py
new file mode 100644
index 0000000..8832b11
--- /dev/null
+++ b/cli/secsend/metadata.py
@@ -0,0 +1,62 @@
+import base64
+import struct
+from dataclasses import dataclass, asdict
+from enum import Enum
+
+ALGOS = ['aes-gcm']
+
+@dataclass
+class EncryptedFileMetadata:
+ name: bytes
+ mime_type: bytes
+ iv: bytes
+ chunk_size: bytes
+ key_sign: bytes
+ timeout_s: int = 0
+ complete: bool = False
+ algo: str = ALGOS[0]
+ version: int = 1
+
+ def jsonable(self):
+ ret = asdict(self)
+ for f in ("name","mime_type","iv","chunk_size","key_sign"):
+ v = base64.b64encode(ret[f]).decode("ascii")
+ ret[f] = v
+ return ret
+
+ @classmethod
+ def from_jsonable(cls, d):
+ for f in ("name","mime_type","iv","chunk_size","key_sign"):
+ d[f] = base64.b64decode(d[f])
+ d = {k: d[k] for k in ("name","mime_type","iv","chunk_size","key_sign","algo","version")}
+ return cls(**d)
+
+@dataclass
+class FileMetadata:
+ name: str
+ mime_type: str
+ iv: bytes
+ chunk_size: int
+ key_sign: bytes
+ timeout_s: int
+ complete: bool = False
+ algo: str = ALGOS[0]
+ version: int = 1
+
+def encryptMetadata(metadata: FileMetadata, crypto) -> EncryptedFileMetadata:
+ ret = asdict(metadata)
+ ret['name'] = ret['name'].encode("utf8")
+ ret['mime_type'] = ret['mime_type'].encode("ascii")
+ ret['chunk_size'] = struct.pack(" FileMetadata:
+ ret = asdict(encr)
+ for idx,f in enumerate(('name','mime_type','chunk_size')):
+ ret[f] = crypto.decr_verify_metadata(idx, ret[f], b"")
+ ret['chunk_size'] = struct.unpack(" 0:
+ cb_done(self.chunk_seek)
+ data = source_stream.read(self.in_chunk_size)
+ cb_done(len(data))
+ data = self.data_process.process(data)
+ data = data[self.bytes_skip:]
+ yield data
+
+ while True:
+ data = source_stream.read(self.in_chunk_size)
+ if data is None or len(data) == 0:
+ return
+ cb_done(len(data))
+ data = self.data_process.process(data)
+ yield data
+
+def stream_transform(source_stream: io.IOBase, data_process, in_chunk_size: int, out_seek: int = 0):
+ ctx = StreamTransform(data_process, in_chunk_size, out_seek)
+ if out_seek > 0:
+ source_stream.seek(ctx.chunk_seek, 0)
+ yield from ctx(source_stream)
+
+
+MIME = magic.Magic(mime=True)
+
+class UploadCtx:
+ def __init__(self, input_stream, path, name, mime, auth, in_size):
+ self.input_stream = input_stream
+ self.path = path
+ self.mime = mime
+ self.name = name
+ self.in_size = in_size
+ self.session = requests.Session()
+ if auth is not None:
+ self.session.auth = auth
+ self._config = None
+ self.id = None
+
+ def config(self):
+ if self._config is None:
+ self._config = self.client.config()
+ return self._config
+
+ @classmethod
+ def from_stdin(cls, name, mime=None, auth=None):
+ if mime is None:
+ mime = "application/octet-stream"
+ return cls(input_stream=sys.stdin.buffer, path=None, name=name, mime=mime, auth=auth, in_size=None)
+
+ @classmethod
+ def from_source_file(cls, path, mime=None, auth=None):
+ if mime is None:
+ mime = MIME.from_file(path)
+ name = pathlib.Path(path).name
+ try:
+ in_size = os.path.getsize(path)
+ except OSError:
+ in_size = None
+ return cls(input_stream=open(path, "rb"), path=path, name=name, mime=mime, auth=auth,in_size=in_size)
+
+ @property
+ def url(self):
+ return DownloadURL(self.server, self.id, self.key)
+
+
+ def upload_new(self, server: str, timeout_s: Optional[int] = None):
+ assert(self.id is None)
+ self.client = ClientAPI(self.session, server)
+
+ if timeout_s is None:
+ timeout_s = self.config().timeout_s_valid[-1]
+ else:
+ if timeout_s not in self.config().timeout_s_valid:
+ raise ValueError("unsupported timeout value. Supported values are: " + ",".join((str(v) for v in self.config().timeout_s_valid)))
+
+ self.key = secrets.token_bytes(16)
+ iv = secrets.token_bytes(AESGCMChunks.IV_LEN)
+ self.metadata = FileMetadata(
+ name=self.name,
+ mime_type=self.mime,
+ iv=iv,
+ chunk_size=1024*1024,
+ key_sign=SignKey(self.key, iv),
+ timeout_s=timeout_s)
+
+ self.encrypt = AESGCMChunks(self.metadata.iv, self.key, encrypt=True)
+
+ self.server = server
+ self.id = self.client.upload_new(encryptMetadata(self.metadata, self.encrypt))
+ self.stream = StreamTransform(self.encrypt, self.metadata.chunk_size, out_seek=0)
+ return self.id
+
+ def upload_resume(self, dest: DownloadURL):
+ assert(self.id is None)
+ self.server = dest.server
+ self.client = ClientAPI(self.session, dest.server)
+ self.id = dest.id
+ self.key = dest.key
+
+ metadata, out_size = self.client.metadata(self.id.file_id())
+ if metadata.algo != ALGOS[0]:
+ raise ValueError("algorithm '%s' not supported" % metadata.algo)
+
+ self.encrypt = AESGCMChunks(metadata.iv, dest.key, encrypt=True)
+ self.metadata = decryptMetadata(metadata, self.encrypt)
+ self.stream = StreamTransform(self.encrypt, self.metadata.chunk_size, out_seek=out_size)
+ self.input_stream.seek(self.stream.chunk_seek)
+
+ def upload_push(self, cb_done=lambda l: l):
+ assert(self.id is not None)
+ self.client.upload_push(self.id, self.stream(self.input_stream, cb_done))
+
+ def upload_finish(self):
+ assert(self.id is not None)
+ self.client.upload_finish(self.id)
+
+ def encrypted_size(self):
+ if self.in_size is None:
+ return None
+ return self.encrypt.out_size(self.in_size, self.metadata.chunk_size)
+
+class DownloadCtx:
+ def __init__(self, server: str, id_: str, key: bytes):
+ self.id = id_
+ self.client = ClientAPI(requests.Session(), server)
+ self.key = key
+ self.metadata = None
+ self.decrypt = None
+
+ @classmethod
+ def from_url(cls, url: DownloadURL):
+ return cls(url.server, url.id, url.key)
+
+ def get_metadata(self) -> FileMetadata:
+ if self.metadata is not None:
+ return self.metadata
+ # Get metadata
+ metadata, size = self.client.metadata(self.id)
+ if not VerifyKey(metadata.key_sign, self.key, metadata.iv):
+ raise InvalidKey()
+ self.decrypt = AESGCMChunks(metadata.iv, self.key, encrypt=False)
+ self.metadata = decryptMetadata(metadata, self.decrypt)
+ self.size = size
+ return self.metadata
+
+ def decrypted_size(self):
+ metadata = self.get_metadata()
+ return self.decrypt.out_size(self.size, metadata.chunk_size)
+
+ def download(self, out_seek = 0):
+ assert(self.metadata is not None)
+ assert(self.decrypt is not None)
+ if not VerifyKey(self.metadata.key_sign, self.key, self.metadata.iv):
+ raise InvalidKey()
+ stream = StreamTransform(self.decrypt, self.metadata.chunk_size+AESGCMChunks.TAG_SIZE, out_seek)
+ r = self.client.download(self.id, stream.chunk_seek)
+ with r:
+ yield from stream(r.raw)
diff --git a/cli/secsend/utils.py b/cli/secsend/utils.py
new file mode 100644
index 0000000..1d3d845
--- /dev/null
+++ b/cli/secsend/utils.py
@@ -0,0 +1,29 @@
+import os
+
+def sanitize_name(name):
+ name = name.replace("../","_")
+ name = name.replace("..\\","_")
+ name = name.replace("\\","_")
+ name = name.replace("/","_")
+ return name
+
+def get_nonexistant_file(path):
+ org_path = path
+ num = 0
+ while True:
+ if not os.path.exists(path):
+ return path
+ num += 1
+ path = "%s.%d" % (org_path,num)
+
+_BASE36_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'
+# Adapted from numpy's base_repr
+def to_base_36(num: int) -> str:
+ assert(num >= 0)
+
+ BASE = len(_BASE36_CHARS)
+ res = []
+ while num > 0:
+ num, v = divmod(num, BASE)
+ res.append(_BASE36_CHARS[v])
+ return ''.join(reversed(res or '0'))
diff --git a/cli/setup.py b/cli/setup.py
new file mode 100644
index 0000000..b5cfdb7
--- /dev/null
+++ b/cli/setup.py
@@ -0,0 +1,33 @@
+from setuptools import setup
+from pathlib import Path
+
+about = {}
+this_dir = Path(__file__).parent.resolve()
+with open(this_dir / "secsend" / "__version__.py", "r") as f:
+ exec(f.read(), about)
+
+setup(name='secsend',
+ version=about['__version__'],
+ description='secsend client library',
+ url=about['__url__'],
+ author=about['__author__'],
+ author_email=about['__author_email__'],
+ license=about['__license__'],
+ packages=['secsend'],
+ scripts=[
+ 'bin/secupload',
+ 'bin/secdownload',
+ 'bin/secadmin'
+ ],
+ install_requires=[
+ 'requests>=2.28,<3',
+ 'python-magic>=0.4,<1',
+ 'cryptography==37.*',
+ 'progressbar2==4.*'
+ ],
+ extras_require={
+ 'dev': [
+ 'requests-mock==1.9.*',
+ ],
+ }
+)
diff --git a/cli/tests/test_stream.py b/cli/tests/test_stream.py
new file mode 100644
index 0000000..879bba1
--- /dev/null
+++ b/cli/tests/test_stream.py
@@ -0,0 +1,165 @@
+import unittest
+import tempfile
+import io
+import os
+import random
+import string
+
+import requests_mock
+
+from secsend.client import DownloadURL, RootID
+from secsend.stream import stream_transform, UploadCtx, DownloadCtx
+from secsend.metadata import FileMetadata, encryptMetadata
+from secsend.crypto import AESGCMChunks, SignKey
+
+class TransformerEncr:
+ TAG = b"TTAG"
+
+ @classmethod
+ def out_chunk_size(cls, in_chunk_size):
+ return in_chunk_size + len(cls.TAG)
+
+ def seek_chunk_idx(self, n): pass
+
+ def process(self, data):
+ return bytes(c^0x01 for c in data) + self.TAG
+
+class TransformerDecr:
+ @staticmethod
+ def out_chunk_size(in_chunk_size):
+ return in_chunk_size - len(TransformerEncr.TAG)
+
+ def seek_chunk_idx(self, n): pass
+
+ def process(self, data):
+ assert(data[-len(TransformerEncr.TAG):] == TransformerEncr.TAG)
+ return bytes(c^0x01 for c in data[:-len(TransformerEncr.TAG)])
+
+class TestStream(unittest.TestCase):
+ def _transform_data(self, data, chunk_size, transformer=TransformerEncr()):
+ in_stream = io.BytesIO()
+ in_stream.write(data)
+ in_stream.seek(0, 0)
+ out = io.BytesIO()
+ for d in stream_transform(in_stream, transformer, chunk_size):
+ out.write(d)
+ out.flush()
+ return out.getvalue()
+
+ def test_stream_transform(self):
+ ref_data = b"hello world!"
+ out_buf = self._transform_data(ref_data, len(ref_data))
+ self.assertEqual(out_buf, TransformerEncr().process(ref_data))
+
+ def test_stream_encrypt_seek(self):
+ ref_data = "".join(random.choice(string.ascii_lowercase) for _ in range(257)).encode("ascii")
+ chunk_size = 16
+ ref_out = self._transform_data(ref_data, chunk_size)
+
+ in_stream = io.BytesIO()
+ in_stream.write(ref_data)
+
+ for cut in (1,2,5,chunk_size-1,chunk_size,chunk_size+1,chunk_size+2,TransformerEncr.out_chunk_size(chunk_size)):
+ in_stream.seek(0, 0)
+
+ out = io.BytesIO()
+ d = next(stream_transform(in_stream, TransformerEncr(), chunk_size))
+ out.write(d[:cut])
+
+ in_stream.seek(0,0)
+ for d in stream_transform(in_stream, TransformerEncr(), chunk_size, out.tell()):
+ out.write(d)
+
+ out.flush()
+ self.assertEqual(out.getvalue(), ref_out)
+
+ def test_stream_decrypt(self):
+ ref_data = "".join(random.choice(string.ascii_lowercase) for _ in range(257)).encode("ascii")
+ chunk_size = 16
+ encr_data = self._transform_data(ref_data, chunk_size)
+ decr_data = self._transform_data(encr_data, chunk_size+len(TransformerEncr.TAG), TransformerDecr())
+ self.assertEqual(decr_data, ref_data)
+
+ def test_stream_decrypt_seek(self):
+ ref_data = "".join(random.choice(string.ascii_lowercase) for _ in range(257)).encode("ascii")
+ chunk_size = 16
+ encr_data = self._transform_data(ref_data, chunk_size)
+
+ in_stream = io.BytesIO()
+ in_stream.write(encr_data)
+
+ in_chunk_size = chunk_size+len(TransformerEncr.TAG)
+ for cut in (1,2,5,in_chunk_size-1,in_chunk_size,in_chunk_size+1,in_chunk_size+2,chunk_size,chunk_size-1,chunk_size+1):
+ in_stream.seek(0, 0)
+
+ out = io.BytesIO()
+ d = next(stream_transform(in_stream, TransformerDecr(), in_chunk_size))
+ out.write(d[:cut])
+
+ in_stream.seek(0,0)
+ for d in stream_transform(in_stream, TransformerDecr(), in_chunk_size, out.tell()):
+ out.write(d)
+
+ out.flush()
+ self.assertEqual(out.getvalue(), ref_data)
+
+ def mock_config(self, session_mock):
+ session_mock.get("http://secsend.test/v1/config", json={'timeout_s_valid': [0]})
+
+ def test_upload(self):
+ ref_data = "".join(random.choice(string.ascii_lowercase) for _ in range(257)).encode("ascii")
+ myid = RootID.generate()
+ iv = random.randbytes(AESGCMChunks.IV_LEN)
+ key = random.randbytes(16)
+ metadata = FileMetadata(name="toto", mime_type="application/octet-stream", iv=iv, chunk_size=10, key_sign=SignKey(key,iv), timeout_s=0)
+ with tempfile.NamedTemporaryFile(prefix="secsend-test") as f:
+ f.write(ref_data)
+ f.flush()
+
+ ctx = UploadCtx.from_source_file(f.name)
+ with requests_mock.Mocker(session=ctx.session) as session_mock:
+ session_mock.post("http://secsend.test/v1/upload/new", json={'root_id': str(myid)})
+ self.mock_config(session_mock)
+
+ id_ = ctx.upload_new("http://secsend.test")
+ self.assertEqual(str(id_), str(myid))
+
+ session_mock.post(ctx.client._get_url("upload/push/%s" % id_), json={})
+ ctx.upload_push()
+
+ session_mock.post(ctx.client._get_url("upload/finish/%s" % id_), json={})
+ ctx.upload_finish()
+
+ ctx = UploadCtx.from_source_file(f.name)
+ with requests_mock.Mocker(session=ctx.session) as session_mock:
+ encrMetadata = encryptMetadata(metadata, AESGCMChunks(iv, key, encrypt=True))
+ session_mock.get("http://secsend.test/v1/metadata/%s" % myid.file_id(), json={'metadata': encrMetadata.jsonable(), 'size': 1})
+ ctx.upload_resume(DownloadURL.from_url("http://secsend.test/v1/download/%s#%s" % (myid, DownloadURL.key_to_txt(key))))
+
+ session_mock.post(ctx.client._get_url("upload/push/%s" % myid), json={})
+ ctx.upload_push()
+
+ session_mock.post(ctx.client._get_url("upload/finish/%s" % myid), json={})
+ ctx.upload_finish()
+
+ def test_download(self):
+ ref_data = "".join(random.choice(string.ascii_lowercase) for _ in range(257)).encode("ascii")
+ key = random.randbytes(16)
+ iv = random.randbytes(AESGCMChunks.IV_LEN)
+ encrypt = AESGCMChunks(iv, key, encrypt=True)
+ chunk_size = 17
+ encr_data = self._transform_data(ref_data, chunk_size, encrypt)
+
+ myid = "MYID"
+ ctx = DownloadCtx("http://secsend.test", myid, key)
+ metadata = FileMetadata(name="toto", mime_type="application/octet-stream", iv=iv, chunk_size=chunk_size, key_sign=SignKey(key, iv), timeout_s=0)
+ with requests_mock.Mocker(session=ctx.client.session) as session_mock:
+ encrMetadata = encryptMetadata(metadata, AESGCMChunks(iv, key, encrypt=False))
+ session_mock.get(ctx.client._get_url("metadata/%s" % myid), json={'metadata': encrMetadata.jsonable(), 'size': len(encr_data)})
+ self.assertEqual(ctx.get_metadata(), metadata)
+
+ session_mock.get(ctx.client._get_url("download/%s" % myid), body=io.BytesIO(encr_data))
+ out = io.BytesIO()
+ for d in ctx.download():
+ out.write(d)
+ self.assertEqual(out.getvalue(), ref_data)
diff --git a/docker.env.example b/docker.env.example
new file mode 100644
index 0000000..5fe68f6
--- /dev/null
+++ b/docker.env.example
@@ -0,0 +1,9 @@
+# Port secsend will listen to (within the container). This port can then be
+# exposed on the host.
+SECSEND_LISTEN_PORT=80
+
+# Maximum file size in bytes. 0 seconds means no limit.
+SECSEND_FILESIZE_LIMIT=1073741824
+
+# Valid time limits, as a comma-separated list of seconds. 0 means no limit.
+SECSEND_TIMEOUT_S_VALID=0
diff --git a/webapp/.eslintignore b/webapp/.eslintignore
new file mode 100644
index 0000000..0ad3d80
--- /dev/null
+++ b/webapp/.eslintignore
@@ -0,0 +1,9 @@
+# don't ever lint node_modules
+node_modules
+# don't lint build output (make sure it's set to your correct build folder name)
+dist_web
+secsend_webapp
+
+.eslintrc.js
+webpack.config.js
+jest.config.js
diff --git a/webapp/.eslintrc.js b/webapp/.eslintrc.js
new file mode 100644
index 0000000..6970a7b
--- /dev/null
+++ b/webapp/.eslintrc.js
@@ -0,0 +1,39 @@
+module.exports = {
+ root: true,
+ parser: '@typescript-eslint/parser',
+ plugins: [
+ '@typescript-eslint',
+ ],
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ ],
+ rules: {
+ "no-constant-condition": ["error", { "checkLoops": false }]
+ },
+ "overrides": [
+ {
+ "files": ["**/*.tsx", "**/*.ts"],
+ "extends": "plugin:@typescript-eslint/recommended",
+ "rules": {
+ "semi": [2, "always"],
+ "@typescript-eslint/ban-ts-ignore": 0,
+ "@typescript-eslint/ban-types": 1,
+ "@typescript-eslint/ban-ts-comment": 0,
+ "@typescript-eslint/no-var-requires": 0,
+ "@typescript-eslint/prefer-interface": 0,
+ "@typescript-eslint/explicit-function-return-type": 0,
+ "@typescript-eslint/explicit-module-boundary-types": 0,
+ "@typescript-eslint/no-empty-function": 0,
+ "@typescript-eslint/no-explicit-any": 0,
+ "@typescript-eslint/indent": [
+ 2,
+ 2,
+ {
+ "SwitchCase": 0
+ }
+ ],
+ }
+ }
+ ]
+};
diff --git a/webapp/README.md b/webapp/README.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/webapp/README.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/webapp/dl.html b/webapp/dl.html
new file mode 100644
index 0000000..a0ffc80
--- /dev/null
+++ b/webapp/dl.html
@@ -0,0 +1,17 @@
+
+
+
+
+ secsend - downloading...
+
+
+