Compare commits

...

33 Commits

Author SHA1 Message Date
472b17caca
chore: run black on migrations file 2023-07-18 13:28:52 -04:00
7f5021c568
/files: better malformed UUID handling 2023-07-18 13:26:48 -04:00
29f854f3a5
deleted migrations
i can't figure out how to fix this db mess so
2023-07-15 22:37:36 -04:00
996de46127
/users/<user>: fix inability to change password via PATCH 2023-07-15 22:26:22 -04:00
2ce47e55c0
test/test_userinfo.py: add missing password change test code
how did i not see this
2023-07-15 20:25:08 -04:00
89166ce708
TODO.txt: added tasks 2023-07-12 21:03:26 -04:00
1b145791e5
/users/password: return 403 on auth failure 2023-07-05 21:22:14 -04:00
e16d842880
sachet/server/files/views.py: fix bug when invalid UUID is passed in 2023-06-25 20:41:43 -04:00
f091f8a1e2
/whoami: implemented
tests and docs added too
2023-06-15 12:46:26 -04:00
38ab542695
contrib/testcmds.fish: use base64 instead of xxd
more widely available command
2023-06-14 21:36:20 -04:00
b525f09487
anon users are now allowed in admin endpoints 2023-06-13 11:44:58 -04:00
404393859b
sachet/server/views_common.py: pagination now has a total pages field 2023-06-09 19:45:28 -04:00
25980be6a3
/files/<uuid>: expose create_date field 2023-06-09 16:50:43 -04:00
f0717f8164
paginated APIs use parameters instead of a JSON body
*apparently* it's bad practice to send JSON in a GET request so
2023-06-09 13:27:05 -04:00
6b9df0a8d3
TODO.txt: uploaded 2023-05-31 20:33:09 -04:00
c96f27f0ec
docs/index.rst: added linkies 2023-05-28 13:20:41 -04:00
409e71b0a0
README.md: updated
since most things are in the docs now, link to it instead
2023-05-28 13:16:37 -04:00
65cae632b7
docs: make things more compatible with readthedocs
- added readthedocs.yaml
- write down steps to compile docs locally
- separated requirements for Sphinx and for Sachet
2023-05-28 13:04:21 -04:00
b714129baa
LICENSE: added 2023-05-28 11:23:32 -04:00
bf8fe8d1c5
docs/cli.rst: added 2023-05-28 11:07:38 -04:00
32dcf0c4a4
docs: moved index contents to "getting started" 2023-05-28 10:56:51 -04:00
a064bc9802
sachet/server/models.py: no more url_for errors in CLI
url_for is now called when requested and not at user init. this means
that creating the user via the CLI doesn't complain about not being in
an active request anymore
2023-05-28 10:48:28 -04:00
3599e80e77
docs/authentication.rst: added docs for password change 2023-05-22 21:01:21 -04:00
ca0c6512fe
/users/password: implemented endpoint
also added uuid to JTIs in JWTs to prevent issues in tests
2023-05-22 20:52:46 -04:00
0bd36b9a95
tests/test_auth.py: remove checks on forbidden names 2023-05-22 19:56:35 -04:00
2d56823b17
sachet/server/users/manage.py: remove checks on forbidden names 2023-05-22 12:23:58 -04:00
3d93e489c4
docs/files.rst: added note about null ownership 2023-05-22 12:16:45 -04:00
14734446ba
/files/<uuid>: fix ownership transfers 2023-05-21 22:18:45 -04:00
bc2c14e52f
/files/<uuid>: fix shares permission issue
users can no longer change the metadata on shares they do not own
2023-05-21 22:01:02 -04:00
88bd79c228
docs/user.rst: make mention of url field in POST 2023-05-21 21:55:31 -04:00
f6e1d1c385
/users/<username>: added DELETE method 2023-05-21 21:51:52 -04:00
85b83ace12
docs/conf.py: reformatted 2023-05-21 21:49:47 -04:00
076dc758df
/users: fix wrong URL returned when POSTing 2023-05-21 17:28:51 -04:00
36 changed files with 1841 additions and 337 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
venv
.venv
.docvenv
__pycache__/
.env
config.yml

13
.readthedocs.yaml Normal file
View File

@ -0,0 +1,13 @@
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.11"
sphinx:
configuration: docs/conf.py
python:
install:
- requirements: docs/requirements.txt

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -1,64 +1,15 @@
# sachet
sachet is a small file share server.
Sachet is a small file share server.
## development
## Installation & Usage
To start sachet in development mode:
See [the docs](https://sachet-server.readthedocs.io/en/latest/) for information about how to set up Sachet, and how to use its API.
Clone the repo.
## Features
```
git clone https://github.com/dogeystamp/sachet
cd sachet
```
Create a venv with required dependencies:
```
python -m venv venv
source venv/bin/activate
python -m pip3 install -r requirements.txt
```
Create a configuration file (and set the secret key!)
```
cp config.yml.example config.yml
vim config.yml
```
Start Flask in development mode:
```
flask --debug --app sachet.server run
```
### tests
Tests are available with the following command:
```
pytest --cov --cov-report term-missing
```
### linting
Please use the linter before submitting code.
```
black .
```
## database maintenance
To clean up the database (remove stale entries):
```
flask --app sachet.server cleanup
```
Otherwise, to upgrade the database after a schema change:
```
flask --app sachet.server db upgrade
```
- JWT-based [authentication](https://sachet-server.readthedocs.io/en/latest/authentication.html)
- Granular user [permissions](https://sachet-server.readthedocs.io/en/latest/permissions.html) (read, create, modify, delete, admin)
- [Anonymous access](https://sachet-server.readthedocs.io/en/latest/admin.html#anonymous-permissions) to shares can be configured
- REST API for interacting with clients
- Chunked uploads/downloads for reliability

264
TODO.txt Normal file
View File

@ -0,0 +1,264 @@
[ ]: not started
[-]: in progress
[x]: done
[!]: wontfix
[?]: maybe
[x] authentication
[x] /users/login POST
[x] /users/logout POST
[x] /users/extend POST
[x] permissions
[x] change the db schema
[x] use a library for serializing classes
[x] permissions are:
- create file
- modify any file
- delete any file
- lock/unlock any file
no modification, no deletion
- list all files
- admin
doesn't imply other privileges directly,
but means you can set them to yourself
[x] /users/<user> GET
this endpoint will now contain permission data too
[x] /users/<user> PATCH
[x] /users/<user> PUT
we'll use these to set permissions
[x] make the "generic model api"
endpoints like /users/<id> and /admin/settings
that directly get/set models will inherit from this class
any authentication/permission logic belongs to the children class
[x] make schemas members of their parent models
[x] settings endpoint
[x] initialize settings to defaults
[x] tests
[x] /admin/settings GET
[x] /admin/settings PATCH
[x] /admin/settings PUT
settings will set the default non-authenticated user's permissions
[x] linter
[x] file management backend
we want to abstract away the filesystem
[x] write todos for this entry
read, write, list
but how are we going to implement metadata and stuff?
are we going to use UUIDs?
hashing?
[x] create the module sachet.storage
[x] class Storage
generic storage
- get handle to file
- list files
[x] class File
- delete file
- r/w file
- read metadata
- write metadata
- rename file
our "real" classes will inherit from the Storage interface
and they will replace all the methods (and the File class)
[x] class FileSystem
just hook up python's os filesystem to FileSystem
[x] rewrite tests
[x] class File
- create file and its associated metadata file
- delete file
- r/w file
- read metadata
- write metadata
- rename file
[x] file sharing
[x] Share model
[x] docstring
[x] auth_required: require certain permissions
metadata
[x] /files POST
client will post metadata as json
returns a 201 and set Location header to the created entry
depends on create file permission
[x] /files/<file> PUT
metadata update
[x] /files/<file> PATCH
metadata update
[x] /files/<file> GET
metadata get
[x] /files/<file> DELETE
deletes file
files
[x] /files/<file>/content POST
uploads file
[x] /files/<file>/content PUT
modifies file
[x] /files/<file>/content GET
downloads file, depends on read permission
[x] debugging
[x] testing
[x] metadata
[x] implement a filename for Share
[x] test filename
[x] test modification
[x] implement listing files
[x] split POST off ModelClass
one class will be for editing models themselves
other class, ModelListClass
POST creates a new Model
GET lists all models (paginated)
[x] implement ModelListClass GET (pagination!)
[x] implement anonymous permissions
[x] test adversarial conditions
[x] fix that one bug where sending a non-int page number causes 500
[x] write a test case to prevent it
[x] fix modification
supposedly users should only be able to modify their own pastes
locking
[x] add locked attribute to Share
[x] implement authorization to modify/delete
[x] implement changing lock/unlock state
setting a bool flag in /files/<file> PUT/PATCH/POST
has the be authorized, though
[!] access docstrings without starting up the webserver
https://stackoverflow.com/questions/18214612/how-to-access-app-config-in-a-blueprint
instead of importing app, import this
[x] database migrations
[x] database cleanup
move all the cleanup to a flask cmd
"delete where date < expiry"
[x] write in README about db cleanup and migrations
[x] implement chunked upload
[x] Upload model
does not have a REST API endpoint
[x] upload_id (primary key a.k.a. dzuuid)
[x] backref to Share
[x] list of Chunks
[x] datetime
[x] Chunk model
does not have a REST API endpoint
[x] id (autoincrement)
[x] index
[x] backref to Upload
[x] datetime
we will store chunks as individual files: share uuid + suffix
suffix is
- upload uuid (prevents race condition for two concurrent uploads)
- chunk index
[x] write tests for chunked upload
[?] write more rigorous tests
poke at the chunks themselves
what happens when you send chunks in a race condition,
at the wrong time, with the wrong permissions, etc?
[x] investigate why share_id is set to none
[x] clear out chunk files after they're used
[x] clear out files after they are deleted
[x] implement chunked download
[x] implement File.size
[x] write tests
[x] write test for Range
[x] implement HTTP 416
[x] write appropriate tests
[x] write periodic cleanup for crusty stale uploads and chunks
so right now the issue is that the chunk on_delete event isn't triggering
and we're leaving random files on the disk
[x] add /users API
[x] fix url for users (returned when posting)
[x] create test case
[x] add /users DELETE
[x] added test case
[x] add a note for this in the docs
[x] password change endpoint
[x] tests
[x] docs
[x] investigate "FOREIGN KEY constraint failed"
[x] investigate what happens when you change ownership of a share to a
non-existent user
add a note maybe to the docs
[x] add note about ownership transfers
[x] add note that setting owner to null allows anon users to own it
[x] make sure that reserved names like `login`, `renew` are actually necessary
right now we're only implementing them on the CLI interface
maybe it doesn't matter because we're using POST only for these
and users don't have POST
[x] if they're not necessary we'll tear out the safeguards
[x] also add a warning to related endpoints to be careful
[x] fix bug where users can modify other users' shares' metadata
[x] proper documentation
[x] use a linter on docstrings
[x] Authentication
[x] Paginated APIs
[x] Permissions
[x] User API
[x] url_for docs
[x] Admin API
[x] anon perms
[x] Files API
[x] metadata API
[x] list API
[x] url_for docs
[x] content API
[x] chunked upload protocol
[x] POST
[x] PUT
[x] GET
[x] lock API
(reference this in permissions)
(reference this in the files schema)
[x] cli
[x] getting started (dev)
[x] implement /whoami endpoint
[x] tests
[x] docs
[ ] expose filesize in share info
[ ] investigate cleanup being in the user subcmd
[ ] investigate cleanup cmd triggering foreign key failure
[ ] if you create a new user without a required field it gives 500
[ ] if you create a new user with the same name as an existing one it gives 500
[x] if you try to read a share with an invalid uuid it gives 500
[ ] investigate if you can interfere with an upload by setting the same id
[ ] prod deployment config WSGI
[ ] write info about this in docs

View File

@ -32,7 +32,7 @@ function sachet_upload -d "uploads a file"
set URL (http --session=$_flag_session post $BASENAME/files file_name=$FNAME | tee /dev/tty | jq -r .url)
http --session=$_flag_session -f post $BASENAME/$URL/content \
upload@$argv \
dzuuid=(cat /dev/urandom | xxd -ps | head -c 32) \
dzuuid=(cat /dev/urandom | base64 | head -c 32) \
dzchunkindex=0 \
dztotalchunks=1
end
@ -48,7 +48,7 @@ function sachet_list -d "lists files on a given page"
if not set -q _flag_per_page
set _flag_per_page 5
end
http --session=$_flag_session get localhost:5000/files per_page=$_flag_per_page page=$argv[1]
http --session=$_flag_session get localhost:5000/files per_page==$_flag_per_page page==$argv[1]
end
function sachet_download -d "downloads a given file id"

View File

@ -23,6 +23,8 @@ Currently, server settings are represented by the following object:
"default_permissions": ["PERMISSION1", "PERMISSION2"]
}
.. _admin_anon_perms:
Anonymous permissions
^^^^^^^^^^^^^^^^^^^^^

View File

@ -28,6 +28,12 @@ The server will respond like this:
"username": "user"
}
.. warning::
Ensure that you are indeed using ``POST``.
Otherwise, you are querying the user with the name ``login``.
This will result in a "not authorized" error.
Save the token in ``auth_token``.
.. _authentication_usage:
@ -69,7 +75,7 @@ You can now use the new token in ``auth_token`` for future authentication.
This does not revoke your old token.
See :ref:`authentication_log_out` for information on revoking tokens.
.. note::
.. warning::
Remember to use the ``POST`` HTTP method and not ``GET``.
If you use ``GET`` by accident, the server will assume you're trying to read the information of a user called 'extend'.
This will result in a "not authorized" error.
@ -87,3 +93,34 @@ Use the following request body:
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODUwNTk3NjIsImlhdCI6MTY4NDQ1NDk2Miwic3ViIjoidXNlciIsImp0aSI6InJlbmV3In0.ZITIK8L5FzLtm-ASwIf6TkTb69z4bsZ8FF0mWee4YI4"
}
.. warning::
Ensure that you are indeed using ``POST``.
Otherwise, you are querying the user with the name ``logout``.
This will result in a "not authorized" error.
.. _authentication_password_change:
Password change
---------------
.. note::
Administrators can change a user's password via the ``PATCH/PUT /users/<username>`` endpoint.
See :ref:`user_info_api`.
A user can change their own password via the password change API::
POST /users/password
Use the following request body:
.. code-block:: json
{
"old": "old_password",
"new": "new_password"
}
Send the user's current password in ``old``, and Sachet will change it to the password in ``new``.
If the password is wrong, Sachet will return a ``403``.

41
docs/cli.rst Normal file
View File

@ -0,0 +1,41 @@
Server CLI
==========
The Sachet server has a basic CLI interface for management.
It can be accessed via the following command::
flask --debug --app sachet.server
.. note::
The ``--debug`` flag tells Sachet we are in development mode.
In production, remove it.
Any command or subcommand has information on how to use it via ``<cmd> --help``.
User
----
To create a user::
flask --debug --app sachet.server user create --username jeff --password password123
To create an administrator user::
flask --debug --app sachet.server user create --username admin --admin yes --password password123
.. warning::
Setting the password via the command-line is not safe.
In a real environment, you should reset this password immediately (see :ref:`authentication_password_change`.)
To delete a user::
flask --debug --app sachet.server user delete jeff
Database
--------
The database is managed via Flask-Migrate.
See their `documentation <https://flask-migrate.readthedocs.io/en/latest/>`_ for more information.

View File

@ -6,22 +6,21 @@
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'sachet-server'
copyright = '2023, dogeystamp'
author = 'dogeystamp'
project = "sachet-server"
copyright = "2023, dogeystamp"
author = "dogeystamp"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ["sphinx_rtd_theme"]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]

View File

@ -28,6 +28,7 @@ File Schema
In JSON, a file share has the following properties::
{
"create_date": "2023-05-20T23:05:31.546561",
"file_name": "file.txt",
"initialized": true,
"locked": false,
@ -43,6 +44,10 @@ In JSON, a file share has the following properties::
- Type
- Limits
- Description
* - ``create_date``
- Date
- Time at which this share was created.
- Read-only
* - ``file_name``
- String
-
@ -64,6 +69,11 @@ In JSON, a file share has the following properties::
- Read-only
- UUID that uniquely identifies this share.
.. note::
Share ownership can be changed by changing ``owner_name``.
Do note that setting it to ``null`` is equivalent to :ref:`unauthenticated users<admin_anon_perms>` owning the share.
.. _files_metadata_api:
Metadata API

113
docs/getting_started.rst Normal file
View File

@ -0,0 +1,113 @@
.. _getting_started:
Getting started
===============
.. note::
Currently, Sachet does not have the configs to run under WSGI for production yet.
This page only explains how to get Sachet up and running for development purposes.
Installation
------------
Clone the repo::
git clone https://github.com/dogeystamp/sachet-server
cd sachet-server
Create a venv with required dependencies::
python -m venv venv
source venv/bin/activate
python -m pip install -r requirements.txt
Create a configuration file::
cp config.yml.example config.yml
Remember to set up a secret key in ``config.yml``:
.. code-block:: yaml
SECRET_KEY: "41DJjqp6+Ztk9krJkwbZem1+JijowDU6ifkgdntF2FK3ygi5HM"
.. note::
You can generate a secret key using the following command on Linux::
cat /dev/urandom | base64 | head -c 50
.. warning::
Keep this secret key safe!
If you leak this key, a malicious attacker could authenticate as any user on your server.
Initialize the database::
flask --debug --app sachet.server db upgrade
Set up an administrator user::
flask --debug --app sachet.server user create --username admin --admin yes --password password123
.. warning::
Setting the password via the command-line is not safe.
In a real environment, you should reset this password immediately (see :ref:`authentication_password_change`.)
You can now start Sachet in development mode::
flask --debug --app sachet.server run
Development
-----------
.. note::
This section also requires being in the virtual environment::
source venv/bin/activate
Testing
^^^^^^^
You can run tests using pytest::
pytest --cov --cov-report term-missing
Linting
^^^^^^^
Please use the linter before submitting code::
black .
Database maintenance
--------------------
To clean up the database (remove stale entries)::
flask --app sachet.server cleanup
Otherwise, to upgrade the database after a schema change::
flask --app sachet.server db upgrade
Documentation
-------------
This documentation is built using Sphinx with the following steps.
First, create a new virtual environment for documentation-related dependencies::
python -m venv .docvenv
source .docvenv/bin/activate
python -m pip install -r docs/requirements.txt
Then, build docs::
make -C docs html
You can now view the documentation with a web browser::
xdg-open docs/_build/html/index.html

View File

@ -9,65 +9,20 @@ Welcome to Sachet's documentation!
.. toctree::
:maxdepth: 2
:caption: Contents:
getting_started
authentication
pagination
permissions
user
admin
files
cli
Sachet is a small file-sharing server.
Its source code can be found on `GitHub <https://github.com/dogeystamp/sachet-server>`_.
development
-----------
To start sachet in dev mode:
Clone the repo::
git clone https://github.com/dogeystamp/sachet
cd sachet
Create a venv with required dependencies::
python -m venv venv
source venv/bin/activate
python -m pip3 install -r requirements.txt
Create a configuration file (and set the secret key!)::
cp config.yml.example config.yml
vim config.yml
Start Flask in development mode::
flask --debug --app sachet.server run
tests
^^^^^
Run tests with pytest::
pytest --cov --cov-report term-missing
linting
^^^^^^^
Please use the linter before submitting code::
black .
database maintenance
--------------------
To clean up the database (remove stale entries)::
flask --app sachet.server cleanup
Otherwise, to upgrade the database after a schema change::
flask --app sachet.server db upgrade
To get started with Sachet, see :ref:`getting_started`.
Indices and tables
==================

View File

@ -7,16 +7,10 @@ Some APIs in Sachet might return lots of data.
Because this is often a lot to handle, Sachet will use pagination on some endpoints.
For example, let's say we want to list all shares on the server.
To do this, we'll run ``GET /files`` using the following request body:
.. code-block:: json
To do this, we'll run ``GET /files?page=1&per_page=3``.
{
"page": "1",
"per_page": "3"
}
As seen in the above example, paginated APIs on Sachet require the following parameters:
Paginated APIs on Sachet require the following parameters:
* ``page`` : the number of the page we want to query;
* ``per_page`` : the number of items per page we receive.
@ -41,6 +35,7 @@ For our example, the server might respond like this (fields removed for brevity)
}
],
"next": 2,
"pages": 3,
"prev": null
}
@ -52,3 +47,6 @@ which help us navigate to other pages.
Since we're on the first page, there is no previous page, which is why ``prev`` is empty.
If we wished to go to the next page, we'd make the same request with the new page number.
The ``pages`` field is the total number of pages there is in this query.
That is, page 3 is the last page in this example.

View File

@ -4,6 +4,8 @@ Permissions
Sachet offers a selection of permissions that can be assigned to users,
which manage their access to certain endpoints.
.. _permissions_serialization:
Serialization
-------------
In Sachet's JSON API, permissions are serialized as an array of string codes.
@ -59,3 +61,19 @@ The following is a table of permissions Sachet offers, and what they do:
* - Administration
- ``ADMIN``
- Allows creating users and managing their permissions.
Whoami API
----------
Information about the current user and their permissions can be obtained through this API.
``GET /whoami`` will return a result like this:
.. code-block:: json
{
"username": "user",
"permissions": ["READ", "CREATE"]
}
``permissions`` will be serialized as in :ref:`permissions_serialization`.
If the user is not currently signed in, ``username`` will be ``null``.

20
docs/requirements.txt Normal file
View File

@ -0,0 +1,20 @@
alabaster==0.7.13
Babel==2.12.1
certifi==2023.5.7
charset-normalizer==3.1.0
docutils==0.18.1
idna==3.4
imagesize==1.4.1
Pygments==2.15.1
requests==2.30.0
snowballstemmer==2.2.0
Sphinx==6.2.1
sphinx-rtd-theme==1.2.0
sphinxcontrib-applehelp==1.0.4
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.1
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
urllib3==2.0.2

View File

@ -54,6 +54,7 @@ Sachet implements the following endpoints for this API::
GET /users/<username>
PATCH /users/<username>
PUT /users/<username>
DELETE /users/<username>
GET
^^^
@ -117,6 +118,13 @@ For example:
Only :ref:`administrators<permissions_table>` can request this method.
DELETE
^^^^^^
Requesting ``DELETE /users/<username>`` deletes the specified user.
Only :ref:`administrators<permissions_table>` can request this method.
.. _user_list_api:
List API
@ -139,3 +147,15 @@ POST
``POST /users`` creates a new user.
The request body must conform to the :ref:`User schema<user_schema>`.
The server will return a ``201 Created`` code with a similar body to this:
.. code-block:: json
{
"status": "success",
"url": "/users/user"
}
The ``url`` field is the URL to the new user.
It can be used in further requests to manage the user's information, or delete it.

View File

