Reupload
This commit is contained in:
commit
61cbd57af1
168 changed files with 31208 additions and 0 deletions
36
.github/workflows/github-actions-test.yml
vendored
Normal file
36
.github/workflows/github-actions-test.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
name: Run tests
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x]
|
||||
mongodb-version: ['6.0']
|
||||
|
||||
steps:
|
||||
- name: Git checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.8.0
|
||||
with:
|
||||
mongodb-version: ${{ matrix.mongodb-version }}
|
||||
mongodb-username: DevUser
|
||||
mongodb-password: DevPassword
|
||||
mongodb-db: awary-test
|
||||
|
||||
- name: Install dependencies
|
||||
run: make server-setup
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
env:
|
||||
CI: true
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
server/node_modules
|
||||
server/dist
|
||||
server/db
|
||||
server/db-test
|
||||
server/conf.env
|
||||
server/requests/.env
|
||||
|
||||
web-app/node_modules
|
||||
web-app/build
|
||||
|
||||
docs/site
|
||||
|
||||
.nvimrc
|
661
LICENSE
Normal file
661
LICENSE
Normal 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/>.
|
45
Makefile
Normal file
45
Makefile
Normal file
|
@ -0,0 +1,45 @@
|
|||
.PHONY: db server-dev server server-setup web-app-dev web-app-build web-app-setup web-app test docs
|
||||
|
||||
UIDS:= USER_ID=$(shell id -u) GROUP_ID=$(shell id -g)
|
||||
ENV_VARS:= set -a && . ./conf.env && ${UIDS} && set +a
|
||||
|
||||
# Database
|
||||
|
||||
db:
|
||||
cd server && mkdir -p db && bash podman-mongodb.sh
|
||||
|
||||
db-test:
|
||||
cd server && bash podman-mongodb-test.sh
|
||||
|
||||
# Server
|
||||
|
||||
server-setup:
|
||||
cd server && npm install
|
||||
|
||||
server-dev:
|
||||
cd server && npm run dev
|
||||
|
||||
server:
|
||||
cd server && npm run start:prod
|
||||
|
||||
test: db-test
|
||||
cd server && npm run test
|
||||
|
||||
# Web app
|
||||
|
||||
web-app-setup:
|
||||
cd web-app && npm install
|
||||
|
||||
web-app-dev:
|
||||
cd web-app && npm start
|
||||
|
||||
web-app-build:
|
||||
cd web-app && npm run build
|
||||
|
||||
web-app:
|
||||
cd web-app && npm run build && npx serve -s build
|
||||
|
||||
# User documentation
|
||||
|
||||
docs:
|
||||
cd docs && python -m mkdocs build
|
46
README.md
Normal file
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
**This software is under development and not stable**
|
||||
|
||||
Awary is a simple software that allows you to **store** data in the form of a **log** or **metric**
|
||||
using HTTP requests, there is no processing of these data.
|
||||
|
||||