@ -1,68 +0,0 @@
"""empty message
Revision ID: 4cd7cdbc2d1f
Revises:
Create Date: 2023-04-30 17:42:00.329050
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
# revision identifiers, used by Alembic.
revision = "4cd7cdbc2d1f"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"blacklist_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("token", sa.String(length=500), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token"),
)
op.create_table(
"server_settings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("default_permissions_number", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"users",
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("password", sa.String(length=255), nullable=False),
sa.Column("register_date", sa.DateTime(), nullable=False),
sa.Column("permissions_number", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("username"),
sa.UniqueConstraint("username"),
)
op.create_table(
"shares",
sa.Column("share_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False),
sa.Column("owner_name", sa.String(), nullable=True),
sa.Column("initialized", sa.Boolean(), nullable=False),
sa.Column("locked", sa.Boolean(), nullable=False),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("file_name", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["owner_name"],
["users.username"],
),
sa.PrimaryKeyConstraint("share_id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("shares")
op.drop_table("users")
op.drop_table("server_settings")
op.drop_table("blacklist_tokens")
# ### end Alembic commands ###

View File

@ -1,72 +0,0 @@
"""empty message
Revision ID: 70ab3c81827a
Revises: 4cd7cdbc2d1f
Create Date: 2023-05-07 21:52:08.250195
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
# revision identifiers, used by Alembic.
revision = "70ab3c81827a"
down_revision = "4cd7cdbc2d1f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"uploads",
sa.Column("upload_id", sa.String(), nullable=False),
sa.Column("share_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("total_chunks", sa.Integer(), nullable=False),
sa.Column("recv_chunks", sa.Integer(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["share_id"],
["shares.share_id"],
),
sa.PrimaryKeyConstraint("upload_id"),
)
op.create_table(
"chunks",
sa.Column("chunk_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("index", sa.Integer(), nullable=False),
sa.Column("upload_id", sa.String(), nullable=True),
sa.Column("filename", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["upload_id"],
["uploads.upload_id"],
),
sa.PrimaryKeyConstraint("chunk_id"),
)
with op.batch_alter_table("shares", schema=None) as batch_op:
batch_op.alter_column(
"share_id",
existing_type=sa.NUMERIC(precision=16),
type_=sqlalchemy_utils.types.uuid.UUIDType(),
existing_nullable=False,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("shares", schema=None) as batch_op:
batch_op.alter_column(
"share_id",
existing_type=sqlalchemy_utils.types.uuid.UUIDType(),
type_=sa.NUMERIC(precision=16),
existing_nullable=False,
)
op.drop_table("chunks")
op.drop_table("uploads")
# ### end Alembic commands ###

View File

@ -0,0 +1,97 @@
"""empty message
Revision ID: e8d2a7570f70
Revises:
Create Date: 2023-07-15 22:34:31.075577
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
# revision identifiers, used by Alembic.
revision = "e8d2a7570f70"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"blacklist_tokens",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("token", sa.String(length=500), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_blacklist_tokens")),
sa.UniqueConstraint("token", name=op.f("uq_blacklist_tokens_token")),
)
op.create_table(
"server_settings",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("default_permissions_number", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_server_settings")),
)
op.create_table(
"users",
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("register_date", sa.DateTime(), nullable=False),
sa.Column("permissions_number", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("username", name=op.f("pk_users")),
sa.UniqueConstraint("username", name=op.f("uq_users_username")),
)
op.create_table(
"shares",
sa.Column("share_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False),
sa.Column("owner_name", sa.String(), nullable=True),
sa.Column("initialized", sa.Boolean(), nullable=False),
sa.Column("locked", sa.Boolean(), nullable=False),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("file_name", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["owner_name"], ["users.username"], name=op.f("fk_shares_owner_name_users")
),
sa.PrimaryKeyConstraint("share_id", name=op.f("pk_shares")),
)
op.create_table(
"uploads",
sa.Column("upload_id", sa.String(), nullable=False),
sa.Column("share_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("total_chunks", sa.Integer(), nullable=False),
sa.Column("recv_chunks", sa.Integer(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["share_id"], ["shares.share_id"], name=op.f("fk_uploads_share_id_shares")
),
sa.PrimaryKeyConstraint("upload_id", name=op.f("pk_uploads")),
)
op.create_table(
"chunks",
sa.Column("chunk_id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("create_date", sa.DateTime(), nullable=False),
sa.Column("index", sa.Integer(), nullable=False),
sa.Column("upload_id", sa.String(), nullable=True),
sa.Column("filename", sa.String(), nullable=False),
sa.ForeignKeyConstraint(
["upload_id"],
["uploads.upload_id"],
name=op.f("fk_chunks_upload_id_uploads"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("chunk_id", name=op.f("pk_chunks")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("chunks")
op.drop_table("uploads")
op.drop_table("shares")
op.drop_table("users")
op.drop_table("server_settings")
op.drop_table("blacklist_tokens")
# ### end Alembic commands ###

View File

@ -1,15 +1,10 @@
alabaster==0.7.13
alembic==1.10.4
attrs==22.2.0
Babel==2.12.1
bcrypt==4.0.1
bitmask @ git+https://github.com/dogeystamp/bitmask@e3726f069c24f1db6ecb2e3d3143c9c930c83fa5
black==23.3.0
certifi==2023.5.7
charset-normalizer==3.1.0
click==8.1.3
coverage==7.2.1
docutils==0.18.1
exceptiongroup==1.1.0
Flask==2.2.3
Flask-Bcrypt==1.0.1
@ -20,8 +15,6 @@ Flask-Script==2.0.6
Flask-SQLAlchemy==3.0.3
Flask-Testing==0.8.1
greenlet==2.0.2
idna==3.4
imagesize==1.4.1
iniconfig==2.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
@ -34,27 +27,14 @@ packaging==23.0
pathspec==0.11.1
platformdirs==3.2.0
pluggy==1.0.0
Pygments==2.15.1
PyJWT==2.6.0
pytest==7.2.2
pytest-cov==4.0.0
pytest-env==0.8.1
PyYAML==6.0
requests==2.30.0
six==1.16.0
snowballstemmer==2.2.0
Sphinx==6.2.1
sphinx-rtd-theme==1.2.0
sphinxcontrib-applehelp==1.0.4
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.1
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
SQLAlchemy==2.0.5.post1
SQLAlchemy-Utils==0.40.0
tomli==2.0.1
typing_extensions==4.5.0
urllib3==2.0.2
Werkzeug==2.2.3

View File

@ -6,7 +6,7 @@ from flask_marshmallow import Marshmallow
from flask_bcrypt import Bcrypt
from flask_migrate import Migrate
from .config import DevelopmentConfig, ProductionConfig, TestingConfig, overlay_config
from sqlalchemy import event
from sqlalchemy import event, MetaData
from sqlalchemy.engine import Engine
from sqlite3 import Connection as SQLite3Connection
@ -25,8 +25,18 @@ with app.app_context():
overlay_config(ProductionConfig)
bcrypt = Bcrypt(app)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
# https://stackoverflow.com/questions/62640576/
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
metadata = MetaData(naming_convention=convention)
db = SQLAlchemy(app, metadata=metadata)
migrate = Migrate(app, db, render_as_batch=True)
ma = Marshmallow()
_storage_method = app.config["SACHET_STORAGE"]
@ -65,3 +75,7 @@ app.register_blueprint(admin_blueprint)
from sachet.server.files.views import files_blueprint
app.register_blueprint(files_blueprint)
from sachet.server.whoami.views import whoami_blueprint
app.register_blueprint(whoami_blueprint)

View File

@ -9,17 +9,17 @@ admin_blueprint = Blueprint("admin_blueprint", __name__)
class ServerSettingsAPI(ModelAPI):
@auth_required(required_permissions=(Permissions.ADMIN,))
@auth_required(required_permissions=(Permissions.ADMIN,), allow_anonymous=True)
def get(self, auth_user=None):
settings = get_settings()
return super().get(settings)
@auth_required(required_permissions=(Permissions.ADMIN,))
@auth_required(required_permissions=(Permissions.ADMIN,), allow_anonymous=True)
def patch(self, auth_user=None):
settings = get_settings()
return super().patch(settings)
@auth_required(required_permissions=(Permissions.ADMIN,))
@auth_required(required_permissions=(Permissions.ADMIN,), allow_anonymous=True)
def put(self, auth_user=None):
settings = get_settings()
return super().put(settings)

View File

@ -2,40 +2,107 @@ import uuid
import io
from flask import Blueprint, request, jsonify, send_file, make_response
from flask.views import MethodView
from sachet.server.models import Share, Permissions, Upload, Chunk
from sachet.server.models import Share, Permissions, Upload, Chunk, User
from sachet.server.views_common import ModelAPI, ModelListAPI, auth_required
from sachet.server import storage, db
files_blueprint = Blueprint("files_blueprint", __name__)
def filter_id(id: str):
try:
uuid.UUID(id)
return id
except ValueError:
return uuid.UUID(int=0)
class FilesMetadataAPI(ModelAPI):
@auth_required(required_permissions=(Permissions.READ,), allow_anonymous=True)
def get(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
return super().get(share)
@auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True)
def patch(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
resp = {
"status": "fail",
"message": "This resource does not exist.",
}
return jsonify(resp), 404
if auth_user != share.owner:
return (
jsonify(
{
"status": "fail",
"message": "Share must be modified by its owner.",
}
),
403,
)
owner_name = request.get_json().get("owner_name")
if owner_name is not None:
if User.query.filter_by(username=owner_name).first() is None:
return (
jsonify(
{
"status": "fail",
"message": f"Invalid value for `owner_name`: {owner_name}",
}
),
400,
)
if share.locked:
return jsonify({"status": "fail", "message": "This share is locked."}), 423
return super().patch(share)
@auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True)
def put(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
resp = {
"status": "fail",
"message": "This resource does not exist.",
}
return jsonify(resp), 404
if auth_user != share.owner:
return (
jsonify(
{
"status": "fail",
"message": "Share must be modified by its owner.",
}
),
403,
)
owner_name = request.get_json().get("owner_name")
if owner_name is not None:
if User.query.filter_by(username=owner_name).first() is None:
return (
jsonify(
{
"status": "fail",
"message": f"Invalid value for `owner_name`: {owner_name}",
}
),
400,
)
if share.locked:
return jsonify({"status": "fail", "message": "This share is locked."}), 423
return super().put(share)
@auth_required(required_permissions=(Permissions.DELETE,), allow_anonymous=True)
def delete(self, share_id, auth_user=None):
try:
uuid.UUID(share_id)
except ValueError:
return jsonify(dict(status="fail", message=f"Invalid ID: '{share_id}'."))
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
resp = {
"status": "fail",
"message": "This resource does not exist.",
}
return jsonify(resp), 404
if share.locked:
return jsonify({"status": "fail", "message": "This share is locked."}), 423
return super().delete(share)
@ -119,7 +186,7 @@ class FileContentAPI(MethodView):
@auth_required(required_permissions=(Permissions.CREATE,), allow_anonymous=True)
def post(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=uuid.UUID(share_id)).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
return (
@ -152,7 +219,7 @@ class FileContentAPI(MethodView):
@auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True)
def put(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
return (
jsonify({"status": "fail", "message": "This share does not exist."})
@ -189,7 +256,7 @@ class FileContentAPI(MethodView):
@auth_required(required_permissions=(Permissions.READ,), allow_anonymous=True)
def get(self, share_id, auth_user=None):
share = Share.query.filter_by(share_id=share_id).first()
share = Share.query.filter_by(share_id=filter_id(share_id)).first()
if not share:
return (
jsonify({"status": "fail", "message": "This share does not exist."})

View File

@ -73,11 +73,44 @@ class PermissionProperty:
db.session.commit()
class PasswordProperty:
"""Property to hash plaintext to a password.
The hash field will have the same name as this property, suffixed with "_hash".
For example::
class User(db.Model):
password = db.Column(db.String(255), nullable=False)
password = PasswordProperty()
Reading will return the hash, while writing hashes plaintext.
"""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return getattr(obj, self.name + "_hash")
def __set__(self, obj, value):
hash = User.gen_hash(value)
setattr(obj, self.name + "_hash", hash.decode())
class User(db.Model):
__tablename__ = "users"
username = db.Column(db.String(255), unique=True, nullable=False, primary_key=True)
password = db.Column(db.String(255), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
password = PasswordProperty()
@staticmethod
def gen_hash(value):
return bcrypt.generate_password_hash(
value, current_app.config.get("BCRYPT_LOG_ROUNDS")
)
register_date = db.Column(db.DateTime, nullable=False)
permissions_number = db.Column(db.BigInteger, nullable=False, default=0)
@ -87,13 +120,15 @@ class User(db.Model):
permissions.AllFlags = Permissions
self.permissions = permissions
self.password = bcrypt.generate_password_hash(
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
).decode()
self.password = password
self.username = username
self.url = url_for("users_blueprint.user_list_api", username=self.username)
self.register_date = datetime.datetime.now()
@property
def url(self):
"""URL linking to this resource."""
return url_for("users_blueprint.user_api", username=self.username)
def encode_token(self, jti=None):
"""Generates an authentication token"""
payload = {
@ -106,6 +141,7 @@ class User(db.Model):
payload, current_app.config.get("SECRET_KEY"), algorithm="HS256"
)
@staticmethod
def read_token(token):
"""Read a JWT and validate it.
@ -135,7 +171,7 @@ class User(db.Model):
username = ma.auto_field()
register_date = ma.auto_field(dump_only=True)
password = ma.auto_field(load_only=True, required=False)
password = fields.Str(load_only=True, required=False)
permissions = PermissionField()
return Schema()
@ -275,6 +311,7 @@ class Share(db.Model):
model = self
share_id = ma.auto_field(dump_only=True)
create_date = ma.auto_field(dump_only=True)
owner_name = ma.auto_field()
file_name = ma.auto_field()
initialized = ma.auto_field(dump_only=True)

View File

@ -3,12 +3,6 @@ from sachet.server.models import User
def create_user(permissions, username, password):
# to reduce confusion with API endpoints
forbidden = {"login", "logout", "extend"}
if username in forbidden:
raise KeyError(f"Username '{username}' is reserved and can not be used.")
user = User.query.filter_by(username=username).first()
if not user:
user = User(username=username, password=password, permissions=permissions)

View File

@ -8,6 +8,7 @@ from sachet.server.models import (
)
from sachet.server.views_common import ModelAPI, ModelListAPI, auth_required
from sachet.server import bcrypt, db
import uuid
users_blueprint = Blueprint("users_blueprint", __name__)
@ -21,7 +22,7 @@ class LoginAPI(MethodView):
return jsonify(resp), 401
if bcrypt.check_password_hash(user.password, post_data.get("password", "")):
token = user.encode_token()
token = user.encode_token(jti=f"login_{uuid.uuid4()}")
resp = {
"status": "success",
"message": "Logged in.",
@ -88,12 +89,58 @@ users_blueprint.add_url_rule(
)
class PasswordAPI(MethodView):
"""Endpoint to change passwords."""
@auth_required
def post(self, auth_user=None):
post_data = request.get_json()
old_psswd = post_data.get("old")
new_psswd = post_data.get("new")
if not old_psswd or not new_psswd:
return (
jsonify(
{
"status": "fail",
"message": "Specify the 'old' password and the 'new' password.",
}
),
400,
)
if not bcrypt.check_password_hash(auth_user.password, old_psswd):
return (
jsonify(
{
"status": "fail",
"message": "Invalid 'old' password.",
}
),
403,
)
else:
auth_user.password = new_psswd
db.session.commit()
return jsonify(
{
"status": "success",
"message": "Password changed.",
}
)
users_blueprint.add_url_rule(
"/users/password", view_func=PasswordAPI.as_view("password_api"), methods=["POST"]
)
class ExtendAPI(MethodView):
"""Endpoint to take a token and get a new one with a later expiry date."""
@auth_required
def post(self, auth_user=None):
token = auth_user.encode_token(jti="renew")
token = auth_user.encode_token(jti=f"renew{uuid.uuid4()}")
resp = {
"status": "success",
"message": "Renewed token.",
@ -109,7 +156,7 @@ users_blueprint.add_url_rule(
class UserAPI(ModelAPI):
"""User information API"""
"""User information API."""
@auth_required
def get(self, username, auth_user=None):
@ -136,21 +183,26 @@ class UserAPI(ModelAPI):
put_user = User.query.filter_by(username=username).first()
return super().put(put_user)
@auth_required(required_permissions=(Permissions.ADMIN,))
def delete(self, username, auth_user=None):
delete_user = User.query.filter_by(username=username).first()
return super().delete(delete_user)
users_blueprint.add_url_rule(
"/users/<username>",
view_func=UserAPI.as_view("user_api"),
methods=["GET", "PATCH", "PUT"],
methods=["GET", "PATCH", "PUT", "DELETE"],
)
class UserListAPI(ModelListAPI):
@auth_required(required_permissions=(Permissions.ADMIN,))
@auth_required(required_permissions=(Permissions.ADMIN,), allow_anonymous=True)
def post(self, auth_user=None):
data = request.get_json()
return super().post(User, data)
@auth_required(required_permissions=(Permissions.ADMIN,))
@auth_required(required_permissions=(Permissions.ADMIN,), allow_anonymous=True)
def get(self, auth_user=None):
return super().get(User)

View File

@ -249,7 +249,7 @@ class ModelListAPI(MethodView):
ModelClass
Model class to query.
JSON Parameters
URL Parameters
---------------
per_page : int
Amount of entries to return in one query.
@ -264,11 +264,12 @@ class ModelListAPI(MethodView):
Number of previous page (if this is not the first).
next : int or None
Number of next page (if this is not the last).
pages : int
Total number of pages.
"""
json_data = request.get_json()
try:
per_page = int(json_data.get("per_page", 15))
page = int(json_data.get("page", 1))
per_page = int(request.args.get("per_page", 15))
page = int(request.args.get("page", 1))
except ValueError as e:
return (
jsonify(
@ -288,5 +289,6 @@ class ModelListAPI(MethodView):
data=data,
prev=page_data.prev_num,
next=page_data.next_num,
pages=page_data.pages,
)
)

View File

@ -0,0 +1,27 @@
from flask import Blueprint, request, jsonify
from flask.views import MethodView
from sachet.server.models import User, ServerSettings, get_settings
from sachet.server.views_common import auth_required
whoami_blueprint = Blueprint("whoami_blueprint", __name__)
class WhoamiAPI(MethodView):
@auth_required(allow_anonymous=True)
def get(self, auth_user=None):
if auth_user:
data = auth_user.get_schema().dump(auth_user)
username = data.get("username")
perms = data.get("permissions")
else:
username = None
model = get_settings()
data = model.get_schema().dump(model)
perms = data.get("default_permissions")
return jsonify(dict(username=username, permissions=perms))
whoami_blueprint.add_url_rule(
"/whoami", view_func=WhoamiAPI.as_view("whoami_api"), methods=["GET"]
)

View File

@ -125,6 +125,16 @@ def users(client):
Permissions.READ,
),
),
no_admin_user=dict(
password="password",
permissions=Bitmask(
Permissions.CREATE,
Permissions.MODIFY,
Permissions.DELETE,
Permissions.LOCK,
Permissions.READ,
),
),
administrator=dict(password="4321", permissions=Bitmask(Permissions.ADMIN)),
)

View File

@ -4,13 +4,6 @@ from sachet.server import db
from sachet.server.users import manage
def test_reserved_users(client):
"""Test that the server prevents reserved endpoints from being registered as usernames."""
for user in ["login", "logout", "extend"]:
with pytest.raises(KeyError):
manage.create_user(False, user, "")
def test_unauth_perms(client):
"""Test endpoints to see if they allow unauthenticated users."""
resp = client.get("/users/jeff")
@ -144,6 +137,47 @@ def test_logout(client, tokens, validate_info, auth):
assert resp.status_code == 401
def test_password_change(client, tokens, users, auth):
"""Test changing passwords."""
# test that we're logged in
resp = client.get("/users/jeff", headers=auth("jeff"))
assert resp.status_code == 200
# change password
resp = client.post(
"/users/password",
json=dict(old=users["jeff"]["password"], new="new_password"),
headers=auth("jeff"),
)
assert resp.status_code == 200
# revoke old token
resp = client.post(
"/users/logout", json=dict(token=tokens["jeff"]), headers=auth("jeff")
)
assert resp.status_code == 200
# test that we're logged out
resp = client.get(
"/users/jeff", headers=auth("jeff"), json=dict(token=tokens["jeff"])
)
assert resp.status_code == 401
# sign in with new token
resp = client.post(
"/users/login", json=dict(username="jeff", password="new_password")
)
assert resp.status_code == 200
data = resp.get_json()
new_token = data.get("auth_token")
assert new_token
# test that we're logged in
resp = client.get("/users/jeff", headers=dict(Authorization=f"bearer {new_token}"))
assert resp.status_code == 200
def test_admin_revoke(client, tokens, validate_info, auth):
"""Test that an admin can revoke any token from other users."""

View File

@ -105,6 +105,42 @@ class TestSuite:
assert resp.data == new_data
assert "filename=new_bin.bin" in resp.headers["Content-Disposition"].split("; ")
def test_transfer(self, client, users, auth):
# create share
resp = client.post(
"/files", headers=auth("jeff"), json={"file_name": "content.bin"}
)
data = resp.get_json()
url = data.get("url")
# transfer ownership over to dave
resp = client.patch(url, headers=auth("jeff"), json={"owner_name": "dave"})
assert resp.status_code == 200
# ensure the transfer worked
resp = client.patch(
url, headers=auth("jeff"), json={"file_name": "jeff's file"}
)
assert resp.status_code == 403
resp = client.patch(
url, headers=auth("dave"), json={"file_name": "dave's file"}
)
assert resp.status_code == 200
# transfer ownership back to jeff
resp = client.patch(url, headers=auth("dave"), json={"owner_name": "jeff"})
assert resp.status_code == 200
# ensure the transfer worked
resp = client.patch(
url, headers=auth("dave"), json={"file_name": "dave's epic file"}
)
assert resp.status_code == 403
resp = client.patch(
url, headers=auth("jeff"), json={"file_name": "jeff's file"}
)
assert resp.status_code == 200
def test_invalid(self, client, users, auth, rand, upload):
"""Test invalid requests."""
@ -129,6 +165,44 @@ class TestSuite:
"/files/" + str(uuid.UUID(int=0)) + "/content", headers=auth("jeff")
)
assert resp.status_code == 404
resp = client.delete("/files/" + str(uuid.UUID(int=0)), headers=auth("jeff"))
assert resp.status_code == 404
resp = client.patch(
"/files/" + str(uuid.UUID(int=0)),
headers=auth("jeff"),
json=dict(filename="incredible-new-filename"),
)
assert resp.status_code == 404
resp = client.put(
"/files/" + str(uuid.UUID(int=0)),
headers=auth("jeff"),
json=dict(filename="incredible-new-filename", owner_name="jeff"),
)
assert resp.status_code == 404
# malformed
resp = client.get("/files/" + "123123123123", headers=auth("jeff"))
assert resp.status_code == 404
resp = client.get("/files/" + "123123123" + "/content", headers=auth("jeff"))
assert resp.status_code == 404
resp = client.post("/files/" + "123123123" + "/content", headers=auth("jeff"))
assert resp.status_code == 404
resp = client.put("/files/" + "123123123" + "/content", headers=auth("jeff"))
assert resp.status_code == 404
resp = client.delete("/files/" + "123123123", headers=auth("jeff"))
assert resp.status_code == 404
resp = client.patch(
"/files/" + "123123123",
headers=auth("jeff"),
json=dict(filename="incredible-new-filename"),
)
assert resp.status_code == 404
resp = client.put(
"/files/" + "123123123",
headers=auth("jeff"),
json=dict(filename="incredible-new-filename", owner_name="jeff"),
)
assert resp.status_code == 404
# no CREATE permission
resp = client.post("/files", headers=auth("no_create_user"))
@ -182,6 +256,22 @@ class TestSuite:
method=client.put,
)
assert resp.status_code == 403
resp = client.patch(
url, headers=auth("dave"), json=dict(file_name="epic_new_filename.bin")
)
assert resp.status_code == 403
resp = client.put(
url,
headers=auth("dave"),
json=dict(file_name="epic_new_filename.bin", owner_name="dave"),
)
assert resp.status_code == 403
# test assigning a file to a non-existent user
resp = client.patch(
url, headers=auth("jeff"), json=dict(owner_name="non_existent_user")
)
assert resp.status_code == 400
# test not allowing re-upload
resp = upload(

View File

@ -45,13 +45,17 @@ def test_files(client, users, auth):
per_page = 9
while page is not None:
resp = client.get(
"/files", headers=auth("jeff"), json=dict(page=page, per_page=per_page)
"/files",
headers=auth("jeff"),
query_string=dict(page=page, per_page=per_page),
)
assert resp.status_code == 200
data = resp.get_json().get("data")
assert len(data) == per_page or len(data) == share_count % per_page
assert resp.get_json().get("pages") == ceil(share_count / per_page)
for share in data:
share_id = share.get("share_id")
assert share_id in shares
@ -128,13 +132,15 @@ def test_users(client, users, auth):
resp = client.get(
"/users",
headers=auth("administrator"),
json=dict(page=page, per_page=per_page),
query_string=dict(page=page, per_page=per_page),
)
assert resp.status_code == 200
data = resp.get_json().get("data")
assert len(data) == per_page or len(data) == user_count % per_page
assert resp.get_json().get("pages") == ceil(user_count / per_page)
for user in data:
username = user.get("username")
assert username in total_users
@ -164,6 +170,6 @@ def test_invalid(client, auth):
"""Test invalid requests to pagination."""
resp = client.get(
"/files", headers=auth("jeff"), json=dict(page="one", per_page="two")
"/files", headers=auth("jeff"), query_string=dict(page="one", per_page="two")
)
assert resp.status_code == 400

68
tests/test_user.py Normal file
View File

@ -0,0 +1,68 @@
import pytest
def test_post(client, users, auth):
"""Test registering a user, then logging in to it."""
# register without adequate permissions
resp = client.post(
"/users",
headers=auth("no_admin_user"),
json={"username": "claire", "permissions": [], "password": "claire123"},
)
assert resp.status_code == 403
# properly register
resp = client.post(
"/users",
headers=auth("administrator"),
json={"username": "claire", "permissions": [], "password": "claire123"},
)
assert resp.status_code == 201
data = resp.get_json()
url = data.get("url")
assert url is not None
assert url == "/users/claire"
# try logging in now
resp = client.post(
"/users/login", json={"username": "claire", "password": "claire123"}
)
assert resp.status_code == 200
data = resp.get_json()
assert data.get("status") == "success"
assert data.get("username") == "claire"
token = data.get("auth_token")
assert token is not None and token != ""
def test_delete(client, users, auth):
"""Test registering a user, then deleting it."""
resp = client.post(
"/users",
headers=auth("administrator"),
json={"username": "claire", "permissions": [], "password": "claire123"},
)
assert resp.status_code == 201
# try logging in now
resp = client.post(
"/users/login", json={"username": "claire", "password": "claire123"}
)
assert resp.status_code == 200
data = resp.get_json()
token = data.get("auth_token")
# test if the token works
resp = client.get("/users/claire", headers={"Authorization": f"bearer {token}"})
assert resp.status_code == 200
# delete without permission
resp = client.delete("/users/claire", headers=auth("no_admin_user"))
assert resp.status_code == 403
# delete properly
resp = client.delete("/users/claire", headers=auth("administrator"))
assert resp.status_code == 200
# test if the token for a non-existent user works
resp = client.get("/users/claire", headers={"Authentication": f"bearer {token}"})
assert resp.status_code == 401

View File

@ -70,6 +70,25 @@ def test_patch(client, users, auth, validate_info):
assert resp.status_code == 200
validate_info("jeff", resp.get_json())
# test password change through patch
resp = client.patch(
"/users/jeff",
json=dict(password="123"),
headers=auth("administrator"),
)
assert resp.status_code == 200
# sign in with new token
resp = client.post("/users/login", json=dict(username="jeff", password="123"))
assert resp.status_code == 200
data = resp.get_json()
new_token = data.get("auth_token")
assert new_token
# test that we're logged in
resp = client.get("/users/jeff", headers=dict(Authorization=f"bearer {new_token}"))
assert resp.status_code == 200
def test_put(client, users, auth, validate_info):
"""Test replacing user information as an administrator."""
@ -102,3 +121,14 @@ def test_put(client, users, auth, validate_info):
resp = client.get("/users/jeff", headers=auth("jeff"))
assert resp.status_code == 200
validate_info("jeff", resp.get_json())
# sign in with new token
resp = client.post("/users/login", json=dict(username="jeff", password="123"))
assert resp.status_code == 200
data = resp.get_json()
new_token = data.get("auth_token")
assert new_token
# test that we're logged in
resp = client.get("/users/jeff", headers=dict(Authorization=f"bearer {new_token}"))
assert resp.status_code == 200

33
tests/test_whoami.py Normal file
View File

@ -0,0 +1,33 @@
"""Test /whoami."""
def test_whoami(client, auth):
"""Test authenticated whoami."""
for perms in [["READ"], ["CREATE", "MODIFY"], []]:
resp = client.patch(
"/users/jeff", headers=auth("administrator"), json=dict(permissions=perms)
)
assert resp.status_code == 200
resp = client.get("/whoami", headers=auth("jeff"))
assert resp.status_code == 200
data = resp.get_json()
assert data.get("username") == "jeff"
assert data.get("permissions") == perms
def test_anon_whoami(client, auth):
"""Test anonymous whoami."""
for perms in [["READ"], ["CREATE", "MODIFY"], []]:
resp = client.patch(
"/admin/settings",
headers=auth("administrator"),
json=dict(default_permissions=perms),
)
assert resp.status_code == 200
resp = client.get("/whoami")
assert resp.status_code == 200
data = resp.get_json()
assert data.get("username") is None
assert data.get("permissions") == perms