|
||||
|
||||
Documentation is available in the /docs directory as markdown files.
|
||||
|
||||
## Features
|
||||
|
||||
- Add logs and update metrics with a simple HTTP request.
|
||||
- Logs support tags.
|
||||
- Each metric update is kept in an history.
|
||||
- Customize your dashboard with charts or simple numbers.
|
||||
- Multiple API keys per project.
|
||||
|
||||
## Quickstart
|
||||
|
||||
*Note: the repository has a docker-compose file for the MongoDB database but you can connect to your own instance if you want.*
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js & npm
|
||||
- make
|
||||
- (optional) docker & docker-compose
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Copy the server config example `server/conf-example.env` to `server/conf.env`, then edit it to fill the required fields, everything should be documented inside the file itself.
|
||||
2. Edit the web app config file `web-app/.env.production` and change the `REACT_APP_API_URL` to match the location of the server.
|
||||
|
||||
### Launch it
|
||||
|
||||
1. `make db` to launch MongoDB with docker (optional).
|
||||
2. `make server-setup` to install the server dependencies (only the first time).
|
||||
3. `make web-app-setup` to install the web app dependencies (only the first time).
|
||||
4. `make server` to launch the server.
|
||||
5. `make web-app` to launch the web app.
|
||||
|
||||
## Building the documentation
|
||||
|
||||
1. Install **make**, **python** and **pip** if you don't already have them.
|
||||
2. Install mkdocs with `pip install mkdocs`.
|
||||
3. Install mkdocs material theme with `pip install mkdocs-material`.
|
||||
4. Build the docs with `make docs`.
|
57
docs/docs/Logs.md
Normal file
57
docs/docs/Logs.md
Normal file
|
@ -0,0 +1,57 @@
|
|||
# Logs
|
||||
|
||||
Logs are text with a date and tags associated with it.
|
||||
|
||||
## API Calls
|
||||
|
||||
You need 2 things to make an api call to create a log:
|
||||
|
||||
- An API Key (generate one from the "Api keys" tab on the left).
|
||||
- The project's `id`, you can copy it by clicking on the clipboard icon at the top of the page.
|
||||
|
||||
### Add a log
|
||||
|
||||
Method: `POST`
|
||||
Url: `/projects/{PROJECT_ID}/logs`
|
||||
Header:
|
||||
```json
|
||||
Authorization: Bearer {API_KEY}
|
||||
```
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"title": "The title",
|
||||
"content": "More information",
|
||||
"tags": [123456789098765432123456]
|
||||
}
|
||||
```
|
||||
|
||||
The `tags` is an array of `id`, you can find the `id` on the tags config panel on the Logs page.
|
||||
|
||||
### Retrieving all logs
|
||||
|
||||
Method: `GET`
|
||||
Url: `/projects/{PROJECT_ID}/logs`
|
||||
Header:
|
||||
```json
|
||||
Authorization: Bearer {API_KEY}
|
||||
```
|
||||
Response body example:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "647a148a4f68451001cec326",
|
||||
"projectId": "647a146d4f68451001cec323",
|
||||
"title": "The title",
|
||||
"content": "More information",
|
||||
"tags": [
|
||||
{
|
||||
"id": "647a148e4f68451001cec328",
|
||||
"projectId": "647a146d4f68451001cec323",
|
||||
"name": 1685722254142,
|
||||
"color": "#ff00ff"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
```
|
87
docs/docs/Metrics.md
Normal file
87
docs/docs/Metrics.md
Normal file
|
@ -0,0 +1,87 @@
|
|||
# Metrics
|
||||
|
||||
Metrics are an easy way to keep track of how things evolve.
|
||||
|
||||
## Use case
|
||||
|
||||
Each time a value change on your app or something else you want to track, you can call the api to
|
||||
update the value, all previous values are stored in a database and can be viewed in the
|
||||
form of a graph on the official web app, or you can call the api to retrieve the data and make your
|
||||
own thing.
|
||||
|
||||
## API Calls
|
||||
|
||||
You need 3 things to make an api call for a metric:
|
||||
|
||||
- An API Key (check this to know how to generate one).
|
||||
- The project's `id`, you can copy it by clicking on the clipboard icon next to the project's name.
|
||||
- The metric's `id`, you can copy it by clicking on the clipboard icon next to the metric's name.
|
||||
|
||||
### Updating a metric
|
||||
|
||||
Method: `PUT`
|
||||
Url: `/projects/{PROJECT_ID}/metrics/{METRIC_ID}`
|
||||
Header:
|
||||
```json
|
||||
Authorization: Bearer {API_KEY}
|
||||
```
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
value: 42
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving one metric
|
||||
|
||||
Method: `GET`
|
||||
Url: `/projects/{PROJECT_ID}/metrics/{METRIC_ID}`
|
||||
Header:
|
||||
```json
|
||||
Authorization: Bearer {API_KEY}
|
||||
```
|
||||
Response body example:
|
||||
```json
|
||||
{
|
||||
"id": "647a148a4f68451001cec326",
|
||||
"projectId": "647a146d4f68451001cec323",
|
||||
"name": "My metric name",
|
||||
"history": [
|
||||
{
|
||||
"id": "647a148e4f68451001cec328",
|
||||
"metricId": "647a148a4f68451001cec326",
|
||||
"date": 1685722254142,
|
||||
"value": 33
|
||||
}
|
||||
],
|
||||
"currentValue": 33
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving all metrics
|
||||
|
||||
Method: `GET`
|
||||
Url: `/projects/{PROJECT_ID}/metrics`
|
||||
Header:
|
||||
```json
|
||||
Authorization: Bearer {API_KEY}
|
||||
```
|
||||
Response body example:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "647a148a4f68451001cec326",
|
||||
"projectId": "647a146d4f68451001cec323",
|
||||
"name": "My metric name",
|
||||
"history": [
|
||||
{
|
||||
"id": "647a148e4f68451001cec328",
|
||||
"metricId": "647a148a4f68451001cec326",
|
||||
"date": 1685722254142,
|
||||
"value": 33
|
||||
}
|
||||
],
|
||||
"currentValue": 33
|
||||
}
|
||||
]
|
||||
```
|
5
docs/docs/index.md
Normal file
5
docs/docs/index.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Documentation for Awary
|
||||
|
||||
Awary is a simple software that allows you to **store** data in the form of a **log** or **metric**
|
||||
using HTTP requests, there is no processing of these data.
|
||||
|
27
docs/mkdocs.yml
Normal file
27
docs/mkdocs.yml
Normal file
|
@ -0,0 +1,27 @@
|
|||
site_name: Awary documentation
|
||||
theme:
|
||||
name: material
|
||||
palette:
|
||||
|
||||
# Palette toggle for light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
toggle:
|
||||
icon: material/weather-night
|
||||
name: Switch to dark mode
|
||||
|
||||
# Palette toggle for dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/weather-sunny
|
||||
name: Switch to light mode
|
||||
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences
|
BIN
images/presentation.png
Normal file
BIN
images/presentation.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 177 KiB |
12
server/.eslintrc.json
Normal file
12
server/.eslintrc.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"root": true,
|
||||
"rules": {
|
||||
"indent": ["error", "tab"],
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"object-curly-spacing": "error"
|
||||
}
|
||||
}
|
1
server/README.md
Normal file
1
server/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# awary-server
|
25
server/conf-example.env
Normal file
25
server/conf-example.env
Normal file
|
@ -0,0 +1,25 @@
|
|||
ENV=production
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8080
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=27017
|
||||
DB_USER=replace-me
|
||||
DB_PASSWORD=replace-me
|
||||
DB_NAME=awary
|
||||
|
||||
# For the following variables, you might want some long random string (if you have openssl installed you can use `openssl rand -base64 64`)
|
||||
JWT_SECRET=replace-me
|
||||
API_ADMIN_AUTHORIZATION=replace-me
|
||||
|
||||
ENABLE_USER_REGISTRATION=true
|
||||
RATE_LIMIT_ENABLED=false
|
||||
|
||||
# Uncomment to enable
|
||||
#
|
||||
# MAX_ACCOUNT=100
|
||||
# MAX_PROJECT_PER_ACCOUNT=4
|
||||
# MAX_METRIC_PER_PROJECT=5
|
||||
# METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||
# METRICS_HISTORY_LENGTH=1000
|
24
server/conf-test.env
Normal file
24
server/conf-test.env
Normal file
|
@ -0,0 +1,24 @@
|
|||
ENV=TEST
|
||||
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8081
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=27018
|
||||
DB_USER=DevUser
|
||||
DB_PASSWORD=DevPassword
|
||||
DB_NAME=awary-test
|
||||
|
||||
JWT_SECRET=ujebxzQ1qZC/jAFBXnVk/KfKwqJd8U+zDo3ZX3ejWAC26XaV1J9kjxa4CGvkMpTxvhFaFBPZoL5DdKnWWEDfVQ==
|
||||
API_ADMIN_AUTHORIZATION=cM6FB3X27THShaNniHwF4bewRc8QFGe0/CIOOPiwcJLa3jtRhIjcTEVW/zPOKDrR6e5kMv+j0kFgXmDjrM/hWA==
|
||||
|
||||
ENABLE_USER_REGISTRATION=true
|
||||
RATE_LIMIT_ENABLED=false
|
||||
|
||||
MAX_ACCOUNT=100
|
||||
|
||||
MAX_PROJECT_PER_ACCOUNT=4
|
||||
|
||||
MAX_METRIC_PER_PROJECT=5
|
||||
METRICS_MAX_UPDATE_PER_MINUTE=6
|
||||
METRICS_HISTORY_LENGTH=1000
|
15
server/create-user.sh
Normal file
15
server/create-user.sh
Normal file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
|
||||
. ./conf.env
|
||||
|
||||
BASE_URL=$1
|
||||
EMAIL=$2
|
||||
PASSWORD=`openssl rand -base64 16 | sed -e "s/=//g"`
|
||||
|
||||
echo $API_ADMIN_AUTHORIZATION
|
||||
echo $1
|
||||
echo $PASSWORD
|
||||
|
||||
curl -X POST $BASE_URL/signup \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\": \"$EMAIL\", \"password\": \"$PASSWORD\", \"adminToken\": \"$API_ADMIN_AUTHORIZATION\"}"
|
5240
server/package-lock.json
generated
Normal file
5240
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
server/package.json
Normal file
43
server/package.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@sinclair/typebox": "^0.23.5",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/sinon": "^10.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"@typescript-eslint/parser": "^5.54.0",
|
||||
"chai": "^4.3.6",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^8.35.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha-better-spec-reporter": "^3.1.0",
|
||||
"nodemon": "^2.0.16",
|
||||
"sinon": "^14.0.0",
|
||||
"ts-mocha": "^10.0.0",
|
||||
"ts-node": "^10.8.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsc-alias": "^1.8.2",
|
||||
"tsc-watch": "^6.0.0",
|
||||
"tsconfig-paths": "^4.0.0",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.0.0",
|
||||
"@fastify/jwt": "^6.1.0",
|
||||
"@fastify/type-provider-typebox": "^1.0.0",
|
||||
"argon2": "^0.28.5",
|
||||
"axios": "^0.27.2",
|
||||
"fastify": "^4.0.3",
|
||||
"mongodb": "^4.7.0",
|
||||
"nodemailer": "^6.7.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "LOGGER_ENABLED=1 env-cmd -f conf.env nodemon --watch './**/*.ts' --exec node --inspect=0.0.0.0 -r ts-node/register src/index.ts",
|
||||
"start:prod": "LOGGER_ENABLED=1 env-cmd -f conf.env node -r ts-node/register src/index.ts",
|
||||
"test": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register --exit 'src/**/*.test.ts'",
|
||||
"test-file": "env-cmd -f conf-test.env --no-override npx mocha -r ts-node/register",
|
||||
"lint": "eslint src"
|
||||
}
|
||||
}
|
12
server/podman-mongodb-test.sh
Normal file
12
server/podman-mongodb-test.sh
Normal file
|
@ -0,0 +1,12 @@
|
|||
source ./conf-test.env
|
||||
|
||||
mkdir -p ./db-test
|
||||
podman stop awary_mongodb_test
|
||||
podman rm awary_mongodb_test
|
||||
podman run -dt -p 27018:27017 \
|
||||
--name awary_mongodb_test \
|
||||
--userns keep-id \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||
-v ./db-test:/data/db \
|
||||
docker.io/library/mongo:5.0
|
13
server/podman-mongodb.sh
Normal file
13
server/podman-mongodb.sh
Normal file
|
@ -0,0 +1,13 @@
|
|||
source ./conf.env
|
||||
|
||||
mkdir -p ./db
|
||||
mkdir -p ./backup
|
||||
podman stop awary_mongodb
|
||||
podman rm awary_mongodb
|
||||
podman run -dt -p 27017:27017 \
|
||||
--name awary_mongodb \
|
||||
--userns keep-id \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=${DB_USER} \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=${DB_PASSWORD} \
|
||||
-v ./db:/data/db \
|
||||
docker.io/library/mongo:5.0
|
3
server/requests/.env-example
Normal file
3
server/requests/.env-example
Normal file
|
@ -0,0 +1,3 @@
|
|||
API_ADMIN_AUTHORIZATION=replace-me ## Must match the conf.env
|
||||
BEARER_TOKEN=random_string
|
||||
HOST=127.0.0.1:8080
|
36
server/requests/login.http
Normal file
36
server/requests/login.http
Normal file
|
@ -0,0 +1,36 @@
|
|||
### LOGIN
|
||||
|
||||
POST http://{{HOST}}/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
|
||||
{%
|
||||
local body = context.json_decode(context.result.body)
|
||||
context.set_env("BEARER_TOKEN", body.token)
|
||||
%}
|
||||
|
||||
### SIGNUP
|
||||
|
||||
POST http://{{HOST}}/signup
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
### FORCE CHANGE PASSWORD
|
||||
|
||||
POST http://{{HOST}}/admin/change-user-password
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{API_ADMIN_AUTHORIZATION}}
|
||||
|
||||
{
|
||||
"email": "user1@email.com",
|
||||
"newPassword": "1234567"
|
||||
}
|
56
server/src/core/App.ts
Normal file
56
server/src/core/App.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {Db} from "mongodb";
|
||||
import {UserFeature} from "./features/users";
|
||||
import {ProjectFeature} from "./features/projects";
|
||||
import {MetricFeature} from "./features/metrics";
|
||||
import {LogFeature} from "./features/logs";
|
||||
import {ActivityLoggerFeature} from "./features/activityLogger";
|
||||
import {ViewsFeature} from "./features/views";
|
||||
import {Logger} from "@app/utils/logger";
|
||||
import {ServerAdminFeature} from "./features/serverAdmin";
|
||||
|
||||
export class App {
|
||||
|
||||
db?: Db
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
viewsFeature: ViewsFeature
|
||||
activityLogger: ActivityLoggerFeature
|
||||
serverAdminFeature: ServerAdminFeature
|
||||
|
||||
constructor(db: Db) {
|
||||
const services = {db}
|
||||
this.userFeature = new UserFeature(services);
|
||||
this.projectFeature = new ProjectFeature(services, {
|
||||
userFeature: this.userFeature
|
||||
});
|
||||
this.logFeature = new LogFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature
|
||||
})
|
||||
this.metricFeature = new MetricFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature
|
||||
})
|
||||
this.viewsFeature = new ViewsFeature(services, {
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
this.activityLogger = new ActivityLoggerFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature,
|
||||
logFeature: this.logFeature,
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
this.serverAdminFeature = new ServerAdminFeature(services, {
|
||||
userFeature: this.userFeature,
|
||||
projectFeature: this.projectFeature,
|
||||
logFeature: this.logFeature,
|
||||
metricFeature: this.metricFeature
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
Logger.info("Starting application")
|
||||
}
|
||||
}
|
20
server/src/core/Feature.ts
Normal file
20
server/src/core/Feature.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {Db} from "mongodb";
|
||||
import {UserFeature} from "./features/users";
|
||||
|
||||
export type AppServices = {
|
||||
db: Db
|
||||
}
|
||||
|
||||
export type AppFeatures = {
|
||||
userFeature: UserFeature
|
||||
}
|
||||
|
||||
export abstract class Feature {
|
||||
|
||||
public abstract name: string
|
||||
protected services: AppServices
|
||||
|
||||
constructor(services: AppServices) {
|
||||
this.services = services
|
||||
}
|
||||
}
|
11
server/src/core/FeatureEvent.ts
Normal file
11
server/src/core/FeatureEvent.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export class FeatureEvent<T> {
|
||||
callbacks: ((data: T) => Promise<void>)[] = []
|
||||
|
||||
register(callback: (data: T) => Promise<void>) {
|
||||
this.callbacks.push(callback)
|
||||
}
|
||||
|
||||
async emit(data: T): Promise<void> {
|
||||
await Promise.all(this.callbacks.map(callback => callback(data)))
|
||||
}
|
||||
}
|
3
server/src/core/Identifiable.ts
Normal file
3
server/src/core/Identifiable.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export interface Identifiable {
|
||||
id: string
|
||||
}
|
3
server/src/core/exceptions/LimitReached.ts
Normal file
3
server/src/core/exceptions/LimitReached.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export class LimitReached extends Error {
|
||||
|
||||
}
|
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
65
server/src/core/features/activityLogger/ActivityLogger.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import {getAdminProjectId, getAdminSucessTagId} from "@app/utils";
|
||||
import {LogsUseCases} from "../logs/LogsUseCases";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {onMetricCreatedData} from "../metrics/MetricsEvents";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
import {ProjectContext} from "../projects/ProjectContext";
|
||||
import {onProjectCreatedData} from "../projects/ProjectsEvents";
|
||||
import {UserFeature} from "../users";
|
||||
import {onUserCreatedData} from "../users/UsersEvents";
|
||||
|
||||
interface ActivityLoggerDependencies {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logService: LogsUseCases
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ActivityLoggerService {
|
||||
private _userFeature: UserFeature
|
||||
private _projectFeature: ProjectFeature
|
||||
private _logService: LogsUseCases
|
||||
private _metricFeature: MetricFeature
|
||||
private _systemCaller = new Caller(new SystemCaller())
|
||||
|
||||
constructor(dependencies: ActivityLoggerDependencies) {
|
||||
this._userFeature = dependencies.userFeature
|
||||
this._projectFeature = dependencies.projectFeature
|
||||
this._logService = dependencies.logService
|
||||
this._metricFeature = dependencies.metricFeature
|
||||
|
||||
this._metricFeature.events.onMetricCreated.register(data => this.logMetricCreation(data))
|
||||
this._projectFeature.events.onProjectCreated.register(data => this.logProjectCreation(data))
|
||||
this._userFeature.events.onMetricCreated.register(data => this.adminLogUserCreation(data))
|
||||
}
|
||||
|
||||
private async adminLogUserCreation(data: onUserCreatedData): Promise<void> {
|
||||
const adminProjectId = getAdminProjectId()
|
||||
if (!adminProjectId)
|
||||
return ;
|
||||
const project = await this._projectFeature.projectRepository.findProjectById(adminProjectId)
|
||||
if (!project)
|
||||
return ;
|
||||
const context = new ProjectContext(project, this._systemCaller)
|
||||
const adminSucessTagId = getAdminSucessTagId()
|
||||
await this._logService.addLog(context, {
|
||||
title: `New user: ${data.user.email}`,
|
||||
tags: adminSucessTagId ? [adminSucessTagId] : []
|
||||
});
|
||||
}
|
||||
|
||||
private async logProjectCreation(data: onProjectCreatedData): Promise<void> {
|
||||
const context = new ProjectContext(data.project, this._systemCaller)
|
||||
await this._logService.addLog(context, {
|
||||
title: `Welcome to your new project: "${data.project.name}"`,
|
||||
});
|
||||
}
|
||||
|
||||
private async logMetricCreation(data: onMetricCreatedData): Promise<void> {
|
||||
const context = new ProjectContext(data.project, this._systemCaller)
|
||||
await this._logService.addLog(context, {
|
||||
title: `Created metric "${data.metric.name}"`,
|
||||
});
|
||||
}
|
||||
}
|
31
server/src/core/features/activityLogger/index.ts
Normal file
31
server/src/core/features/activityLogger/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {ActivityLoggerService} from "./ActivityLogger";
|
||||
|
||||
export type ActivityLoggerFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ActivityLoggerFeature extends Feature {
|
||||
|
||||
public name = "ActivityLogger"
|
||||
public service: ActivityLoggerService
|
||||
public dependencies: ActivityLoggerFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: ActivityLoggerFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.service = new ActivityLoggerService({
|
||||
userFeature: this.dependencies.userFeature,
|
||||
projectFeature: this.dependencies.projectFeature,
|
||||
logService: this.dependencies.logFeature.useCases,
|
||||
metricFeature: this.dependencies.metricFeature
|
||||
})
|
||||
}
|
||||
}
|
71
server/src/core/features/logs/LogsRepository.ts
Normal file
71
server/src/core/features/logs/LogsRepository.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {LogDataOnCreation, Log} from "./entities";
|
||||
|
||||
export interface LogDocument {
|
||||
projectId: ObjectId
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
function ConvertLogDocumentToEntity(document: WithId<LogDocument>): Log {
|
||||
return new Log({
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
title: document.title,
|
||||
tags: document.tags || [],
|
||||
content: document.content,
|
||||
createdAt: document.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
export class LogsRepository {
|
||||
|
||||
private _projectsLogs: Collection<LogDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._projectsLogs = db.collection("projectsLogs")
|
||||
}
|
||||
|
||||
async create(project: Project, data: LogDataOnCreation): Promise<void> {
|
||||
await this._projectsLogs.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
tags: data.tags,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
async findLogs(project: Project): Promise<Log[]> {
|
||||
const results = await this._projectsLogs.find({
|
||||
projectId: new ObjectId(project.id)
|
||||
})
|
||||
.sort({createdAt: -1})
|
||||
.toArray();
|
||||
const resultsWithId = results.map(value => ConvertLogDocumentToEntity(value));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findLogById(logId: string): Promise<Log | null> {
|
||||
const result = await this._projectsLogs.findOne({
|
||||
_id: new ObjectId(logId),
|
||||
});
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
return ConvertLogDocumentToEntity(result)
|
||||
}
|
||||
|
||||
async deleteLog(log: Log): Promise<void> {
|
||||
await this._projectsLogs.deleteOne({
|
||||
_id: new ObjectId(log.id),
|
||||
});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._projectsLogs.countDocuments();
|
||||
}
|
||||
}
|
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
95
server/src/core/features/logs/LogsUseCases.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||
import {Log, LogDataOnCreation, Tag, TagOnCreation} from "./entities";
|
||||
import {LogsRepository} from "./LogsRepository";
|
||||
import {TagsRepository} from "./TagsRepository";
|
||||
|
||||
interface LogServiceDependencies {
|
||||
projectService: ProjectsUseCases,
|
||||
logsRepository: LogsRepository,
|
||||
tagsRepository: TagsRepository
|
||||
}
|
||||
|
||||
export class LogsUseCases {
|
||||
private _projectService: ProjectsUseCases
|
||||
private _logsRepository: LogsRepository
|
||||
private _tagsRepository: TagsRepository
|
||||
|
||||
constructor(dependencies: LogServiceDependencies) {
|
||||
this._projectService = dependencies.projectService
|
||||
this._logsRepository = dependencies.logsRepository
|
||||
this._tagsRepository = dependencies.tagsRepository
|
||||
}
|
||||
|
||||
async addLog(context: ProjectContext, log: LogDataOnCreation): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._logsRepository.create(context.project, {
|
||||
title: log.title,
|
||||
content: log.content,
|
||||
tags: log.tags,
|
||||
})
|
||||
}
|
||||
|
||||
async getLogById(context: ProjectContext, logId: string): Promise<Log> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const log = await this._logsRepository.findLogById(logId)
|
||||
if (!log) {
|
||||
throw new MissingResource("Log doesn't exist")
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async getLogs(context: ProjectContext) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._logsRepository.findLogs(context.project);
|
||||
}
|
||||
|
||||
async deleteLog(context: ProjectContext, log: Log) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._logsRepository.deleteLog(log);
|
||||
}
|
||||
|
||||
async createTag(context: ProjectContext, data: Omit<TagOnCreation, "projectId">): Promise<Tag> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
return this._tagsRepository.create(context.project, {
|
||||
projectId: context.project.id,
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
})
|
||||
}
|
||||
|
||||
async updateTag(context: ProjectContext, tag: Tag, data: Omit<TagOnCreation, "projectId">): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._tagsRepository.updateTag(tag, {
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
})
|
||||
}
|
||||
|
||||
async getTags(context: ProjectContext): Promise<Tag[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
return await this._tagsRepository.findByProject(context.project);
|
||||
}
|
||||
|
||||
async getTag(context: ProjectContext, id: string): Promise<Tag> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const tag = await this._tagsRepository.findById(context.project, id);
|
||||
if (!tag) {
|
||||
throw new MissingResource("Tag doesn't exist")
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
async deleteTag(context: ProjectContext, tag: Tag): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._tagsRepository.deleteTag(tag);
|
||||
}
|
||||
}
|
80
server/src/core/features/logs/TagsRepository.ts
Normal file
80
server/src/core/features/logs/TagsRepository.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {Tag, TagOnCreation} from "./entities";
|
||||
|
||||
export interface TagDocument {
|
||||
projectId: ObjectId
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
function ConvertTagDocumentToEntity(document: WithId<TagDocument>): Tag {
|
||||
return {
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
name: document.name,
|
||||
color: document.color
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsRepository {
|
||||
|
||||
private _tags: Collection<TagDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._tags = db.collection("tags")
|
||||
}
|
||||
|
||||
async create(project: Project, data: TagOnCreation): Promise<Tag> {
|
||||
const tag = await this._tags.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
name: data.name,
|
||||
color: data.color
|
||||
});
|
||||
return ConvertTagDocumentToEntity({
|
||||
_id: tag.insertedId,
|
||||
projectId: new ObjectId(project.id),
|
||||
name: data.name,
|
||||
color: data.color
|
||||
})
|
||||
}
|
||||
|
||||
async updateTag(tag: Tag, data: Partial<TagDocument>): Promise<void> {
|
||||
await this._tags.updateOne({_id: new ObjectId(tag.id)}, {
|
||||
$set: {
|
||||
name: data.name,
|
||||
color: data.color
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findByProject(project: Project): Promise<Tag[]> {
|
||||
const results = await this._tags.find({
|
||||
projectId: new ObjectId(project.id)
|
||||
})
|
||||
.sort({createdAt: -1})
|
||||
.toArray();
|
||||
const resultsWithId = results.map(value => ConvertTagDocumentToEntity(value));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findById(project: Project, id: string): Promise<Tag | null> {
|
||||
const result = await this._tags.findOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
_id: new ObjectId(id)
|
||||
})
|
||||
if (!result)
|
||||
return null
|
||||
return ConvertTagDocumentToEntity(result)
|
||||
}
|
||||
|
||||
async deleteTag(tag: Tag): Promise<void> {
|
||||
await this._tags.deleteOne({
|
||||
_id: new ObjectId(tag.id),
|
||||
});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._tags.countDocuments();
|
||||
}
|
||||
}
|
35
server/src/core/features/logs/entities/Log.ts
Normal file
35
server/src/core/features/logs/entities/Log.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
export interface LogDataOnCreation {
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface LogConstructor extends Identifiable {
|
||||
id: string
|
||||
projectId: string
|
||||
title: string
|
||||
content?: string
|
||||
tags?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export class Log {
|
||||
|
||||
public readonly id: string
|
||||
public readonly projectId: string
|
||||
public readonly title: string
|
||||
public readonly content?: string
|
||||
public readonly tags: string[]
|
||||
public readonly createdAt: number
|
||||
|
||||
constructor(data: LogConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.title = data.title
|
||||
this.content = data.content
|
||||
this.tags = data.tags || []
|
||||
this.createdAt = data.createdAt
|
||||
}
|
||||
}
|
12
server/src/core/features/logs/entities/Tag.ts
Normal file
12
server/src/core/features/logs/entities/Tag.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export interface TagOnCreation {
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
2
server/src/core/features/logs/entities/index.ts
Normal file
2
server/src/core/features/logs/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./Log"
|
||||
export * from "./Tag"
|
32
server/src/core/features/logs/index.ts
Normal file
32
server/src/core/features/logs/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {LogsRepository} from "./LogsRepository";
|
||||
import {LogsUseCases} from "./LogsUseCases";
|
||||
import {TagsRepository} from "./TagsRepository";
|
||||
|
||||
export type LogFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
}
|
||||
|
||||
export class LogFeature extends Feature {
|
||||
|
||||
name = "Log"
|
||||
logsRepository: LogsRepository
|
||||
tagsRepository: TagsRepository
|
||||
useCases: LogsUseCases
|
||||
dependencies: LogFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: LogFeatureDependencies) {
|
||||
super(services);
|
||||
this.logsRepository = new LogsRepository(services.db)
|
||||
this.tagsRepository = new TagsRepository(services.db)
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new LogsUseCases({
|
||||
projectService: this.dependencies.projectFeature.service,
|
||||
logsRepository: this.logsRepository,
|
||||
tagsRepository: this.tagsRepository
|
||||
})
|
||||
}
|
||||
}
|
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
14
server/src/core/features/metrics/MetricsEvents.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Caller} from "../projects/entities/Caller";
|
||||
import {Project} from "../projects/entities/Project";
|
||||
import {Metric} from ".";
|
||||
|
||||
export type onMetricCreatedData = {
|
||||
project: Project
|
||||
metric: Metric
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class MetricEvents {
|
||||
onMetricCreated: FeatureEvent<onMetricCreatedData> = new FeatureEvent()
|
||||
}
|
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
141
server/src/core/features/metrics/MetricsRepository.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities";
|
||||
import {Metric, MetricValue} from "./entities";
|
||||
|
||||
interface MetricDocument {
|
||||
projectId: ObjectId
|
||||
name: string
|
||||
currentValue: undefined | number
|
||||
}
|
||||
|
||||
interface MetricHistoryDocument {
|
||||
metricId: ObjectId
|
||||
date: number // timestamp
|
||||
value: number
|
||||
}
|
||||
|
||||
function MetricDocumentToEntity(document: WithId<MetricDocument>, values: WithId<MetricHistoryDocument>[] | null): Metric {
|
||||
return new Metric({
|
||||
id: document._id.toString(),
|
||||
name: document.name,
|
||||
projectId: document.projectId.toString(),
|
||||
currentValue: document.currentValue,
|
||||
history: values ? values.map(MetricHistoryDocumentToEntity) : null
|
||||
})
|
||||
}
|
||||
|
||||
function MetricHistoryDocumentToEntity(document: WithId<MetricHistoryDocument>): MetricValue {
|
||||
return {
|
||||
id: document._id.toString(),
|
||||
metricId: document.metricId.toString(),
|
||||
date: document.date,
|
||||
value: document.value,
|
||||
};
|
||||
}
|
||||
|
||||
export class MetricsRepository {
|
||||
|
||||
private _metrics: Collection<MetricDocument>
|
||||
private _metricsHistory: Collection<MetricHistoryDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._metrics = db.collection("projectsMetrics")
|
||||
this._metricsHistory = db.collection("projectsMetricsHistory")
|
||||
}
|
||||
|
||||
async createMetric(project: Project, name: string): Promise<Metric> {
|
||||
const metric = await this._metrics.insertOne({
|
||||
projectId: new ObjectId(project.id),
|
||||
name: name,
|
||||
currentValue: undefined
|
||||
});
|
||||
return MetricDocumentToEntity({
|
||||
_id: metric.insertedId,
|
||||
projectId: new ObjectId(project.id),
|
||||
name,
|
||||
currentValue: undefined
|
||||
}, null)
|
||||
}
|
||||
|
||||
async updateMetric(metric: Metric, name: string): Promise<void> {
|
||||
await this._metrics.updateOne({_id: new ObjectId(metric.id)}, {
|
||||
$set: {
|
||||
name: name,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async AddValueToHistory(metric: Metric, value: Omit<MetricValue, "id" | "metricId">): Promise<void> {
|
||||
const metricQuery = {projectId: new ObjectId(metric.projectId), name: metric.name}
|
||||
const newValueDocument = {...value, metricId: new ObjectId(metric.id)}
|
||||
await this._metricsHistory.insertOne(newValueDocument);
|
||||
await this._metrics.updateOne(metricQuery, {$set: {currentValue: value.value}})
|
||||
}
|
||||
|
||||
async deleteHistoryRecord(metric: Metric, recordId: string): Promise<void> {
|
||||
const deleteFilter = {metricId: new ObjectId(metric.id), _id: new ObjectId(recordId)}
|
||||
await this._metricsHistory.deleteOne(deleteFilter);
|
||||
}
|
||||
|
||||
async findAll(project: Project, fetchValues = false): Promise<Metric[]> {
|
||||
const metric = await this._metrics.find({projectId: new ObjectId(project.id)}).toArray()
|
||||
const metricIds = metric.map(metric => metric._id);
|
||||
const allMetricValues = await this._metricsHistory.find({metricId: {$in: metricIds}}).sort({date: 1}).toArray()
|
||||
return metric.map(thisMetric => {
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(thisMetric, null);
|
||||
}
|
||||
const values = allMetricValues.filter(value => value.metricId.equals(thisMetric._id))
|
||||
return MetricDocumentToEntity(thisMetric, values);
|
||||
})
|
||||
}
|
||||
|
||||
async findOne(project: Project, name: string, fetchValues = false): Promise<Metric | null> {
|
||||
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name})
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(metric, null);
|
||||
}
|
||||
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||
return MetricDocumentToEntity(metric, values);
|
||||
}
|
||||
|
||||
async findMetricById(project: Project, id: string, fetchValues = false): Promise<Metric | null> {
|
||||
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id),_id: new ObjectId(id)})
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
if (!fetchValues) {
|
||||
return MetricDocumentToEntity(metric, null);
|
||||
}
|
||||
const values = await this._metricsHistory.find({metricId: metric._id}).sort({date: 1}).toArray()
|
||||
return MetricDocumentToEntity(metric, values);
|
||||
}
|
||||
|
||||
async MetricExists(project: Project, name: string): Promise<boolean> {
|
||||
const metric = await this._metrics.findOne({projectId: new ObjectId(project.id), name});
|
||||
return metric !== null
|
||||
}
|
||||
|
||||
async deleteMetric(metric: Metric): Promise<void> {
|
||||
const metricId = new ObjectId(metric.id);
|
||||
await this._metricsHistory.deleteMany({metricId: metricId})
|
||||
await this._metrics.deleteOne({_id: metricId})
|
||||
}
|
||||
|
||||
async getHistoryLengthOfMetric(metric: Metric): Promise<number> {
|
||||
return this._metricsHistory.countDocuments({metricId: new ObjectId(metric.id)});
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._metrics.countDocuments();
|
||||
}
|
||||
|
||||
async countHistory(): Promise<number> {
|
||||
return this._metricsHistory.countDocuments();
|
||||
}
|
||||
}
|
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
84
server/src/core/features/metrics/MetricsUseCases.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {MissingResource} from "../projects/exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {ProjectsUseCases} from "../projects/ProjectsUseCases";
|
||||
import {getMetricsHistoryLimit} from "../users/utils";
|
||||
import {Metric, MetricCreationProperties} from "./entities";
|
||||
import {MetricEvents} from "./MetricsEvents";
|
||||
import {MetricsRepository} from "./MetricsRepository";
|
||||
|
||||
interface MetricServiceDependencies {
|
||||
projectService: ProjectsUseCases,
|
||||
metricRepository: MetricsRepository,
|
||||
metricEvents: MetricEvents
|
||||
}
|
||||
|
||||
export class MetricsUseCases {
|
||||
private _projectService: ProjectsUseCases
|
||||
private _metricRepository: MetricsRepository
|
||||
private _metricEvents: MetricEvents
|
||||
|
||||
constructor(dependencies: MetricServiceDependencies) {
|
||||
this._projectService = dependencies.projectService
|
||||
this._metricRepository = dependencies.metricRepository
|
||||
this._metricEvents = dependencies.metricEvents
|
||||
}
|
||||
|
||||
async createMetric(context: ProjectContext, metricInfo: MetricCreationProperties): Promise<Metric> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project, caller} = context;
|
||||
|
||||
const metric = await this._metricRepository.createMetric(project, metricInfo.name)
|
||||
await this._metricEvents.onMetricCreated.emit({project, metric: metric, caller});
|
||||
return metric
|
||||
}
|
||||
|
||||
async updateMetric(context: ProjectContext, metric: Metric, metricInfo: MetricCreationProperties): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._metricRepository.updateMetric(metric, metricInfo.name)
|
||||
}
|
||||
|
||||
async getAllMetrics(context: ProjectContext, fetchValues?: boolean): Promise<Metric[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const metric = await this._metricRepository.findAll(context.project, fetchValues);
|
||||
return metric
|
||||
}
|
||||
|
||||
async getMetricById(context: ProjectContext, metricId: string, fetchValues?: boolean): Promise<Metric> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
const metric = await this._metricRepository.findMetricById(context.project, metricId, fetchValues);
|
||||
if (!metric) {
|
||||
throw new MissingResource("Metric doesn't exist")
|
||||
}
|
||||
return metric
|
||||
}
|
||||
|
||||
async deleteMetric(context: ProjectContext, metric: Metric): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._metricRepository.deleteMetric(metric);
|
||||
}
|
||||
|
||||
async deleteMetricHistoryRecord(context: ProjectContext, metric: Metric, recordId: string): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
await this._metricRepository.deleteHistoryRecord(metric, recordId);
|
||||
}
|
||||
|
||||
async setMetricValue(context: ProjectContext, metric: Metric, value: number, date?: number) {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
|
||||
const currentHistoryLength = await this._metricRepository.getHistoryLengthOfMetric(metric)
|
||||
|
||||
if (currentHistoryLength >= getMetricsHistoryLimit()) {
|
||||
throw new LimitReached("History limit reached (experimental feature)")
|
||||
}
|
||||
|
||||
await this._metricRepository.AddValueToHistory(metric, {
|
||||
date: date || Date.now(),
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
40
server/src/core/features/metrics/entities/Metric.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
|
||||
export interface MetricCreationProperties {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface MetricConstructor extends Identifiable {
|
||||
projectId: string
|
||||
name: string
|
||||
history: MetricValue[] | null
|
||||
currentValue: undefined | number
|
||||
}
|
||||
|
||||
export interface MetricValue extends Identifiable {
|
||||
metricId: string
|
||||
date: number // timestamp
|
||||
value: number
|
||||
}
|
||||
|
||||
export class Metric implements MetricConstructor {
|
||||
|
||||
public readonly id: string
|
||||
public readonly projectId: string
|
||||
public readonly name: string
|
||||
public readonly history: MetricValue[] | null
|
||||
public readonly currentValue: undefined | number
|
||||
|
||||
constructor(data: MetricConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.name = data.name
|
||||
this.history = data.history
|
||||
this.currentValue = data.currentValue
|
||||
}
|
||||
|
||||
hasValues(): boolean {
|
||||
return this.history !== null
|
||||
}
|
||||
}
|
1
server/src/core/features/metrics/entities/index.ts
Normal file
1
server/src/core/features/metrics/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./Metric"
|
35
server/src/core/features/metrics/index.ts
Normal file
35
server/src/core/features/metrics/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {MetricEvents} from "./MetricsEvents";
|
||||
import {MetricsRepository} from "./MetricsRepository";
|
||||
import {MetricsUseCases} from "./MetricsUseCases";
|
||||
|
||||
export * from "./MetricsUseCases"
|
||||
export * from "./entities"
|
||||
|
||||
export type MetricFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
}
|
||||
|
||||
export class MetricFeature extends Feature {
|
||||
|
||||
name = "Metric"
|
||||
events: MetricEvents = new MetricEvents()
|
||||
dependencies: MetricFeatureDependencies
|
||||
|
||||
repository: MetricsRepository
|
||||
useCases: MetricsUseCases
|
||||
|
||||
constructor(services: AppServices, dependencies: MetricFeatureDependencies) {
|
||||
super(services);
|
||||
this.repository = new MetricsRepository(services.db)
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new MetricsUseCases({
|
||||
projectService: this.dependencies.projectFeature.service,
|
||||
metricRepository: this.repository,
|
||||
metricEvents: this.events
|
||||
})
|
||||
}
|
||||
}
|
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
43
server/src/core/features/projects/ApiKeyRepository.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
import {ApiKey} from "./entities/ApiKey";
|
||||
import {ApiKeyData} from "./entities/ApiKeyData";
|
||||
import {Project} from "./entities/Project";
|
||||
|
||||
export class ApiKeyRepository {
|
||||
|
||||
private _repository: Collection<Omit<ApiKeyData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._repository = db.collection("projectsApiKeys")
|
||||
}
|
||||
|
||||
async create(data: Omit<ApiKeyData, "id">): Promise<void> {
|
||||
await this._repository.insertOne(data);
|
||||
}
|
||||
|
||||
async delete(apiKey: ApiKey): Promise<void> {
|
||||
await this._repository.deleteOne({_id: new ObjectId(apiKey.id)});
|
||||
}
|
||||
|
||||
async findByKey(key: string): Promise<ApiKey | null> {
|
||||
const result = await this._repository.findOne({key});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return new ApiKey({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
const result = await this._repository.findOne({_id: new ObjectId(id)});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return new ApiKey({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async findAllByProject(project: Project): Promise<ApiKey[]> {
|
||||
const results = await this._repository.find({projectId: project.id}).toArray();
|
||||
const resultsWithId = results.map(value => new ApiKey(({...value, id: value._id.toString()})));
|
||||
return resultsWithId;
|
||||
}
|
||||
}
|
33
server/src/core/features/projects/ProjectContext.ts
Normal file
33
server/src/core/features/projects/ProjectContext.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {Project, Caller} from "./entities";
|
||||
import {MissingAuthorization} from "./exceptions/MissingAuthorization";
|
||||
|
||||
export enum ProjectAuthorization {
|
||||
Read = 'Read',
|
||||
Write = 'Write'
|
||||
}
|
||||
|
||||
export class ProjectContext {
|
||||
|
||||
project: Project
|
||||
caller: Caller
|
||||
|
||||
constructor(project: Project, caller: Caller) {
|
||||
this.project = project
|
||||
this.caller = caller
|
||||
}
|
||||
|
||||
enforceAuthorizations(authorizations: string[]): void {
|
||||
if (this.caller.isSystem())
|
||||
return
|
||||
if (this.caller.isUser() && this.caller.asUser().id === this.project.ownerId)
|
||||
return
|
||||
if (this.caller.isApiKey() && this.caller.asApiKey().projectId === this.project.id) {
|
||||
const hasAuthorizations = authorizations.every(authorization =>
|
||||
this.caller.asApiKey().hasAuthorization(authorization)
|
||||
)
|
||||
if (hasAuthorizations)
|
||||
return;
|
||||
}
|
||||
throw new MissingAuthorization("Missing authorization");
|
||||
}
|
||||
}
|
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
11
server/src/core/features/projects/ProjectsEvents.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Project, Caller} from "../projects/entities";
|
||||
|
||||
export type onProjectCreatedData = {
|
||||
project: Project
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class ProjectEvents {
|
||||
onProjectCreated: FeatureEvent<onProjectCreatedData> = new FeatureEvent()
|
||||
}
|
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
73
server/src/core/features/projects/ProjectsRepository.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {Project, ProjectData, Tag} from "./entities";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
|
||||
export class ProjectsRepository {
|
||||
|
||||
private _projects: Collection<Omit<ProjectData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._projects = db.collection("projects")
|
||||
}
|
||||
|
||||
async createProject(data: Omit<ProjectData, "id">): Promise<Project> {
|
||||
const createProject = await this._projects.insertOne(data);
|
||||
return this._format({...data, id: createProject.insertedId.toString()})
|
||||
}
|
||||
|
||||
async saveProject(project: Project): Promise<void> {
|
||||
await this._projects.updateOne(
|
||||
{_id: new ObjectId(project.id)},
|
||||
{$set: {...project.getState(), updatedAt: Date.now()}}
|
||||
)
|
||||
}
|
||||
|
||||
async addTag(project: Project, tag: Tag) : Promise<void> {
|
||||
await this._projects.updateOne({_id: new ObjectId(project.id)}, {$push: {tags: tag}});
|
||||
}
|
||||
|
||||
async findProjectsOfUser(user: User): Promise<Project[]> {
|
||||
const results = await this._projects.find({ownerId: user.id}).toArray();
|
||||
const resultsWithId = results.map(value => this._format({...value, id: value._id.toString()}));
|
||||
return resultsWithId
|
||||
}
|
||||
|
||||
async findProjectById(id: string): Promise<Project | null> {
|
||||
const result = await this._projects.findOne({_id: new ObjectId(id)});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return this._format({...result, id: result._id.toString()})
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._projects.countDocuments();
|
||||
}
|
||||
|
||||
/*async createLog(data: Omit<LogData, "id">): Promise<void> {
|
||||
await this._projectsLogs.insertOne(data);
|
||||
}
|
||||
|
||||
async findLogsOfProject(project: Project): Promise<LogData[]> {
|
||||
const results = await this._projectsLogs.find({projectId: project.id}).toArray();
|
||||
const resultsWithId = results.map(value => ({...value, id: value._id.toString()}));
|
||||
return resultsWithId
|
||||
}*/
|
||||
|
||||
private _format(data: Partial<ProjectData>): Project {
|
||||
if (!data.id) {
|
||||
throw Error("Id is undefined")
|
||||
}
|
||||
if (!data.ownerId) {
|
||||
throw Error("ownerId is undefined")
|
||||
}
|
||||
return new Project ({
|
||||
id: data.id,
|
||||
ownerId: data.ownerId,
|
||||
name: data.name || "Missing name",
|
||||
tags: data.tags || [],
|
||||
createdAt: data.createdAt || 0,
|
||||
updatedAt: data.updatedAt || 0
|
||||
})
|
||||
}
|
||||
}
|
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
109
server/src/core/features/projects/ProjectsUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {randomBytes} from "crypto";
|
||||
import {getAccountProjectsLimit} from "../users/utils";
|
||||
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||
import {ApiKey, Caller, Project} from "./entities";
|
||||
import {MissingResource} from "./exceptions/MissingResource";
|
||||
import {ProjectAuthorization, ProjectContext} from "./ProjectContext";
|
||||
import {ProjectEvents} from "./ProjectsEvents";
|
||||
import {ProjectsRepository} from "./ProjectsRepository";
|
||||
|
||||
interface ProjectServiceDependencies {
|
||||
projectRepository: ProjectsRepository,
|
||||
projectEvents: ProjectEvents
|
||||
apiKeyRepository: ApiKeyRepository
|
||||
}
|
||||
|
||||
export class ProjectsUseCases {
|
||||
private _projectRepository: ProjectsRepository
|
||||
private _projectEvents: ProjectEvents
|
||||
private _apiKeyRepository: ApiKeyRepository
|
||||
|
||||
constructor(dependencies: ProjectServiceDependencies) {
|
||||
this._projectRepository = dependencies.projectRepository
|
||||
this._projectEvents = dependencies.projectEvents
|
||||
this._apiKeyRepository = dependencies.apiKeyRepository
|
||||
}
|
||||
|
||||
async createProject(owner: User, name: string): Promise<Project> {
|
||||
const userProjects = await this._projectRepository.findProjectsOfUser(owner);
|
||||
if (userProjects.length >= getAccountProjectsLimit()) {
|
||||
throw new LimitReached(`Maximum account limit (${getAccountProjectsLimit()}) reached`)
|
||||
}
|
||||
const project = await this._projectRepository.createProject({
|
||||
name,
|
||||
ownerId: owner.id,
|
||||
tags: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
await this._projectEvents.onProjectCreated.emit({project, caller: new Caller(owner)})
|
||||
return project;
|
||||
}
|
||||
|
||||
async getProjectsOfUser(user: User): Promise<Project[]> {
|
||||
const projects = await this._projectRepository.findProjectsOfUser(user);
|
||||
return projects;
|
||||
}
|
||||
|
||||
async getProjectById(caller: Caller, id: string): Promise<Project | null> {
|
||||
const project = await this._projectRepository.findProjectById(id);
|
||||
if (!project) {
|
||||
throw new MissingResource("Project not found")
|
||||
}
|
||||
const context = new ProjectContext(project, caller)
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return project;
|
||||
}
|
||||
|
||||
async addTagToProject(context: ProjectContext, {name, color}: {name: string, color: string}): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
await this._projectRepository.addTag(project, {id: project.tags.length + 1, name, color});
|
||||
}
|
||||
|
||||
async updateTagOfProject(context: ProjectContext, id: number, {name, color}: {name: string, color: string}): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
project.changeTag(id, name, color);
|
||||
await this._projectRepository.saveProject(project);
|
||||
}
|
||||
|
||||
async generateApiKey(context: ProjectContext, name: string): Promise<string> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const apiKey = randomBytes(32).toString("base64").replace(/=/gi, "");
|
||||
await this._apiKeyRepository.create({
|
||||
key: apiKey,
|
||||
projectId: context.project.id,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
return apiKey
|
||||
}
|
||||
|
||||
async getApiKeysOfProject(context: ProjectContext): Promise<ApiKey[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return this._apiKeyRepository.findAllByProject(context.project);
|
||||
}
|
||||
|
||||
async deleteApiKeys(context: ProjectContext, apiKey: ApiKey): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
return this._apiKeyRepository.delete(apiKey);
|
||||
}
|
||||
|
||||
async getApiKey(context: ProjectContext, apiKey: string): Promise<ApiKey | null> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return this._apiKeyRepository.findByKey(apiKey);
|
||||
}
|
||||
|
||||
async getApiKeyById(context: ProjectContext, keyId: string): Promise<ApiKey> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
const apiKey = await this._apiKeyRepository.findById(keyId);
|
||||
if (!apiKey) {
|
||||
throw new MissingResource("Api key not found")
|
||||
}
|
||||
return apiKey
|
||||
}
|
||||
}
|
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
35
server/src/core/features/projects/entities/ApiKey.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {ProjectAuthorization} from "../ProjectContext";
|
||||
import {ApiKeyData} from "./ApiKeyData";
|
||||
|
||||
export interface ApiKey extends Readonly<ApiKeyData> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export class ApiKey {
|
||||
|
||||
authorizations: string[]
|
||||
|
||||
constructor(data: ApiKeyData) {
|
||||
Object.assign(this, data);
|
||||
this.authorizations = [ProjectAuthorization.Read, ProjectAuthorization.Write]
|
||||
}
|
||||
|
||||
hasAuthorization(authorization: string): boolean {
|
||||
return this.authorizations.includes(authorization);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.key
|
||||
}
|
||||
|
||||
getState(): ApiKeyData {
|
||||
return {
|
||||
id: this.id,
|
||||
key: this.key,
|
||||
name: this.name,
|
||||
projectId: this.projectId,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
8
server/src/core/features/projects/entities/ApiKeyData.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface ApiKeyData {
|
||||
id: string
|
||||
key: string
|
||||
projectId: string
|
||||
name: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
73
server/src/core/features/projects/entities/Caller.ts
Normal file
73
server/src/core/features/projects/entities/Caller.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {User} from "@app/core/features/users/entities";
|
||||
import {ApiKey} from "./ApiKey";
|
||||
import {Project} from "./Project";
|
||||
|
||||
export class SystemCaller {
|
||||
|
||||
}
|
||||
|
||||
export class AdminUser {
|
||||
|
||||
}
|
||||
|
||||
export class Caller {
|
||||
|
||||
private _user?: User
|
||||
private _apiKey?: ApiKey
|
||||
private _isSystem = false
|
||||
private _isAdmin = false
|
||||
|
||||
constructor(caller: User | ApiKey | SystemCaller | AdminUser) {
|
||||
if (caller instanceof User) {
|
||||
this._user = caller
|
||||
} else if (caller instanceof ApiKey) {
|
||||
this._apiKey = caller
|
||||
} else if (caller instanceof SystemCaller) {
|
||||
this._isSystem = true
|
||||
} else if (caller instanceof AdminUser) {
|
||||
this._isAdmin = true
|
||||
} else {
|
||||
throw new Error("Wrong type for Caller constructor")
|
||||
}
|
||||
}
|
||||
|
||||
CanReadProject(project: Project): boolean {
|
||||
if (this?._user && this._user.id === project.ownerId)
|
||||
return true
|
||||
if (this?._apiKey && this._apiKey.projectId === project.id)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
CanWriteProject(project: Project): boolean {
|
||||
return this.CanReadProject(project)
|
||||
}
|
||||
|
||||
isApiKey(): boolean {
|
||||
return !!this._apiKey
|
||||
}
|
||||
|
||||
isUser(): boolean {
|
||||
return !!this._user
|
||||
}
|
||||
|
||||
isSystem(): boolean {
|
||||
return this._isSystem;
|
||||
}
|
||||
|
||||
isAdmin(): boolean {
|
||||
return this._isAdmin;
|
||||
}
|
||||
|
||||
asApiKey(): ApiKey {
|
||||
if (!this.isApiKey())
|
||||
throw new Error("Trying to get caller User as ApiKey")
|
||||
return this._apiKey as ApiKey
|
||||
}
|
||||
|
||||
asUser(): User {
|
||||
if (!this.isUser())
|
||||
throw new Error("Trying to get caller ApiKey as User")
|
||||
return this._user as User
|
||||
}
|
||||
}
|
45
server/src/core/features/projects/entities/Project.ts
Normal file
45
server/src/core/features/projects/entities/Project.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {Tag} from "./Tag";
|
||||
import {ProjectData} from "./ProjectData";
|
||||
|
||||
export class Project implements ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
tags: Tag[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
|
||||
constructor(data: ProjectData) {
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
this.ownerId = data.ownerId
|
||||
this.tags = data.tags
|
||||
this.createdAt = data.createdAt
|
||||
this.updatedAt = data.updatedAt
|
||||
}
|
||||
|
||||
getState(): ProjectData {
|
||||
return {
|
||||
id: this.id,
|
||||
ownerId: this.ownerId,
|
||||
name: this.name,
|
||||
tags: this.tags,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
changeTag(id: number, name: string, color: string): Tag {
|
||||
const tag = this.tags.find(tag => tag.id === id)
|
||||
if (!tag) {
|
||||
throw new Error(`Tag '${id}' doesn't exist`);
|
||||
}
|
||||
tag.name = name;
|
||||
tag.color = color;
|
||||
return tag;
|
||||
}
|
||||
|
||||
hasTag(name: string): boolean {
|
||||
return this.tags.find(tag => tag.name === name) !== undefined
|
||||
}
|
||||
}
|
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
10
server/src/core/features/projects/entities/ProjectData.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {Tag} from "./Tag"
|
||||
|
||||
export interface ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
tags: Tag[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
5
server/src/core/features/projects/entities/Tag.ts
Normal file
5
server/src/core/features/projects/entities/Tag.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
6
server/src/core/features/projects/entities/index.ts
Normal file
6
server/src/core/features/projects/entities/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from "./ApiKey"
|
||||
export * from "./ApiKeyData"
|
||||
export * from "./Caller"
|
||||
export * from "./Project"
|
||||
export * from "./ProjectData"
|
||||
export * from "./Tag"
|
|
@ -0,0 +1,3 @@
|
|||
export class MissingAuthorization extends Error {
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export class MissingResource extends Error {
|
||||
|
||||
}
|
32
server/src/core/features/projects/index.ts
Normal file
32
server/src/core/features/projects/index.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {UserFeature} from "../users";
|
||||
import {ApiKeyRepository} from "./ApiKeyRepository";
|
||||
import {ProjectEvents} from "./ProjectsEvents";
|
||||
import {ProjectsRepository} from "./ProjectsRepository";
|
||||
import {ProjectsUseCases} from "./ProjectsUseCases";
|
||||
|
||||
export type ProjectFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
}
|
||||
|
||||
export class ProjectFeature extends Feature {
|
||||
|
||||
name = "Project"
|
||||
projectRepository: ProjectsRepository
|
||||
apiKeyRepository: ApiKeyRepository
|
||||
service: ProjectsUseCases
|
||||
dependencies: ProjectFeatureDependencies
|
||||
events: ProjectEvents = new ProjectEvents()
|
||||
|
||||
constructor(services: AppServices, dependencies: ProjectFeatureDependencies) {
|
||||
super(services);
|
||||
this.projectRepository = new ProjectsRepository(services.db)
|
||||
this.apiKeyRepository = new ApiKeyRepository(services.db)
|
||||
this.service = new ProjectsUseCases({
|
||||
projectRepository: this.projectRepository,
|
||||
projectEvents: this.events,
|
||||
apiKeyRepository: this.apiKeyRepository
|
||||
})
|
||||
this.dependencies = dependencies
|
||||
}
|
||||
}
|
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
56
server/src/core/features/serverAdmin/ServerAdminUseCases.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
import {UserFeature} from "../users";
|
||||
import {AuthenticationFailed} from "../users/exceptions/AuthenticationFailed";
|
||||
|
||||
interface ServerAdminDependencies {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature,
|
||||
metricFeature: MetricFeature,
|
||||
}
|
||||
|
||||
export class ServerAdminUseCases {
|
||||
private _userFeature: UserFeature
|
||||
private _projectFeature: ProjectFeature
|
||||
private _logFeature: LogFeature
|
||||
private _metricFeature: MetricFeature
|
||||
private _systemCaller = new Caller(new SystemCaller())
|
||||
|
||||
constructor(dependencies: ServerAdminDependencies) {
|
||||
this._userFeature = dependencies.userFeature
|
||||
this._projectFeature = dependencies.projectFeature
|
||||
this._logFeature = dependencies.logFeature
|
||||
this._metricFeature = dependencies.metricFeature
|
||||
}
|
||||
|
||||
async getGlobalStats(caller: Caller): Promise<GlobalStats> {
|
||||
if (!caller.isAdmin()) {
|
||||
throw new AuthenticationFailed("User needs to be Admin");
|
||||
}
|
||||
|
||||
const userCount = await this._userFeature.repository.count();
|
||||
const projectCount = await this._projectFeature.projectRepository.count();
|
||||
const logCount = await this._logFeature.logsRepository.count();
|
||||
const metricCount = await this._metricFeature.repository.count();
|
||||
const metricHistoryEntryCount = await this._metricFeature.repository.countHistory();
|
||||
|
||||
return {
|
||||
userCount,
|
||||
projectCount,
|
||||
logCount,
|
||||
metricCount,
|
||||
metricHistoryEntryCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GlobalStats = {
|
||||
userCount: number
|
||||
projectCount: number
|
||||
logCount: number
|
||||
metricCount: number
|
||||
metricHistoryEntryCount: number
|
||||
}
|
31
server/src/core/features/serverAdmin/index.ts
Normal file
31
server/src/core/features/serverAdmin/index.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {LogFeature} from "../logs";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ProjectFeature} from "../projects";
|
||||
import {UserFeature} from "../users";
|
||||
import {ServerAdminUseCases} from "./ServerAdminUseCases";
|
||||
|
||||
export type ServerAdminFeatureDependencies = {
|
||||
userFeature: UserFeature
|
||||
projectFeature: ProjectFeature
|
||||
logFeature: LogFeature
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ServerAdminFeature extends Feature {
|
||||
|
||||
public name = "ActivityLogger"
|
||||
public useCases: ServerAdminUseCases
|
||||
public dependencies: ServerAdminFeatureDependencies
|
||||
|
||||
constructor(services: AppServices, dependencies: ServerAdminFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.useCases = new ServerAdminUseCases({
|
||||
userFeature: this.dependencies.userFeature,
|
||||
projectFeature: this.dependencies.projectFeature,
|
||||
logFeature: this.dependencies.logFeature,
|
||||
metricFeature: this.dependencies.metricFeature
|
||||
})
|
||||
}
|
||||
}
|
12
server/src/core/features/users/UsersEvents.ts
Normal file
12
server/src/core/features/users/UsersEvents.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import {FeatureEvent} from "@app/core/FeatureEvent";
|
||||
import {Caller} from "../projects/entities";
|
||||
import {User} from "./entities";
|
||||
|
||||
export type onUserCreatedData = {
|
||||
user: User
|
||||
caller: Caller
|
||||
}
|
||||
|
||||
export class UserEvents {
|
||||
onMetricCreated: FeatureEvent<onUserCreatedData> = new FeatureEvent()
|
||||
}
|
46
server/src/core/features/users/UsersRepository.ts
Normal file
46
server/src/core/features/users/UsersRepository.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import {UserData} from "./entities/UserData";
|
||||
import {Collection, Db, ObjectId} from "mongodb";
|
||||
import { User } from "./entities";
|
||||
|
||||
export class UsersRepository {
|
||||
|
||||
private _users: Collection<Omit<UserData, "id">>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._users = db.collection("users")
|
||||
}
|
||||
|
||||
async createUser(data: Omit<UserData, "id">): Promise<void> {
|
||||
await this._users.insertOne(data);
|
||||
}
|
||||
|
||||
async findUserById(id: string): Promise<UserData | null> {
|
||||
const userData = await this._users.findOne({_id: new ObjectId(id)});
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return {...userData, id: userData._id.toString()};
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string): Promise<UserData | null> {
|
||||
const userData = await this._users.findOne({email});
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return {...userData, id: userData._id.toString()};
|
||||
}
|
||||
|
||||
async findAll(): Promise<UserData[]> {
|
||||
const userData = await this._users.find().toArray();
|
||||
return userData.map(data => ({...data, id: data._id.toString()}));
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this._users.countDocuments();
|
||||
}
|
||||
|
||||
async updateUser(user: UserData, data: Partial<Omit<UserData, "id">>): Promise<boolean> {
|
||||
const res = await this._users.updateOne({_id: new ObjectId(user.id)}, {$set: data})
|
||||
return res.matchedCount > 0
|
||||
}
|
||||
}
|
109
server/src/core/features/users/UsersUseCases.ts
Normal file
109
server/src/core/features/users/UsersUseCases.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {User} from "./entities/User";
|
||||
import {UsersRepository} from "./UsersRepository";
|
||||
import argon2 from "argon2";
|
||||
import {AuthenticationFailed} from "./exceptions/AuthenticationFailed";
|
||||
import {getAccountCreationLimit, isRegistrationEnabled} from "./utils";
|
||||
import {SignupFailed, SignupFailedCode} from "./exceptions/SignupFailed";
|
||||
import {UserEvents} from "./UsersEvents";
|
||||
import {Caller, SystemCaller} from "../projects/entities/Caller";
|
||||
|
||||
interface UsersUseCasesDependencies {
|
||||
repository: UsersRepository,
|
||||
events: UserEvents
|
||||
}
|
||||
|
||||
export class UsersUseCases {
|
||||
private _repository: UsersRepository
|
||||
private _events: UserEvents
|
||||
|
||||
constructor(dependencies: UsersUseCasesDependencies) {
|
||||
this._repository = dependencies.repository
|
||||
this._events = dependencies.events
|
||||
}
|
||||
|
||||
// [TODO] This function doesn't really belong here, make a new feature (ex: reporter)
|
||||
async getGlobalInfo(adminToken: string) {
|
||||
if (adminToken !== process.env.API_ADMIN_AUTHORIZATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
users: (await this._repository.findAll()).map(user => ({
|
||||
...user,
|
||||
createdAt: new Date(user.createdAt),
|
||||
password: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async adminChangeUserPassword(caller: Caller, email: string, newPassword: string) {
|
||||
if (!caller.isAdmin()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this._repository.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new SignupFailed("Target user not found", SignupFailedCode.Unknown)
|
||||
}
|
||||
|
||||
return this._repository.updateUser(user, {password: await argon2.hash(newPassword)})
|
||||
}
|
||||
|
||||
async signupUser(email: string, password: string, adminToken?: string) {
|
||||
if (!isRegistrationEnabled(adminToken)) {
|
||||
throw new SignupFailed("User signup not enabled", SignupFailedCode.NotEnabled)
|
||||
}
|
||||
|
||||
if (await this._repository.count() >= getAccountCreationLimit()) {
|
||||
throw new SignupFailed(`Maximum account limit (${getAccountCreationLimit()}) reached`, SignupFailedCode.LimitReached)
|
||||
}
|
||||
|
||||
const user = await this._repository.findUserByEmail(email);
|
||||
if (user) {
|
||||
throw new SignupFailed("Email is already taken", SignupFailedCode.EmailAlreadyInUse)
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await this._repository.createUser({
|
||||
email,
|
||||
password: passwordHash,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
const createdUser = await this._repository.findUserByEmail(email)
|
||||
|
||||
if (!createdUser) {
|
||||
throw new SignupFailed("Error in user creation", SignupFailedCode.Unknown)
|
||||
}
|
||||
const caller = new Caller(new SystemCaller())
|
||||
await this._events.onMetricCreated.emit({user: new User(createdUser), caller})
|
||||
|
||||
/*if (!process.env.TEST) {*/
|
||||
/*await mailService.SendMail(*/
|
||||
/*`Confirm your email on Cronarium : https://my.awary.com/signup-email-confirmation?email=${email}&emailConfirmationToken=${createdUser?.GetDocument().emailConfirmationToken}`,*/
|
||||
/*[email]);*/
|
||||
/*}*/
|
||||
}
|
||||
|
||||
async logUser(email: string, password: string): Promise<User> {
|
||||
const userData = await this._repository.findUserByEmail(email);
|
||||
if (!userData) {
|
||||
throw new AuthenticationFailed("Wrong email or password")
|
||||
}
|
||||
const user = new User(userData);
|
||||
if (!await user.verifyPassword(password)) {
|
||||
throw new AuthenticationFailed("Wrong email or password")
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const userData = await this._repository.findUserByEmail(email);
|
||||
if (!userData) {
|
||||
return null;
|
||||
}
|
||||
return new User(userData);
|
||||
}
|
||||
}
|
26
server/src/core/features/users/entities/User.ts
Normal file
26
server/src/core/features/users/entities/User.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {UserData} from "./UserData";
|
||||
import argon2 from "argon2"
|
||||
|
||||
export interface User extends Readonly<UserData> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export class User {
|
||||
constructor(data: UserData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
async verifyPassword(password: string): Promise<boolean> {
|
||||
return this.password ? argon2.verify(this.password, password) : false;
|
||||
}
|
||||
|
||||
getState(): UserData {
|
||||
return {
|
||||
id: this.id,
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
7
server/src/core/features/users/entities/UserData.ts
Normal file
7
server/src/core/features/users/entities/UserData.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface UserData {
|
||||
id: string
|
||||
email: string
|
||||
password: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
2
server/src/core/features/users/entities/index.ts
Normal file
2
server/src/core/features/users/entities/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./User"
|
||||
export * from "./UserData"
|
|
@ -0,0 +1,3 @@
|
|||
export class AuthenticationFailed extends Error {
|
||||
|
||||
}
|
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
12
server/src/core/features/users/exceptions/SignupFailed.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export enum SignupFailedCode {
|
||||
NotEnabled,
|
||||
EmailAlreadyInUse,
|
||||
LimitReached,
|
||||
Unknown
|
||||
}
|
||||
|
||||
export class SignupFailed extends Error {
|
||||
constructor(message: string, public errorCode: SignupFailedCode) {
|
||||
super(message)
|
||||
}
|
||||
}
|
19
server/src/core/features/users/index.ts
Normal file
19
server/src/core/features/users/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {UserEvents} from "./UsersEvents";
|
||||
import {UsersRepository} from "./UsersRepository";
|
||||
import {UsersUseCases} from "./UsersUseCases";
|
||||
|
||||
export class UserFeature extends Feature {
|
||||
|
||||
name = "User"
|
||||
repository: UsersRepository
|
||||
useCases: UsersUseCases
|
||||
events: UserEvents
|
||||
|
||||
constructor(services: AppServices) {
|
||||
super(services);
|
||||
this.repository = new UsersRepository(services.db)
|
||||
this.events = new UserEvents()
|
||||
this.useCases = new UsersUseCases({repository: this.repository, events: this.events})
|
||||
}
|
||||
}
|
22
server/src/core/features/users/utils.ts
Normal file
22
server/src/core/features/users/utils.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export function isRegistrationEnabled(adminToken?: string): boolean {
|
||||
if (adminToken && adminToken === process.env.API_ADMIN_AUTHORIZATION) {
|
||||
return true;
|
||||
}
|
||||
return process.env.ENABLE_USER_REGISTRATION === "true";
|
||||
}
|
||||
|
||||
export function getAccountCreationLimit(): number {
|
||||
return process.env.MAX_ACCOUNT ? parseInt(process.env.MAX_ACCOUNT) : 9999999;
|
||||
}
|
||||
|
||||
export function getAccountProjectsLimit(): number {
|
||||
return process.env.MAX_PROJECT_PER_ACCOUNT ? parseInt(process.env.MAX_PROJECT_PER_ACCOUNT) : 9999999;
|
||||
}
|
||||
|
||||
export function getMetricsUpdateLimit(): number {
|
||||
return process.env.METRICS_MAX_UPDATE_PER_MINUTE ? parseInt(process.env.METRICS_MAX_UPDATE_PER_MINUTE) : 9999999;
|
||||
}
|
||||
|
||||
export function getMetricsHistoryLimit(): number {
|
||||
return process.env.METRICS_HISTORY_LENGTH ? parseInt(process.env.METRICS_HISTORY_LENGTH) : 9999999;
|
||||
}
|
81
server/src/core/features/views/ViewsRepository.ts
Normal file
81
server/src/core/features/views/ViewsRepository.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import {Collection, Db, ObjectId, WithId} from "mongodb";
|
||||
import {Project} from "../projects/entities";
|
||||
import {View, ViewProvider} from "./entities";
|
||||
|
||||
interface ViewDocument {
|
||||
projectId: ObjectId
|
||||
type: string
|
||||
name: string
|
||||
provider: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
interface ViewProperties {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
function viewDocumentToEntity(document: WithId<ViewDocument>): View {
|
||||
return new View({
|
||||
id: document._id.toString(),
|
||||
projectId: document.projectId.toString(),
|
||||
type: document.type,
|
||||
name: document.name,
|
||||
provider: document.provider as ViewProvider,
|
||||
config: document.config
|
||||
})
|
||||
}
|
||||
|
||||
export class ViewsRepository {
|
||||
|
||||
private _views: Collection<ViewDocument>
|
||||
|
||||
constructor(db: Db) {
|
||||
this._views = db.collection("projectsViews")
|
||||
}
|
||||
|
||||
async createView(viewData: ViewProperties): Promise<View> {
|
||||
const viewDocument = await this._views.insertOne({
|
||||
...viewData,
|
||||
projectId: new ObjectId(viewData.projectId)
|
||||
});
|
||||
return viewDocumentToEntity({
|
||||
_id: viewDocument.insertedId,
|
||||
projectId: new ObjectId(viewData.projectId),
|
||||
type: viewData.type,
|
||||
name: viewData.name,
|
||||
provider: viewData.provider,
|
||||
config: viewData.config
|
||||
})
|
||||
}
|
||||
|
||||
async updateView(view: View, properties: Partial<ViewProperties>): Promise<void> {
|
||||
await this._views.updateOne({_id: new ObjectId(view.id)}, {
|
||||
$set: {
|
||||
name: properties.name,
|
||||
config: properties.config
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(project: Project, filter: Partial<ViewProperties> = {}): Promise<View[]> {
|
||||
const viewDocuments = await this._views.find({...filter, projectId: new ObjectId(project.id)}).toArray()
|
||||
return viewDocuments.map(viewDocumentToEntity)
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<View | null> {
|
||||
|
||||
const viewDocument = await this._views.findOne({_id: new ObjectId(id)})
|
||||
if (!viewDocument) {
|
||||
return null;
|
||||
}
|
||||
return viewDocumentToEntity(viewDocument);
|
||||
}
|
||||
|
||||
async deleteView(view: View): Promise<void> {
|
||||
await this._views.deleteOne({_id: new ObjectId(view.id)})
|
||||
}
|
||||
}
|
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
58
server/src/core/features/views/ViewsUseCases.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import {ProjectAuthorization, ProjectContext} from "../projects/ProjectContext";
|
||||
import {View, ViewProvider} from "./entities";
|
||||
import {ViewsRepository} from "./ViewsRepository";
|
||||
|
||||
interface ViewsUseCasesDependencies {
|
||||
viewsRepository: ViewsRepository
|
||||
}
|
||||
|
||||
interface ViewDataUpdate {
|
||||
name: string
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface ViewCreationProperties {
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export class ViewsUseCases {
|
||||
private _viewsRepository: ViewsRepository
|
||||
|
||||
constructor(dependencies: ViewsUseCasesDependencies) {
|
||||
this._viewsRepository = dependencies.viewsRepository
|
||||
}
|
||||
|
||||
async createView(context: ProjectContext, viewData: ViewCreationProperties): Promise<View> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
const {project} = context;
|
||||
|
||||
const view = await this._viewsRepository.createView({
|
||||
projectId: project.id,
|
||||
name: viewData.name,
|
||||
type: viewData.type,
|
||||
provider: viewData.provider,
|
||||
config: viewData.config
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
async updateView(context: ProjectContext, view: View, data: ViewDataUpdate): Promise<void> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Write])
|
||||
await this._viewsRepository.updateView(view, data)
|
||||
}
|
||||
|
||||
async getAllViews(context: ProjectContext): Promise<View[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._viewsRepository.findAll(context.project);
|
||||
}
|
||||
|
||||
async getViewsByType(context: ProjectContext, type: string): Promise<View[]> {
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
|
||||
return await this._viewsRepository.findAll(context.project, {type});
|
||||
}
|
||||
}
|
53
server/src/core/features/views/entities/View.ts
Normal file
53
server/src/core/features/views/entities/View.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {Identifiable} from "@app/core/Identifiable"
|
||||
|
||||
export enum ViewProvider {
|
||||
Web = "Web",
|
||||
Custom = "Custom"
|
||||
}
|
||||
|
||||
export interface ViewCreationProperties {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface ViewConstructor extends Identifiable {
|
||||
projectId: string
|
||||
type: string
|
||||
name: string
|
||||
provider: ViewProvider
|
||||
config: unknown
|
||||
}
|
||||
|
||||
export interface WebViewProvider {
|
||||
panels: {
|
||||
name: string
|
||||
order: number
|
||||
metrics: {
|
||||
metricId: string
|
||||
order: number
|
||||
config: unknown
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export class View implements ViewConstructor {
|
||||
|
||||
readonly id: string
|
||||
readonly projectId: string
|
||||
readonly name: string
|
||||
readonly type: string
|
||||
readonly provider: ViewProvider
|
||||
readonly config: unknown
|
||||
|
||||
constructor(data: ViewConstructor) {
|
||||
this.id = data.id
|
||||
this.projectId = data.projectId
|
||||
this.type = data.type
|
||||
this.name = data.name
|
||||
this.provider = data.provider
|
||||
this.config = data.config
|
||||
}
|
||||
}
|
1
server/src/core/features/views/entities/index.ts
Normal file
1
server/src/core/features/views/entities/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./View"
|
28
server/src/core/features/views/index.ts
Normal file
28
server/src/core/features/views/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import {Feature, AppServices} from "@app/core/Feature";
|
||||
import {MetricFeature} from "../metrics";
|
||||
import {ViewsRepository} from "./ViewsRepository";
|
||||
import {ViewsUseCases} from "./ViewsUseCases";
|
||||
|
||||
export * from "./entities"
|
||||
|
||||
export type ViewsFeatureDependencies = {
|
||||
metricFeature: MetricFeature
|
||||
}
|
||||
|
||||
export class ViewsFeature extends Feature {
|
||||
|
||||
name = "Views"
|
||||
dependencies:ViewsFeatureDependencies
|
||||
|
||||
repository: ViewsRepository
|
||||
useCases: ViewsUseCases
|
||||
|
||||
constructor(services: AppServices, dependencies: ViewsFeatureDependencies) {
|
||||
super(services);
|
||||
this.dependencies = dependencies
|
||||
this.repository = new ViewsRepository(services.db)
|
||||
this.useCases = new ViewsUseCases({
|
||||
viewsRepository: this.repository
|
||||
})
|
||||
}
|
||||
}
|
1
server/src/core/index.ts
Normal file
1
server/src/core/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./App"
|
143
server/src/http/HttpServer.ts
Normal file
143
server/src/http/HttpServer.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import fastifyCors from "@fastify/cors";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import {TypeBoxTypeProvider} from "@fastify/type-provider-typebox";
|
||||
import {App} from "@app/core";
|
||||
import {Caller} from "@app/core/features/projects/entities/Caller";
|
||||
import {Project} from "@app/core/features/projects/entities/Project";
|
||||
import {MissingAuthorization} from "@app/core/features/projects/exceptions/MissingAuthorization";
|
||||
import {MissingResource} from "@app/core/features/projects/exceptions/MissingResource";
|
||||
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||
import {User} from "@app/core/features/users/entities/User";
|
||||
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||
import {SignupFailed, SignupFailedCode} from "@app/core/features/users/exceptions/SignupFailed";
|
||||
import fastify, {FastifyInstance, FastifyLoggerInstance, InjectOptions, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault} from "fastify";
|
||||
import {Logger} from "utils/logger";
|
||||
import {projectsRoutes, usersRoutes, logsRoutes, metricsRoutes} from "./routes";
|
||||
import {viewsRoutes} from "./routes/views.routes";
|
||||
import {LimitReached} from "@app/core/exceptions/LimitReached";
|
||||
import {adminRoutes} from "./routes/admin.routes";
|
||||
|
||||
export type FastifyTypebox = FastifyInstance<
|
||||
RawServerDefault,
|
||||
RawRequestDefaultExpression<RawServerDefault>,
|
||||
RawReplyDefaultExpression<RawServerDefault>,
|
||||
FastifyLoggerInstance,
|
||||
TypeBoxTypeProvider
|
||||
>;
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { email: string } // payload type is used for signing and verifying
|
||||
user: { email: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
data: { // [TODO] This file shouldn't know about these classes
|
||||
user: User
|
||||
caller: Caller
|
||||
project: Project
|
||||
context: ProjectContext
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpServer {
|
||||
|
||||
server: FastifyTypebox
|
||||
app: App
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.server = fastify({
|
||||
ajv: {
|
||||
customOptions: {
|
||||
strict: 'log',
|
||||
keywords: ['kind', 'modifier']
|
||||
}
|
||||
}
|
||||
}).withTypeProvider<TypeBoxTypeProvider>();
|
||||
}
|
||||
|
||||
async listen() {
|
||||
this.server.listen({host: process.env.SERVER_HOST || "0.0.0.0", port: parseInt(process.env.SERVER_PORT || "8080")}, (err, address) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Server listening at ${address}`);
|
||||
});
|
||||
}
|
||||
|
||||
async inject(data: InjectOptions) {
|
||||
return this.server.inject(data);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw Error("Missing JWT_SECRET env var");
|
||||
}
|
||||
|
||||
await this.server.register(fastifyCors);
|
||||
await this.server.register(fastifyJwt, {secret: process.env.JWT_SECRET});
|
||||
|
||||
this.server.addHook("onSend", (req, reply, payload, done) => {
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
done();
|
||||
return ;
|
||||
}
|
||||
|
||||
if (reply.statusCode >= 200 && reply.statusCode < 300)
|
||||
Logger.success(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
else if (reply.statusCode >= 500)
|
||||
Logger.error(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
else
|
||||
Logger.warn(`${req.method} ${reply.statusCode} ${req.url}`);
|
||||
done();
|
||||
});
|
||||
|
||||
this.server.setErrorHandler((err, req, reply) => {
|
||||
let statusCode
|
||||
if (err instanceof AuthenticationFailed) {
|
||||
statusCode = 401
|
||||
} else if (err instanceof SignupFailed) {
|
||||
statusCode = 500
|
||||
if (err.errorCode === SignupFailedCode.EmailAlreadyInUse) {
|
||||
statusCode = 409
|
||||
} else if (err.errorCode === SignupFailedCode.NotEnabled) {
|
||||
statusCode = 401
|
||||
}
|
||||
} else if (err instanceof MissingAuthorization) {
|
||||
statusCode = 401
|
||||
} else if (err instanceof MissingResource) {
|
||||
statusCode = 404;
|
||||
} else if (err instanceof LimitReached) {
|
||||
statusCode = 422;
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
reply.status(statusCode).send({error: err.message})
|
||||
} else {
|
||||
reply.send(err)
|
||||
}
|
||||
})
|
||||
|
||||
this.server.addHook("onError", (req, reply, error, done) => {
|
||||
if (!error.validation) {
|
||||
Logger.error(error);
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
this.server.register(usersRoutes(this.app));
|
||||
this.server.register(projectsRoutes(this.app));
|
||||
this.server.register(logsRoutes(this.app));
|
||||
this.server.register(metricsRoutes(this.app));
|
||||
this.server.register(viewsRoutes(this.app));
|
||||
this.server.register(adminRoutes(this.app));
|
||||
|
||||
return this.server;
|
||||
}
|
||||
}
|
1
server/src/http/index.ts
Normal file
1
server/src/http/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./HttpServer"
|
20
server/src/http/routes/admin.routes.ts
Normal file
20
server/src/http/routes/admin.routes.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
|
||||
export function adminRoutes(app: App) {
|
||||
const adminUseCases = app.serverAdminFeature.useCases
|
||||
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/admin/server-stats",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
return adminUseCases.getGlobalStats(caller);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
146
server/src/http/routes/admin.test.ts
Normal file
146
server/src/http/routes/admin.test.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import {ADMIN_TOKEN, getAdminSucessTagId} from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Admin", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1Id: string
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
users = await setupNewUsers(server.server);
|
||||
users[2].SetAuthorization(ADMIN_TOKEN);
|
||||
});
|
||||
|
||||
it ("Return error 401 if not using the ADMIN_TOKEN", async function() {
|
||||
const resGet = await users[1].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Return 0 projects, 3 users, 0 logs, 0 metrics on initial state", async function() {
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 0,
|
||||
logCount: 0,
|
||||
metricCount: 0,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
describe("Server stats", function() {
|
||||
|
||||
beforeEach(async function () {
|
||||
// Create basic projects
|
||||
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"}); // Yep, don't need all projects
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
project1Id = resAddProject1.body.id;
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 3 logs, 0 metrics", async function() {
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 3,
|
||||
metricCount: 0,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 4 logs, 1 metrics", async function() {
|
||||
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 1"
|
||||
})
|
||||
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
expect(resGet.statusCode).to.deep.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 4,
|
||||
metricCount: 1,
|
||||
metricHistoryEntryCount: 0
|
||||
})
|
||||
});
|
||||
|
||||
it ("Return 3 projects, 3 users, 5 logs, 2 metrics, 3 metric history entries", async function() {
|
||||
await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 1"
|
||||
})
|
||||
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1Id}/metrics`, {
|
||||
name: "metric 2"
|
||||
})
|
||||
|
||||
const metricId = resAddMetric.body.id;
|
||||
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 1});
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 2});
|
||||
await users[0].Post(`/projects/${project1Id}/metrics/${metricId}`, {value: 3});
|
||||
|
||||
const resGet = await users[2].Get(`/admin/server-stats`);
|
||||
expect(resGet.statusCode).to.deep.equals(200);
|
||||
expect(resGet.body).to.deep.equals({
|
||||
userCount: 3,
|
||||
projectCount: 3,
|
||||
logCount: 5,
|
||||
metricCount: 2,
|
||||
metricHistoryEntryCount: 3
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
describe("Internal log to admin project", function() {
|
||||
|
||||
beforeEach(async function () {
|
||||
const resAddProject1 = await users[0].Post("/projects", {name: "p1"});
|
||||
project1Id = resAddProject1.body.id;
|
||||
const resAddTag = await users[0].Post(`/projects/${project1Id}/tags`, {
|
||||
name: "Success",
|
||||
color: "#00ff00"
|
||||
});
|
||||
process.env.ADMIN_PROJECT_ID = project1Id
|
||||
process.env.ADMIN_SUCCESS_TAG_ID = resAddTag.body.id
|
||||
});
|
||||
|
||||
it("Add log when an user is created", async function() {
|
||||
const resGetLogs1 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||
expect(resGetLogs1.statusCode).to.equals(200);
|
||||
expect(resGetLogs1.body).to.have.length(1);
|
||||
|
||||
const userEmail = "user.email@something.happy"
|
||||
const resAddUser = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: userEmail,
|
||||
password: "SomePassword",
|
||||
}
|
||||
});
|
||||
expect(resAddUser.statusCode).to.equals(201);
|
||||
|
||||
const resGetLogs2 = await users[0].Get(`/projects/${project1Id}/logs`);
|
||||
expect(resGetLogs2.statusCode).to.equals(200);
|
||||
expect(resGetLogs2.body).to.have.length(2);
|
||||
expect(resGetLogs2.body[0].title).to.equals(`New user: ${userEmail}`);
|
||||
expect(resGetLogs2.body[0].tags).to.have.length(1);
|
||||
expect(resGetLogs2.body[0].tags[0].id).to.equals(getAdminSucessTagId());
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
delete process.env.ADMIN_PROJECT_ID
|
||||
delete process.env.ADMIN_SUCCESS_TAG_ID
|
||||
})
|
||||
})
|
||||
});
|
4
server/src/http/routes/index.ts
Normal file
4
server/src/http/routes/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./users.routes";
|
||||
export * from "./projects.routes";
|
||||
export * from "./logs.routes";
|
||||
export * from "./metrics.routes";
|
36
server/src/http/routes/logs.def.ts
Normal file
36
server/src/http/routes/logs.def.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateLogBody = Type.Object({
|
||||
title: Type.String(),
|
||||
content: Type.String(),
|
||||
tags: Type.Array(Type.String({minLength: 24, maxLength: 24}), {maxItems: 2})
|
||||
});
|
||||
|
||||
export const CreateLogParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteLogParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
logId: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateTagBody = Type.Object({
|
||||
name: Type.String(),
|
||||
color: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateTagBody = Type.Object({
|
||||
name: Type.String(),
|
||||
color: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateTagParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
tagId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteTagParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
tagId: Type.String(),
|
||||
});
|
121
server/src/http/routes/logs.routes.ts
Normal file
121
server/src/http/routes/logs.routes.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {CreateLogBody, CreateLogParams, CreateTagBody, DeleteLogParams, DeleteTagParams, UpdateTagBody, UpdateTagParams} from "./logs.def";
|
||||
import {LogsUseCases} from "@app/core/features/logs/LogsUseCases";
|
||||
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
|
||||
export type MetricRoutesDependencies = {
|
||||
logService: LogsUseCases,
|
||||
projectService: ProjectsUseCases,
|
||||
userService: UsersUseCases
|
||||
}
|
||||
|
||||
export function logsRoutes(app: App) {
|
||||
const logUseCases = app.logFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/projects/:projectId/logs",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const logs = await logUseCases.getLogs(context);
|
||||
const tags = await logUseCases.getTags(context);
|
||||
const formattedLogs = logs.map(log => ({
|
||||
...log,
|
||||
tags: log.tags.map(logTag => tags.find(tag => tag.id === logTag)).filter(t => t)
|
||||
}))
|
||||
reply.status(200).send(formattedLogs);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateLogBody>, Params: Static<typeof CreateLogParams>}>(
|
||||
"/projects/:projectId/logs",
|
||||
{
|
||||
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||
schema: {body: CreateLogBody, params: CreateLogParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {title, content, tags} = request.body;
|
||||
await logUseCases.addLog(context, {title, content, tags})
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteLogParams>}>(
|
||||
"/projects/:projectId/logs/:logId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteLogParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {logId} = request.params
|
||||
const log = await logUseCases.getLogById(context, logId)
|
||||
await logUseCases.deleteLog(context, log);
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects/:projectId/tags",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const tags = await logUseCases.getTags(context);
|
||||
reply.status(200).send(tags);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateTagBody>}>(
|
||||
"/projects/:projectId/tags",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateTagBody}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name, color} = request.body;
|
||||
const tag = await logUseCases.createTag(context, {name, color})
|
||||
reply.status(201).send({id: tag.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateTagBody>, Params: Static<typeof UpdateTagParams>}>(
|
||||
"/projects/:projectId/tags/:tagId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateTagBody, params: UpdateTagParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name, color} = request.body;
|
||||
const {tagId} = request.params;
|
||||
const tag = await logUseCases.getTag(context, tagId);
|
||||
await logUseCases.updateTag(context, tag, {name, color})
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteTagParams>}>(
|
||||
"/projects/:projectId/tags/:tagId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteTagParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {tagId} = request.params
|
||||
const tag = await logUseCases.getTag(context, tagId)
|
||||
await logUseCases.deleteTag(context, tag);
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
129
server/src/http/routes/logs.test.ts
Normal file
129
server/src/http/routes/logs.test.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Logs", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1: Record<string, unknown>
|
||||
let project1ApiKey1: TestApiKey
|
||||
|
||||
let project2: Record<string, unknown>
|
||||
let project2ApiKey1: TestApiKey
|
||||
|
||||
beforeEach(async function () {
|
||||
// Setup the server and app
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
// Create basic users
|
||||
users = await setupNewUsers(server.server);
|
||||
// Create basic projects
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
project1 = response.body[0];
|
||||
project2 = response.body[1];
|
||||
|
||||
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
expect(response.body[0].name).to.equals("key1")
|
||||
expect(response.body[0].projectId).to.equals(project1.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
expect(response.body[1].name).to.equals("key2")
|
||||
expect(response.body[1].projectId).to.equals(project1.id)
|
||||
expect(response.body[1].key).to.not.be.undefined
|
||||
|
||||
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
new TestApiKey(server.server, response.body[1].key)
|
||||
|
||||
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
expect(response.body[0].name).to.equals("key3")
|
||||
expect(response.body[0].projectId).to.equals(project2.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
|
||||
project2ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
});
|
||||
|
||||
it ("[User 1] Get logs with just the welcome log", async function() {
|
||||
const response = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Get logs with just the welcome log", async function() {
|
||||
const response = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Add log to p1", async function() {
|
||||
const resAddLog = await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||
title: "title1",
|
||||
content: "content1",
|
||||
tags: []
|
||||
});
|
||||
expect(resAddLog.statusCode).to.equals(201);
|
||||
|
||||
const resGetLogs = await users[0].Get(`/projects/${project1.id}/logs`);
|
||||
expect(resGetLogs.statusCode).to.equals(200);
|
||||
expect(resGetLogs.body).to.be.a("array");
|
||||
expect(resGetLogs.body).to.have.length(2);
|
||||
});
|
||||
|
||||
it ("[K1-1 > P1] Get logs with 2 entries", async function() {
|
||||
await project1ApiKey1.Post(`/projects/${project1.id}/logs`, {
|
||||
title: "title1",
|
||||
content: "content1",
|
||||
tags: []
|
||||
});
|
||||
|
||||
const resGetLogs = await project1ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
expect(resGetLogs.statusCode).to.equals(200);
|
||||
expect(resGetLogs.body).to.be.a("array");
|
||||
expect(resGetLogs.body).to.have.length(2);
|
||||
});
|
||||
|
||||
it ("[K2-1 > P1] Return error (401)", async function() {
|
||||
const response = await project2ApiKey1.Get(`/projects/${project1.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("[K2-1 > P2] Get logs with just the welcome log", async function() {
|
||||
const response = await project2ApiKey1.Get(`/projects/${project2.id}/logs`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
});
|
34
server/src/http/routes/metrics.def.ts
Normal file
34
server/src/http/routes/metrics.def.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateMetricBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateMetricParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateMetricBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateMetricParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String(),
|
||||
});
|
||||
|
||||
export const SetMetricValueBody = Type.Object({
|
||||
value: Type.Number(),
|
||||
date: Type.Optional(Type.Number())
|
||||
});
|
||||
|
||||
export const SetMetricValueParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String()
|
||||
});
|
||||
|
||||
export const DeleteHistoryRecordParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
metricId: Type.String(),
|
||||
recordId: Type.String()
|
||||
});
|
124
server/src/http/routes/metrics.routes.ts
Normal file
124
server/src/http/routes/metrics.routes.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {App} from "@app/core";
|
||||
import {SetMetricValueBody, SetMetricValueParams, CreateMetricBody, CreateMetricParams, UpdateMetricBody, UpdateMetricParams, DeleteHistoryRecordParams} from "./metrics.def";
|
||||
import {ProjectsUseCases} from "@app/core/features/projects/ProjectsUseCases";
|
||||
import {UsersUseCases} from "@app/core/features/users/UsersUseCases";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
import {MetricsUseCases} from "@app/core/features/metrics";
|
||||
|
||||
export type MetricRouteDependencies = {
|
||||
metricService: MetricsUseCases,
|
||||
projectService: ProjectsUseCases,
|
||||
userService: UsersUseCases
|
||||
}
|
||||
|
||||
export function metricsRoutes(app: App) {
|
||||
const metricsUseCases = app.metricFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get("/projects/:projectId/metrics",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const metrics = await metricsUseCases.getAllMetrics(context, true);
|
||||
reply.status(200).send(metrics);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateMetricBody>, Params: Static<typeof CreateMetricParams>}>(
|
||||
"/projects/:projectId/metrics",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateMetricBody, params: CreateMetricParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name} = request.body;
|
||||
const metric = await metricsUseCases.createMetric(context, {name})
|
||||
reply.status(201).send({id: metric.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateMetricBody>, Params: Static<typeof UpdateMetricParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateMetricBody, params: UpdateMetricParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const {name} = request.body;
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
if (!metric) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
await metricsUseCases.updateMetric(context, metric, {name});
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.get<{Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId, true)
|
||||
reply.status(200).send(metric);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof SetMetricValueBody>, Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [rateLimit(1, 1000), withData(app, [AppData.Context])],
|
||||
schema: {body: SetMetricValueBody, params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const {value, date} = request.body;
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.setMetricValue(context, metric, value, date);
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof SetMetricValueParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: SetMetricValueParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.deleteMetric(context, metric)
|
||||
reply.status(200).send();
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteHistoryRecordParams>}>(
|
||||
"/projects/:projectId/metrics/:metricId/history/:recordId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteHistoryRecordParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {metricId, recordId} = request.params
|
||||
const metric = await metricsUseCases.getMetricById(context, metricId)
|
||||
await metricsUseCases.deleteMetricHistoryRecord(context, metric, recordId);
|
||||
reply.status(200).send({});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
166
server/src/http/routes/metrics.test.ts
Normal file
166
server/src/http/routes/metrics.test.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import {sleep} from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestApiKey, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Metrics", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
let project1: Record<string, unknown>
|
||||
let project1ApiKey1: TestApiKey
|
||||
|
||||
let project2: Record<string, unknown>
|
||||
|
||||
beforeEach(async function () {
|
||||
// Setup the server and app
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
// Create basic users
|
||||
users = await setupNewUsers(server.server);
|
||||
// Create basic projects
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
await users[1].Post("/projects", {name: "p3"});
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
project1 = response.body[0];
|
||||
project2 = response.body[1];
|
||||
|
||||
// Add 2 Api keys for project 1 and 1 Api ket for project 2
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project1.id}/apiKeys`, {name: "key2"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Post(`/projects/${project2.id}/apiKeys`, {name: "key3"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
response = await users[0].Get(`/projects/${project1.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
expect(response.body[0].name).to.equals("key1")
|
||||
expect(response.body[0].projectId).to.equals(project1.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
expect(response.body[1].name).to.equals("key2")
|
||||
expect(response.body[1].projectId).to.equals(project1.id)
|
||||
expect(response.body[1].key).to.not.be.undefined
|
||||
|
||||
project1ApiKey1 = new TestApiKey(server.server, response.body[0].key)
|
||||
new TestApiKey(server.server, response.body[1].key)
|
||||
|
||||
response = await users[0].Get(`/projects/${project2.id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
expect(response.body[0].name).to.equals("key3")
|
||||
expect(response.body[0].projectId).to.equals(project2.id)
|
||||
expect(response.body[0].key).to.not.be.undefined
|
||||
});
|
||||
|
||||
it ("Return empty metric list", async function() {
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(0);
|
||||
});
|
||||
|
||||
it ("Return error 400 on bad payload", async function() {
|
||||
let resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {});
|
||||
|
||||
expect(resGet.statusCode).to.equals(400);
|
||||
|
||||
resGet = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
type: "numeric"
|
||||
});
|
||||
|
||||
expect(resGet.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it ("Add a metric", async function() {
|
||||
const resAdd = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
expect(resAdd.statusCode).to.equals(201);
|
||||
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("Get metrics with [Api Key 1]", async function() {
|
||||
await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const response = await project1ApiKey1.Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(1);
|
||||
});
|
||||
|
||||
it ("Set value of a metric as [Api Key 1]", async function() {
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const metricId = resAddMetric.body.id
|
||||
const resSetValue = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 42
|
||||
});
|
||||
expect(resSetValue.statusCode).to.equals(201);
|
||||
|
||||
const resGet = await users[0].Get(`/projects/${project1.id}/metrics`);
|
||||
|
||||
expect(resGet.statusCode).to.equals(200);
|
||||
expect(resGet.body).to.be.a("array");
|
||||
expect(resGet.body).to.have.length(1);
|
||||
expect(resGet.body[0].history).to.have.length(1);
|
||||
expect(resGet.body[0].history[0].value).to.equals(42);
|
||||
});
|
||||
|
||||
it ("Simple rate limit test for [Api Key 1]", async function() {
|
||||
process.env.RATE_LIMIT_ENABLED = 'true'
|
||||
const resAddMetric = await users[0].Post(`/projects/${project1.id}/metrics`, {
|
||||
name: "metric 1",
|
||||
type: "numeric"
|
||||
});
|
||||
const metricId = resAddMetric.body.id
|
||||
const response1 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 42
|
||||
});
|
||||
|
||||
expect(response1.statusCode).to.equals(201);
|
||||
|
||||
const response2 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 43
|
||||
});
|
||||
|
||||
expect(response2.statusCode).to.equals(429);
|
||||
|
||||
await sleep(1100)
|
||||
|
||||
const response3 = await project1ApiKey1.Post(`/projects/${project1.id}/metrics/${metricId}`, {
|
||||
value: 44
|
||||
});
|
||||
|
||||
expect(response3.statusCode).to.equals(201);
|
||||
|
||||
process.env.RATE_LIMIT_ENABLED = 'false'
|
||||
});
|
||||
});
|
18
server/src/http/routes/projects.def.ts
Normal file
18
server/src/http/routes/projects.def.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateProjectBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateApiKeyBody = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateApiKeyParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const DeleteApiKeyParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
apiKeyId: Type.String(),
|
||||
});
|
90
server/src/http/routes/projects.routes.ts
Normal file
90
server/src/http/routes/projects.routes.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {CreateApiKeyBody, CreateApiKeyParams, CreateProjectBody, DeleteApiKeyParams} from "./projects.def";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
import {ProjectAuthorization} from "@app/core/features/projects/ProjectContext";
|
||||
|
||||
export function projectsRoutes(app: App) {
|
||||
const projectService = app.projectFeature.service
|
||||
|
||||
return async (server: FastifyInstance) => {
|
||||
server.post<{Body: Static<typeof CreateProjectBody>}>("/projects",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])],
|
||||
schema: {body: CreateProjectBody}
|
||||
},
|
||||
async (request, reply) => {
|
||||
const {name} = request.body;
|
||||
const {caller} = request.data;
|
||||
|
||||
const project = await projectService.createProject(caller.asUser(), name);
|
||||
|
||||
reply.status(201).send({id: project.id});
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
const projects = await projectService.getProjectsOfUser(caller.asUser());
|
||||
return projects.map(project => project.getState())
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/projects/:projectId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request) {
|
||||
const {context} = request.data;
|
||||
context.enforceAuthorizations([ProjectAuthorization.Read])
|
||||
return context.project.getState();
|
||||
});
|
||||
|
||||
|
||||
|
||||
server.get("/projects/:projectId/apiKeys",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])]
|
||||
},
|
||||
async function (request) {
|
||||
const {context} = request.data;
|
||||
const apiKeys = projectService.getApiKeysOfProject(context);
|
||||
return apiKeys
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateApiKeyBody>, Params: Static<typeof CreateApiKeyParams>}>(
|
||||
"/projects/:projectId/apiKeys",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateApiKeyBody, params: CreateApiKeyParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {name} = request.body;
|
||||
const apiKey = await projectService.generateApiKey(context, name);
|
||||
reply.status(201).send({apiKey});
|
||||
}
|
||||
);
|
||||
|
||||
server.delete<{Params: Static<typeof DeleteApiKeyParams>}>(
|
||||
"/projects/:projectId/apiKeys/:apiKeyId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {params: DeleteApiKeyParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {apiKeyId} = request.params;
|
||||
const apiKey = await projectService.getApiKeyById(context, apiKeyId);
|
||||
await projectService.deleteApiKeys(context, apiKey);
|
||||
reply.status(200).send({apiKey});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
118
server/src/http/routes/projects.test.ts
Normal file
118
server/src/http/routes/projects.test.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {expect} from "chai";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase, setupNewUsers, TestUser} from "testUtils/apiTestHelper";
|
||||
|
||||
describe("Projects", function () {
|
||||
|
||||
let server: HttpServer;
|
||||
let users: TestUser[];
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
users = await setupNewUsers(server.server);
|
||||
});
|
||||
|
||||
it ("Return empty project list", async function() {
|
||||
const responseUser1 = await users[0].Get("/projects");
|
||||
expect(responseUser1.statusCode).to.equals(200);
|
||||
expect(responseUser1.body).to.be.a("array");
|
||||
expect(responseUser1.body).to.have.length(0)
|
||||
|
||||
const responseUser2 = await users[1].Get("/projects");
|
||||
expect(responseUser2.statusCode).to.equals(200);
|
||||
expect(responseUser2.body).to.be.a("array");
|
||||
expect(responseUser2.body).to.have.length(0)
|
||||
});
|
||||
|
||||
it ("Add 1 project", async function() {
|
||||
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||
expect(addRes.statusCode).to.equals(201);
|
||||
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(200);
|
||||
expect(getRes.body).to.be.a("array");
|
||||
expect(getRes.body).to.have.length(1)
|
||||
});
|
||||
|
||||
it ("Add 2 projects", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||
expect(addRes1.statusCode).to.equals(201);
|
||||
expect(addRes2.statusCode).to.equals(201);
|
||||
|
||||
const getUser1Res = await users[0].Get("/projects");
|
||||
expect(getUser1Res.statusCode).to.equals(200);
|
||||
expect(getUser1Res.body).to.be.a("array");
|
||||
expect(getUser1Res.body).to.have.length(2)
|
||||
|
||||
// I just want to be sure the 2nd user cannot get them
|
||||
const getUser2Res = await users[1].Get("/projects");
|
||||
expect(getUser2Res.statusCode).to.equals(200);
|
||||
expect(getUser2Res.body).to.be.a("array");
|
||||
expect(getUser2Res.body).to.have.length(0)
|
||||
});
|
||||
|
||||
it ("Check projects properties", async function() {
|
||||
await users[0].Post("/projects", {name: "p1"});
|
||||
await users[0].Post("/projects", {name: "p2"});
|
||||
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(200);
|
||||
expect(getRes.body).to.be.a("array");
|
||||
expect(getRes.body).to.have.length(2)
|
||||
expect(getRes.body[0].name).to.equals("p1")
|
||||
expect(getRes.body[1].name).to.equals("p2")
|
||||
});
|
||||
|
||||
it ("Fail to add project (no valid authorization token)", async function() {
|
||||
await users[0].Logout();
|
||||
const addRes = await users[0].Post("/projects", {name: "p1"});
|
||||
expect(addRes.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Fail to get projects (no valid authorization token)", async function() {
|
||||
await users[0].Logout();
|
||||
const getRes = await users[0].Get("/projects");
|
||||
expect(getRes.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Projects have no Api key when created", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const addRes2 = await users[0].Post("/projects", {name: "p2"});
|
||||
|
||||
const project1Id = addRes1.body.id;
|
||||
const project2Id = addRes2.body.id;
|
||||
|
||||
let response = await users[0].Get("/projects");
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(2);
|
||||
|
||||
response = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(0);
|
||||
|
||||
response = await users[0].Get(`/projects/${project2Id}/apiKeys`);
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.body).to.be.a("array");
|
||||
expect(response.body).to.have.length(0);
|
||||
});
|
||||
|
||||
it ("Add Api key to project", async function() {
|
||||
const addRes1 = await users[0].Post("/projects", {name: "p1"});
|
||||
const project1Id = addRes1.body.id;
|
||||
|
||||
const response = await users[0].Post(`/projects/${project1Id}/apiKeys`, {name: "key1"});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
expect(response.body).to.be.a("object");
|
||||
|
||||
const resGetKeys = await users[0].Get(`/projects/${project1Id}/apiKeys`);
|
||||
expect(resGetKeys.statusCode).to.equals(200);
|
||||
expect(resGetKeys.body).to.be.a("array");
|
||||
expect(resGetKeys.body).to.have.length(1);
|
||||
expect(resGetKeys.body[0].name).to.equals("key1");
|
||||
});
|
||||
});
|
26
server/src/http/routes/users.def.ts
Normal file
26
server/src/http/routes/users.def.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const InfoBody = Type.Object({
|
||||
adminToken: Type.String()
|
||||
});
|
||||
|
||||
export const SignupBody = Type.Object({
|
||||
email: Type.String({format: "email"}),
|
||||
password: Type.String({minLength: 6}),
|
||||
adminToken: Type.Optional(Type.String())
|
||||
});
|
||||
|
||||
export const LoginBody= Type.Object({
|
||||
email: Type.String(),
|
||||
password: Type.String(),
|
||||
});
|
||||
|
||||
export const VerifyEmailQuerystring= Type.Object({
|
||||
email: Type.String(),
|
||||
emailConfirmationToken: Type.String(),
|
||||
});
|
||||
|
||||
export const AdminChangeUserPasswordBody = Type.Object({
|
||||
email: Type.String(),
|
||||
newPassword: Type.String()
|
||||
});
|
87
server/src/http/routes/users.routes.ts
Normal file
87
server/src/http/routes/users.routes.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {AdminChangeUserPasswordBody, InfoBody, LoginBody, SignupBody} from "./users.def";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, rateLimit, withData} from "http/utils";
|
||||
import {isRegistrationEnabled} from "@app/core/features/users/utils";
|
||||
|
||||
export function usersRoutes(app: App) {
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
const usersUseCases = app.userFeature.useCases
|
||||
|
||||
server.post<{Body: Static<typeof SignupBody>}>("/signup",
|
||||
{
|
||||
schema: {body: SignupBody}
|
||||
},
|
||||
async (request, reply) => {
|
||||
const {email, password, adminToken} = request.body;
|
||||
await usersUseCases.signupUser(email, password, adminToken)
|
||||
reply.status(201).send({success: true});
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof LoginBody>}>("/login",
|
||||
{
|
||||
schema: {body: LoginBody}
|
||||
},
|
||||
async (request) => {
|
||||
const {email, password} = request.body;
|
||||
const user = await usersUseCases.logUser(email, password)
|
||||
|
||||
const token = server.jwt.sign({email});
|
||||
|
||||
return {...user.getState(), password: undefined, token};
|
||||
}
|
||||
);
|
||||
|
||||
/*server.get<{Querystring: Static<typeof VerifyEmailQuerystring>}>*/
|
||||
/*("/signup-email-confirmation", {schema: {querystring: VerifyEmailQuerystring}}, async function (request, reply) {*/
|
||||
/*[>const {email, emailConfirmationToken} = request.query;<]*/
|
||||
/*[>const user = await app.userService.getUser({email});<]*/
|
||||
/*[>if (!user || !await user.verifyEmail(email, emailConfirmationToken)) {<]*/
|
||||
/*[>reply.status(401);<]*/
|
||||
/*[>return {};<]*/
|
||||
/*[>}<]*/
|
||||
/*reply.send({success: true});*/
|
||||
/*});*/
|
||||
|
||||
server.post<{Body: Static<typeof InfoBody>}>("/info",
|
||||
{
|
||||
schema: {body: InfoBody},
|
||||
preValidation: [rateLimit(1, 1000)],
|
||||
},
|
||||
async (request) => {
|
||||
const {adminToken} = request.body;
|
||||
return usersUseCases.getGlobalInfo(adminToken)
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof AdminChangeUserPasswordBody>}>("/admin/change-user-password",
|
||||
{
|
||||
schema: {body: AdminChangeUserPasswordBody},
|
||||
preValidation: [withData(app, [AppData.Caller]), rateLimit(1, 60000)],
|
||||
},
|
||||
async (request) => {
|
||||
const {email, newPassword} = request.body;
|
||||
const {caller} = request.data
|
||||
return usersUseCases.adminChangeUserPassword(caller, email, newPassword)
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/auth",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Caller])]
|
||||
},
|
||||
async function (request) {
|
||||
const {caller} = request.data;
|
||||
return {...caller.asUser().getState(), password: undefined};
|
||||
}
|
||||
);
|
||||
|
||||
server.get("/isUserRegistrationEnabled", async function () {
|
||||
return {enabled: isRegistrationEnabled()};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
294
server/src/http/routes/users.test.ts
Normal file
294
server/src/http/routes/users.test.ts
Normal file
|
@ -0,0 +1,294 @@
|
|||
import { ADMIN_TOKEN } from "@app/utils";
|
||||
import {expect} from "chai";
|
||||
import {LightMyRequestResponse} from "fastify";
|
||||
import {HttpServer} from "http/HttpServer";
|
||||
import {buildTestServer, deleteDatabase} from "testUtils/apiTestHelper";
|
||||
|
||||
const users = [
|
||||
{
|
||||
email: "user1@test.com",
|
||||
password: "user1_password"
|
||||
},
|
||||
{
|
||||
email: "user2@test.com",
|
||||
password: "user2_password"
|
||||
},
|
||||
{
|
||||
email: "user3@test.com",
|
||||
password: "user3_password"
|
||||
}
|
||||
];
|
||||
|
||||
type TestRequestBody = {
|
||||
method: string,
|
||||
url: string,
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function TestRequiredBody(server: HttpServer, OriginalRequest: TestRequestBody) {
|
||||
const payloadKeys = Object.keys(OriginalRequest.payload);
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
|
||||
const request = JSON.parse(JSON.stringify(OriginalRequest));
|
||||
delete request.payload[key];
|
||||
const response = await server.inject(request);
|
||||
expect(response.statusCode).to.equals(400);
|
||||
}
|
||||
}
|
||||
|
||||
let server: HttpServer;
|
||||
|
||||
async function logReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||
return server.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function signupReq(email: string, password: string): Promise<LightMyRequestResponse> {
|
||||
return server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("Users", function () {
|
||||
|
||||
beforeEach(async function () {
|
||||
await deleteDatabase();
|
||||
server = await buildTestServer();
|
||||
});
|
||||
|
||||
describe("Signup", function() {
|
||||
it ("User registration is enabled", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/isUserRegistrationEnabled",
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(200);
|
||||
expect(response.json().enabled).to.equals(true)
|
||||
});
|
||||
|
||||
it ("Fail get current user (401) because no Bearer token is provided", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email has wrong format", async function () {
|
||||
const res = await signupReq("wrong", users[1].password)
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email is an empty string", async function () {
|
||||
const res = await signupReq("", users[1].password)
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because password is an empty string", async function () {
|
||||
const res = await signupReq(users[1].email, "")
|
||||
expect(res.statusCode).to.equals(400);
|
||||
});
|
||||
|
||||
it("Fail to sign up because email is already taken", async function () {
|
||||
const resSignup1 = await signupReq(users[0].email, users[0].password)
|
||||
expect(resSignup1.statusCode).to.equals(201);
|
||||
|
||||
const resSignup2 = await signupReq(users[0].email, users[1].password)
|
||||
expect(resSignup2.statusCode).to.equals(409);
|
||||
});
|
||||
|
||||
it("Succeed to sign up a new user", async function () {
|
||||
const res = await signupReq(users[0].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(201);
|
||||
});
|
||||
})
|
||||
|
||||
/*it("Should fail to log in with the new user because email is not verified", async function () {*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "POST",*/
|
||||
/*url: "/login",*/
|
||||
/*payload: {*/
|
||||
/*email: users[0].email,*/
|
||||
/*password: users[0].password,*/
|
||||
/*}*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(403);*/
|
||||
/*});*/
|
||||
|
||||
/*it("Fail to verify the email (wrong token)", async function () {*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=this_is_a_random_string`,*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(401);*/
|
||||
/*});*/
|
||||
|
||||
/*it("Verify the email", async function () {*/
|
||||
|
||||
/*const user = await server.app.managers.userManager.FetchUserByEmail(users[0].email);*/
|
||||
|
||||
/*const response = await server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${users[0].email}&emailConfirmationToken=${user?.GetDocument().emailConfirmationToken}`,*/
|
||||
/*});*/
|
||||
|
||||
/*expect(response.statusCode).to.equals(200);*/
|
||||
/*});*/
|
||||
|
||||
describe("Login", function() {
|
||||
beforeEach(async function() {
|
||||
const response = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
})
|
||||
|
||||
it ("Fail (400) when a required property is missing", async function() {
|
||||
await TestRequiredBody(server, {
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("Fail to log in with wrong credentials", async function () {
|
||||
const res = await logReq("abc", "def")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it ("Fail to get current authenticated user because of wrong jwt token", async function() {
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
headers: {
|
||||
["Authorization"]: "Bearer abcd"
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with password of another user", async function () {
|
||||
const res = await logReq(users[1].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with a total random password", async function () {
|
||||
const res = await logReq(users[1].email, "qwertyasdfgzxcvb")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with an unknown email but existing password", async function () {
|
||||
const res = await logReq("this_email@doesnt.exist", users[0].password)
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Fail to log in with an unknown email and unknown password", async function () {
|
||||
const res = await logReq("this_email@doesnt.exist", "this_password_doesnt_exist")
|
||||
expect(res.statusCode).to.equals(401);
|
||||
});
|
||||
|
||||
it("Succeed to log in", async function () {
|
||||
const res = await logReq(users[0].email, users[0].password)
|
||||
expect(res.statusCode).to.equals(200);
|
||||
expect(res.json().token).to.not.be.undefined;
|
||||
expect(res.json().password).to.be.undefined;
|
||||
});
|
||||
|
||||
it ("Succeed to get current authenticated user", async function() {
|
||||
const resLogin = await logReq(users[0].email, users[0].password)
|
||||
expect(resLogin.statusCode).to.equals(200);
|
||||
|
||||
const token = resLogin.json().token
|
||||
|
||||
const resAuth = await server.inject({
|
||||
method: "GET",
|
||||
url: "/auth",
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
expect(resAuth.statusCode).to.equals(200);
|
||||
expect(resAuth.json().password).to.be.undefined;
|
||||
});
|
||||
})
|
||||
|
||||
describe("Password reset", function() {
|
||||
beforeEach(async function() {
|
||||
const response = await server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
password: users[0].password,
|
||||
}
|
||||
});
|
||||
expect(response.statusCode).to.equals(201);
|
||||
})
|
||||
|
||||
it ("Admin succeed to force change an user password", async function() {
|
||||
const newPassword = "my new password"
|
||||
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resWrongPassword.statusCode).to.equals(401);
|
||||
const resChangePassword = await server.inject({
|
||||
method: "post",
|
||||
url: "/admin/change-user-password",
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${ADMIN_TOKEN}`
|
||||
},
|
||||
payload: {
|
||||
email: users[0].email,
|
||||
newPassword: newPassword
|
||||
}
|
||||
});
|
||||
expect(resChangePassword.statusCode).to.equals(200);
|
||||
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resGoodPassword.statusCode).to.equals(200);
|
||||
});
|
||||
|
||||
it ("Non Admin fail to force change an user password", async function() {
|
||||
const newPassword = "my new password"
|
||||
const resWrongPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resWrongPassword.statusCode).to.equals(401);
|
||||
const resChangePassword = await server.inject({
|
||||
method: "post",
|
||||
url: "/admin/change-user-password",
|
||||
headers: {
|
||||
["Authorization"]: "Something random"
|
||||
},
|
||||
payload: {
|
||||
password: newPassword
|
||||
}
|
||||
});
|
||||
expect(resChangePassword.statusCode).to.equals(401);
|
||||
const resGoodPassword = await logReq(users[0].email, newPassword)
|
||||
expect(resGoodPassword.statusCode).to.equals(401);
|
||||
});
|
||||
})
|
||||
});
|
29
server/src/http/routes/views.def.ts
Normal file
29
server/src/http/routes/views.def.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import {ViewProvider} from "@app/core/features/views";
|
||||
import {Type} from "@sinclair/typebox";
|
||||
|
||||
export const CreateViewBody = Type.Object({
|
||||
type: Type.String(),
|
||||
name: Type.String(),
|
||||
provider: Type.Enum(ViewProvider),
|
||||
config: Type.String(),
|
||||
});
|
||||
|
||||
export const CreateViewParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateViewBody = Type.Object({
|
||||
name: Type.String(),
|
||||
config: Type.String(),
|
||||
});
|
||||
|
||||
export const UpdateViewParams = Type.Object({
|
||||
projectId: Type.String(),
|
||||
viewId: Type.String(),
|
||||
});
|
||||
|
||||
|
||||
export const GetViewsQuerystring = Type.Object({
|
||||
type: Type.Optional(Type.String()),
|
||||
name: Type.Optional(Type.String()),
|
||||
});
|
60
server/src/http/routes/views.routes.ts
Normal file
60
server/src/http/routes/views.routes.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {FastifyInstance} from "fastify";
|
||||
import {Static} from "@sinclair/typebox";
|
||||
import {App} from "@app/core";
|
||||
import {AppData, withData} from "http/utils";
|
||||
import {CreateViewBody, CreateViewParams, GetViewsQuerystring, UpdateViewBody, UpdateViewParams} from "./views.def";
|
||||
|
||||
export function viewsRoutes(app: App) {
|
||||
const viewsUseCases = app.viewsFeature.useCases
|
||||
return async (server: FastifyInstance) => {
|
||||
|
||||
server.get<{Querystring: Static<typeof GetViewsQuerystring>}>("/projects/:projectId/views",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {querystring: GetViewsQuerystring}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {type} = request.query
|
||||
let views = []
|
||||
if (type) {
|
||||
views = await viewsUseCases.getViewsByType(context, type)
|
||||
} else {
|
||||
views = await viewsUseCases.getAllViews(context)
|
||||
}
|
||||
reply.status(200).send(views);
|
||||
}
|
||||
);
|
||||
|
||||
server.post<{Body: Static<typeof CreateViewBody>, Params: Static<typeof CreateViewParams>}>(
|
||||
"/projects/:projectId/views",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: CreateViewBody, params: CreateViewParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
await viewsUseCases.createView(context, request.body)
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
|
||||
server.put<{Body: Static<typeof UpdateViewBody>, Params: Static<typeof UpdateViewParams>}>(
|
||||
"/projects/:projectId/views/:viewId",
|
||||
{
|
||||
preValidation: [withData(app, [AppData.Context])],
|
||||
schema: {body: UpdateViewBody, params: UpdateViewParams}
|
||||
},
|
||||
async function (request, reply) {
|
||||
const {context} = request.data;
|
||||
const {viewId} = request.params
|
||||
const view = await app.viewsFeature.repository.findOne(viewId)
|
||||
if (!view) {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
await viewsUseCases.updateView(context, view, request.body)
|
||||
reply.status(201).send({});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
109
server/src/http/utils.ts
Normal file
109
server/src/http/utils.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import {App} from "@app/core";
|
||||
import {AdminUser, Caller} from "@app/core/features/projects/entities/Caller";
|
||||
import {Project} from "@app/core/features/projects/entities/Project";
|
||||
import {ProjectContext} from "@app/core/features/projects/ProjectContext";
|
||||
import {User} from "@app/core/features/users/entities";
|
||||
import {AuthenticationFailed} from "@app/core/features/users/exceptions/AuthenticationFailed";
|
||||
import {ADMIN_TOKEN} from "@app/utils";
|
||||
import {FastifyReply, FastifyRequest} from "fastify";
|
||||
|
||||
export function rateLimit(count: number, ms: number) {
|
||||
let rateLimitCache: { [callerId: string]: { [url: string]: number } } = {}
|
||||
setInterval(() => { rateLimitCache = {} }, ms)
|
||||
|
||||
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (process.env.RATE_LIMIT_ENABLED !== 'true') {
|
||||
return ;
|
||||
}
|
||||
let callerId = "";
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
callerId = request.user.email
|
||||
} catch (e) {
|
||||
callerId = request.headers.authorization?.split(' ')[1] || ""
|
||||
}
|
||||
if (!rateLimitCache[callerId]) {
|
||||
rateLimitCache[callerId] = {}
|
||||
}
|
||||
if (!rateLimitCache[callerId][request.url]) {
|
||||
rateLimitCache[callerId][request.url] = 0
|
||||
}
|
||||
if (rateLimitCache[callerId][request.url] >= count) {
|
||||
return reply.status(429).send({message: "Rate limit reached"});
|
||||
}
|
||||
|
||||
rateLimitCache[callerId][request.url] += 1
|
||||
}
|
||||
}
|
||||
|
||||
export enum AppData {
|
||||
Caller,
|
||||
Project,
|
||||
Context
|
||||
}
|
||||
|
||||
export function withData(app: App, required: AppData[]) {
|
||||
const projectRepository = app.projectFeature.projectRepository
|
||||
const userRepository = app.userFeature.repository
|
||||
const apiKeyRepository = app.projectFeature.apiKeyRepository
|
||||
return async (request: FastifyRequest) => {
|
||||
let user
|
||||
let caller
|
||||
let project
|
||||
let context
|
||||
|
||||
if (required.includes(AppData.Caller) || required.includes(AppData.Context)) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const userData = await userRepository.findUserByEmail(request.user.email);
|
||||
if (userData) {
|
||||
user = new User(userData)
|
||||
caller = new Caller(new User(userData))
|
||||
}
|
||||
} catch (e) {
|
||||
const token = request.headers.authorization?.split(' ')[1]
|
||||
if (typeof token !== "string") {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
if (token === ADMIN_TOKEN) {
|
||||
caller = new Caller(new AdminUser())
|
||||
} else {
|
||||
const apiKey = await apiKeyRepository.findByKey(token);
|
||||
if (!apiKey) {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
caller = new Caller(apiKey)
|
||||
}
|
||||
}
|
||||
if (!caller) {
|
||||
throw new AuthenticationFailed("Unknown authorization token");
|
||||
}
|
||||
}
|
||||
|
||||
if (required.includes(AppData.Project) || required.includes(AppData.Context)) {
|
||||
const params = request.params as Record<string, unknown>
|
||||
const projectId = params.projectId as string
|
||||
project = await projectRepository.findProjectById(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Unknown project id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (required.includes(AppData.Context)) {
|
||||
if (!caller || !project) {
|
||||
throw new Error("Missing information to create a project Context")
|
||||
}
|
||||
context = new ProjectContext(project, caller)
|
||||
}
|
||||
|
||||
request.data = {
|
||||
user: user as User,
|
||||
caller: caller as Caller,
|
||||
project: project as Project,
|
||||
context: context as ProjectContext
|
||||
}
|
||||
|
||||
return request.data
|
||||
}
|
||||
}
|
24
server/src/index.ts
Normal file
24
server/src/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {App} from "@app/core";
|
||||
import {MongoClient, MongoClientOptions} from "mongodb";
|
||||
import {HttpServer} from "./http";
|
||||
|
||||
(async () => {
|
||||
console.log(`node version: ${process.version}`)
|
||||
const {
|
||||
DB_HOST,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
DB_PASSWORD,
|
||||
DB_NAME,
|
||||
} = process.env;
|
||||
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}`;
|
||||
const mongoOptions: MongoClientOptions = {ignoreUndefined: true}
|
||||
const client = await MongoClient.connect(mongoUrl, mongoOptions);
|
||||
const db = client.db(DB_NAME)
|
||||
const app = new App(db);
|
||||
await app.start();
|
||||
|
||||
const server = new HttpServer(app);
|
||||
server.setup();
|
||||
server.listen();
|
||||
})()
|
241
server/src/testUtils/apiTestHelper.ts
Normal file
241
server/src/testUtils/apiTestHelper.ts
Normal file
|
@ -0,0 +1,241 @@
|
|||
import {expect} from "chai";
|
||||
import {App} from "@app/core";
|
||||
import {LightMyRequestResponse} from "fastify";
|
||||
import {FastifyTypebox, HttpServer} from "../http";
|
||||
import {MongoClient} from "mongodb";
|
||||
|
||||
const {
|
||||
DB_HOST,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
DB_PASSWORD,
|
||||
DB_NAME,
|
||||
} = process.env;
|
||||
|
||||
const mongoUrl = `mongodb://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}?authSource=admin`;
|
||||
const mongoDbName = DB_NAME as string;
|
||||
|
||||
export async function deleteDatabase(): Promise<boolean> {
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
return client.db(mongoDbName).dropDatabase();
|
||||
}
|
||||
|
||||
const usersCredientials = [
|
||||
{
|
||||
email: "user1@test.com",
|
||||
password: "user1_password"
|
||||
},
|
||||
{
|
||||
email: "user2@test.com",
|
||||
password: "user2_password"
|
||||
},
|
||||
{
|
||||
email: "user3@test.com",
|
||||
password: "user3_password"
|
||||
}
|
||||
];
|
||||
|
||||
export async function buildTestServer(): Promise<HttpServer> {
|
||||
|
||||
await deleteDatabase();
|
||||
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
const db = client.db(DB_NAME)
|
||||
const app = new App(db);
|
||||
await app.start();
|
||||
|
||||
const server = new HttpServer(app);
|
||||
server.setup();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export interface ResponseHelper {
|
||||
response: LightMyRequestResponse,
|
||||
/* eslint-disable-next-line */
|
||||
body: any,
|
||||
statusCode: number
|
||||
}
|
||||
|
||||
export class TestApiKey {
|
||||
private _server: FastifyTypebox;
|
||||
private _authorization = "";
|
||||
|
||||
public get authorization() {return this._authorization;}
|
||||
|
||||
constructor(server: FastifyTypebox, apiKey: string) {
|
||||
this._server = server;
|
||||
this._authorization = apiKey;
|
||||
}
|
||||
|
||||
async Get(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "PUT",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Delete(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "DELETE",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
}
|
||||
|
||||
export class TestUser {
|
||||
private _server: FastifyTypebox;
|
||||
private _email = "";
|
||||
private _password = "";
|
||||
private _authorization = "";
|
||||
|
||||
public get authorization() {return this._authorization;}
|
||||
|
||||
constructor(server: FastifyTypebox,) {
|
||||
this._server = server;
|
||||
}
|
||||
|
||||
async Login(email: string, password: string): Promise<boolean> {
|
||||
this._email = email;
|
||||
this._password = password;
|
||||
|
||||
/*const user = await this._app.userService.getUser({email});*/
|
||||
|
||||
let response = await this._server.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
|
||||
/*response = await this._server.inject({*/
|
||||
/*method: "GET",*/
|
||||
/*url: `/signup-email-confirmation?email=${email}&emailConfirmationToken=${user?.getData().emailConfirmationToken}`,*/
|
||||
/*});*/
|
||||
|
||||
response = await this._server.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: {
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
this._authorization = body.token;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async Logout() {
|
||||
this._authorization = ""
|
||||
}
|
||||
|
||||
async SetAuthorization(authorization: string) {
|
||||
this._authorization = authorization;
|
||||
}
|
||||
|
||||
async Get(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Post(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Put(url: string, payload: Record<string, unknown>): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "PUT",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
},
|
||||
payload
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
|
||||
async Delete(url: string): Promise<ResponseHelper> {
|
||||
const response = await this._server.inject({
|
||||
method: "DELETE",
|
||||
url,
|
||||
headers: {
|
||||
["Authorization"]: `Bearer ${this._authorization}`,
|
||||
}
|
||||
});
|
||||
return {response, body: response.json(), statusCode: response.statusCode};
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupNewUsers(server: FastifyTypebox): Promise<TestUser[]> {
|
||||
const usersWithAuthorization = await Promise.all(usersCredientials.map(async (userCredientials) =>{
|
||||
const user = new TestUser(server);
|
||||
await user.Login(userCredientials.email, userCredientials.password);
|
||||
return user;
|
||||
}));
|
||||
return usersWithAuthorization;
|
||||
}
|
||||
|
||||
export async function TestRequiredBody(method: string, url: string, user: TestUser, originalPayload: Record<string, unknown>) {
|
||||
const payloadKeys = Object.keys(originalPayload);
|
||||
|
||||
for (const key of payloadKeys) {
|
||||
|
||||
const payload = JSON.parse(JSON.stringify(originalPayload));
|
||||
delete payload[key];
|
||||
if (method === "POST") {
|
||||
const {response} = await user.Post(url, payload);
|
||||
expect(response.statusCode).to.equals(400, `Key '${key}' in '${method} ${url}' is required but the server did not return a bad request error`);
|
||||
}
|
||||
}
|
||||
}
|
19
server/src/utils/index.ts
Normal file
19
server/src/utils/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export const ADMIN_TOKEN = process.env.API_ADMIN_AUTHORIZATION as string;
|
||||
|
||||
export function getAdminProjectId(): string | undefined {
|
||||
return process.env.ADMIN_PROJECT_ID;
|
||||
}
|
||||
|
||||
export function getAdminSucessTagId(): string | undefined {
|
||||
return process.env.ADMIN_SUCCESS_TAG_ID;
|
||||
}
|
||||
|
||||
export function getAdminFailTagId(): string | undefined {
|
||||
return process.env.ADMIN_FAIL_TAG_ID;
|
||||
}
|
44
server/src/utils/logger.ts
Normal file
44
server/src/utils/logger.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
const consoleColors = {
|
||||
RESET: "\x1b[0m",
|
||||
SUCCESS: "\x1b[32m",
|
||||
WARNING: "\x1b[33m",
|
||||
ERROR: "\x1b[31m",
|
||||
DEBUG: "\x1b[36m"
|
||||
};
|
||||
|
||||
export class Logger {
|
||||
static info(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
static debug(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.log(`${consoleColors.DEBUG}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static success(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.SUCCESS}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static warn(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.WARNING}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
|
||||
static error(message: unknown) {
|
||||
if (!process.env.LOGGER_ENABLED) {
|
||||
return;
|
||||
}
|
||||
console.error(`${consoleColors.ERROR}${message}${consoleColors.RESET}`);
|
||||
}
|
||||
}
|
111
server/tsconfig.json
Normal file
111
server/tsconfig.json
Normal file
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"@app/*": ["./src/*"],
|
||||
"utils/*": ["./src/utils/*"],
|
||||
"http/*": ["./src/http/*"],
|
||||
"testUtils/*": ["./src/testUtils/*"]
|
||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
}
|
||||
}
|
2
web-app/.env.development
Normal file
2
web-app/.env.development
Normal file
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_API_URL=http://localhost:8080
|
||||
REACT_APP_DOCS_URL=http://localhost:8000
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue