:init: Init
This commit is contained in:
parent
9e46cb72fa
commit
60e6fa9a85
329 changed files with 42461 additions and 1 deletions
3
.dockerignore
Executable file
3
.dockerignore
Executable file
|
@ -0,0 +1,3 @@
|
|||
.vscode
|
||||
src/node_modules
|
||||
src/wwwroot/
|
3
.gitignore
vendored
Executable file
3
.gitignore
vendored
Executable file
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
src/node_modules
|
||||
src/wwwroot/
|
34
Dockerfile
Executable file
34
Dockerfile
Executable file
|
@ -0,0 +1,34 @@
|
|||
# Server Build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS server
|
||||
WORKDIR /work/src
|
||||
|
||||
COPY ./src/Aptabase.csproj /work/src
|
||||
|
||||
ARG TARGETARCH
|
||||
RUN dotnet restore "./Aptabase.csproj" -a $TARGETARCH
|
||||
|
||||
COPY ./etc/clickhouse /work/etc/clickhouse
|
||||
COPY ./etc/geoip /work/etc/geoip
|
||||
COPY ./src /work/src
|
||||
|
||||
RUN dotnet publish "Aptabase.csproj" -a $TARGETARCH -c Release -o /work/publish /p:UseAppHost=false
|
||||
|
||||
# WebApp Build
|
||||
FROM oven/bun:1 AS webapp
|
||||
WORKDIR /work
|
||||
|
||||
COPY ./src/package.json ./src/package-lock.json ./
|
||||
RUN bun install
|
||||
|
||||
COPY ./src ./
|
||||
RUN bun run build
|
||||
|
||||
# Final
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=server /work/publish .
|
||||
COPY --from=webapp /work/wwwroot ./wwwroot
|
||||
COPY LICENSE .
|
||||
|
||||
ENTRYPOINT ["dotnet", "Aptabase.dll"]
|
619
LICENSE
Executable file
619
LICENSE
Executable file
|
@ -0,0 +1,619 @@
|
|||
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
|
57
README.md
Normal file → Executable file
57
README.md
Normal file → Executable file
|
@ -1 +1,56 @@
|
|||
# Zalvena
|
||||
# Zalvena Service
|
||||
|
||||
A fork of Aptabase Server software, modified to only work with projects using Zalvena.
|
||||
|
||||
## Changes
|
||||
- Updated app key generation to use `ZV` instead of `SH`
|
||||
- Updated browserslist
|
||||
- Updated `node:18` to `over/bun:1` in `Dockerfile`
|
||||
- Updated the pacakge `@types/node` from `20.8.7` to `22.13.4`
|
||||
- Updated the pacakge `@types/react` from `18.2.30` to `19.0.10`
|
||||
- Updated the pacakge `@types/react-dom` from `18.2.14` to `19.0.4`
|
||||
- Updated the pacakge `@vitejs/plugin-react-swc` from `3.4.0` to `3.8.0`
|
||||
- Updated the pacakge `autoprefixer` from `10.4.16` to `10.4.20`
|
||||
- Updated the pacakge `postcss` from `8.4.31` to `8.5.3`
|
||||
- Updated the pacakge `tailwind-merge` from `1.14.0` to `3.0.1`
|
||||
- Updated the pacakge `typescript` from `5.2.2` to `5.7.3`
|
||||
- Updated the pacakge `vite` from `4.5.3` to `6.1.1`
|
||||
- Updated the pacakge `vite-plugin-mkcert` from `1.17.5` to `1.17.6`
|
||||
- Updated the pacakge `@aptabase/web` from `0.4.0` to `0.4.3`
|
||||
- Updated the pacakge `@fontsource/inter` from `5.0.14` to `5.1.1`
|
||||
- Updated the pacakge `@headlessui/react` from `2.1.2` to `2.2.0`
|
||||
- Updated the pacakge `@radix-ui/react-accordion` from `1.1.2` to `1.2.3`
|
||||
- Updated the pacakge `@radix-ui/react-label` from `2.0.2` to `2.1.2`
|
||||
- Updated the pacakge `@radix-ui/react-popover` from `1.0.7` to `1.1.6`
|
||||
- Updated the pacakge `@radix-ui/react-select` from `2.0.0` to `2.1.6`
|
||||
- Updated the pacakge `@radix-ui/react-slot` from `1.0.2` to `1.1.2`
|
||||
- Updated the pacakge `@radix-ui/react-tabs` from `1.0.4` to `1.1.3`
|
||||
- Updated the pacakge `@radix-ui/react-tooltip` from `1.0.7` to `1.1.8`
|
||||
- Updated the pacakge `@tabler/icons-react` from `2.39.0` to `3.30.0`
|
||||
- Updated the pacakge `@tanstack/react-query` from `5.0.0` to `5.66.7`
|
||||
- Updated the pacakge `chart.js` from `4.4.0` to `4.4.8`
|
||||
- Updated the pacakge `chartjs-plugin-annotation` from `3.0.1` to `3.1.0`
|
||||
- Updated the pacakge `cmdk` from `0.2.1` to `1.0.4`
|
||||
- Updated the pacakge `date-fns` from `3.6.0` to `4.1.0`
|
||||
- Updated the pacakge `framer-motion` from `10.16.4` to `12.4.5`
|
||||
- Updated the pacakge `jotai` from `2.9.1` to `2.12.1`
|
||||
- Updated the pacakge `react` from `18.2.0` to `19.0.0`
|
||||
- Updated the pacakge `react-dom` from `18.2.0` to `19.0.0`
|
||||
- Updated the pacakge `react-markdown` from `9.0.0` to `9.0.3`
|
||||
- Updated the pacakge `react-router-dom` from `6.17.0` to `7.2.0`
|
||||
- Updated the pacakge `rehype-highlight` from `7.0.0` to `7.0.2`
|
||||
- Updated the pacakge `remark-gfm` from `4.0.0` to `4.0.1`
|
||||
- Updated the pacakge `sonner` from `1.0.3` to `2.0.0`
|
||||
- Remote sources removed to make this fork comply with SudoVanilla Umbrella Policy
|
||||
- Default avatar points to https://md.sudovanilla.org/images/icons/Aptabase.jpg
|
||||
- Flags point to https://md.sudovanilla.org/images/flags/
|
||||
- Removed all support elements and Chirp
|
||||
- Removed instructions screen
|
||||
- Removed `/etc/tinybird`
|
||||
- Removed `/src/Properties/`
|
||||
- Removed `/tests/`
|
||||
- Removed `/tools/`
|
||||
|
||||
## License
|
||||
|
||||
Aptabase and Zalvena Service is open-source under the [AGPLv3 license](./LICENSE). You can use it for free, but you must share any changes you make to the code.
|
36
docker-compose.yml
Executable file
36
docker-compose.yml
Executable file
|
@ -0,0 +1,36 @@
|
|||
services:
|
||||
aptabase_db:
|
||||
container_name: aptabase_db
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tmp/db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: aptabase
|
||||
POSTGRES_PASSWORD: sLPnqXzjupt6pR1dumYrGeVXpVDup49hYRRn2TEUfRmF6eXbBu4CJYl5ynBFxXx8
|
||||
aptabase_events_db:
|
||||
container_name: aptabase_events_db
|
||||
image: clickhouse/clickhouse-server:23.8.4.69-alpine
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tmp/events-db-data:/var/lib/clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_USER: aptabase
|
||||
CLICKHOUSE_PASSWORD: lBxMObwAWOU8L4ieQPq1ZcbQeUND4FwwgEfvmGstU5o8IZS3XekInmKtcAwqSxgm
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
aptabase:
|
||||
container_name: aptabase_app
|
||||
image: oci.registry.sudovanilla.org/zalvena
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- /etc/hosts:/etc/hosts
|
||||
depends_on:
|
||||
- aptabase_events_db
|
||||
- aptabase_db
|
||||
ports:
|
||||
- 6521:8080
|
26
etc/clickhouse/0001-events.sql
Executable file
26
etc/clickhouse/0001-events.sql
Executable file
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE IF NOT EXISTS events
|
||||
(
|
||||
`app_id` String,
|
||||
`timestamp` DateTime,
|
||||
`event_name` String,
|
||||
`user_id` String,
|
||||
`session_id` String,
|
||||
`os_name` LowCardinality(String),
|
||||
`os_version` String,
|
||||
`locale` LowCardinality(String),
|
||||
`app_version` String,
|
||||
`app_build_number` String,
|
||||
`engine_name` LowCardinality(String),
|
||||
`engine_version` String,
|
||||
`sdk_version` String,
|
||||
`country_code` LowCardinality(String),
|
||||
`region_name` LowCardinality(String),
|
||||
`city` String,
|
||||
`string_props` String,
|
||||
`numeric_props` String,
|
||||
`ttl` DateTime
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(timestamp)
|
||||
ORDER BY (app_id, timestamp, event_name)
|
||||
TTL ttl;
|
8
etc/clickhouse/0002-monthly_usage_v1_mv.sql
Executable file
8
etc/clickhouse/0002-monthly_usage_v1_mv.sql
Executable file
|
@ -0,0 +1,8 @@
|
|||
CREATE OR REPLACE VIEW monthly_usage_v1
|
||||
AS
|
||||
SELECT
|
||||
app_id,
|
||||
toStartOfMonth(timestamp) AS period,
|
||||
countState() AS events
|
||||
FROM events
|
||||
GROUP BY app_id, period
|
28
etc/clickhouse/0003-sessions_live_v1.sql
Executable file
28
etc/clickhouse/0003-sessions_live_v1.sql
Executable file
|
@ -0,0 +1,28 @@
|
|||
CREATE OR REPLACE VIEW sessions_live_v1
|
||||
AS
|
||||
SELECT
|
||||
app_id,
|
||||
session_id,
|
||||
user_id,
|
||||
minSimpleState(e.timestamp) AS min_timestamp,
|
||||
maxSimpleState(e.timestamp) AS max_timestamp,
|
||||
anySimpleState(os_name) AS os_name,
|
||||
anySimpleState(os_version) AS os_version,
|
||||
anySimpleState(locale) AS locale,
|
||||
anySimpleState(app_version) AS app_version,
|
||||
anySimpleState(app_build_number) AS app_build_number,
|
||||
anySimpleState(engine_name) AS engine_name,
|
||||
anySimpleState(engine_version) AS engine_version,
|
||||
anySimpleState(sdk_version) AS sdk_version,
|
||||
anySimpleState(country_code) AS country_code,
|
||||
anySimpleState(region_name) AS region_name,
|
||||
countState() AS events_count,
|
||||
groupArrayState(event_name) AS events_name,
|
||||
groupArrayState(e.timestamp) AS events_timestamp,
|
||||
groupArrayState(string_props) AS events_string_props,
|
||||
groupArrayState(numeric_props) AS events_numeric_props
|
||||
FROM events AS e
|
||||
GROUP BY
|
||||
app_id,
|
||||
session_id,
|
||||
user_id
|
2
etc/clickhouse/0004-add_device_model_to_events.sql
Executable file
2
etc/clickhouse/0004-add_device_model_to_events.sql
Executable file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS device_model String;
|
7
etc/clickhouse/queries/billing_historical_usage__v1.liquid
Executable file
7
etc/clickhouse/queries/billing_historical_usage__v1.liquid
Executable file
|
@ -0,0 +1,7 @@
|
|||
SELECT period as Date,
|
||||
countMerge(events) as Events
|
||||
FROM monthly_usage_v1
|
||||
WHERE app_id IN ('{{app_ids}}')
|
||||
AND period >= toStartOfMonth(now() - INTERVAL 6 MONTH)
|
||||
GROUP BY period
|
||||
ORDER BY period WITH FILL FROM toStartOfMonth(now() - INTERVAL 6 MONTH) TO toStartOfMonth(now() + INTERVAL 1 MONTH) STEP toIntervalMonth(1)
|
5
etc/clickhouse/queries/billing_usage_per_app__v1.liquid
Executable file
5
etc/clickhouse/queries/billing_usage_per_app__v1.liquid
Executable file
|
@ -0,0 +1,5 @@
|
|||
SELECT replace(app_id, '_DEBUG', '') as AppId,
|
||||
countMerge(events) as Count
|
||||
FROM monthly_usage_v1
|
||||
WHERE period = '{{period}}'
|
||||
GROUP BY replace(app_id, '_DEBUG', '')
|
4
etc/clickhouse/queries/get_billing_usage__v1.liquid
Executable file
4
etc/clickhouse/queries/get_billing_usage__v1.liquid
Executable file
|
@ -0,0 +1,4 @@
|
|||
SELECT countMerge(events) as Count
|
||||
FROM monthly_usage_v1
|
||||
WHERE app_id IN ('{{app_ids}}')
|
||||
AND period = toStartOfMonth(now())
|
36
etc/clickhouse/queries/historical_sessions__v1.liquid
Executable file
36
etc/clickhouse/queries/historical_sessions__v1.liquid
Executable file
|
@ -0,0 +1,36 @@
|
|||
SELECT session_id as Id,
|
||||
min(min_timestamp) as StartedAt,
|
||||
max(max_timestamp) - min(min_timestamp) as Duration,
|
||||
countMerge(events_count) as EventsCount,
|
||||
any(app_version) as AppVersion,
|
||||
any(country_code) as CountryCode,
|
||||
any(region_name) as RegionName,
|
||||
any(os_name) as OsName,
|
||||
any(os_version) as OsVersion
|
||||
FROM sessions_live_v1
|
||||
WHERE app_id = '{{app_id}}'
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
{% if date_to %}
|
||||
AND (min_timestamp < '{{date_to}}' OR (min_timestamp = '{{date_to}}' AND session_id < '{{session_id}}'))
|
||||
{% endif %}
|
||||
{% if date_from %}
|
||||
AND (min_timestamp > '{{date_from}}' OR (min_timestamp = '{{date_from}}' AND session_id > '{{session_id}}'))
|
||||
{% endif %}
|
||||
GROUP BY session_id
|
||||
{% if event_name %}
|
||||
HAVING hasAny(groupArrayMerge(events_name), ['{{event_name}}'])
|
||||
{% endif %}
|
||||
{% if date_from %}
|
||||
ORDER BY StartedAt ASC, Id ASC
|
||||
{% else %}
|
||||
ORDER BY StartedAt DESC, Id DESC
|
||||
{% endif %}
|
||||
LIMIT 10
|
30
etc/clickhouse/queries/key_metrics__v1.liquid
Executable file
30
etc/clickhouse/queries/key_metrics__v1.liquid
Executable file
|
@ -0,0 +1,30 @@
|
|||
SELECT uniqExact(user_id) / (date_diff('day', min(min), max(max)) + 1) as DailyUsers,
|
||||
uniqExact(session_id) as Sessions,
|
||||
if(isNaN(median(max - min)), 0, median(max - min)) as DurationSeconds,
|
||||
sum(count) as Events
|
||||
FROM (
|
||||
SELECT min(timestamp) AS min,
|
||||
max(timestamp) AS max,
|
||||
user_id,
|
||||
session_id,
|
||||
count(*) AS count
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP BY user_id, session_id
|
||||
)
|
33
etc/clickhouse/queries/key_metrics__v2.liquid
Executable file
33
etc/clickhouse/queries/key_metrics__v2.liquid
Executable file
|
@ -0,0 +1,33 @@
|
|||
SELECT uniqExact(user_id) / (date_diff('day', min(min), max(max)) + if(date_diff('day', min(min), max(max)) = 0, 1, 0)) AS DailyUsers,
|
||||
uniqExact(session_id) as Sessions,
|
||||
if(isNaN(median(max - min)), 0, median(max - min)) as DurationSeconds,
|
||||
sum(count) as Events
|
||||
FROM (
|
||||
SELECT min(timestamp) AS min,
|
||||
max(timestamp) AS max,
|
||||
user_id,
|
||||
session_id,
|
||||
count(*) AS count
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if device_model %}
|
||||
AND device_model = '{{device_model}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP BY user_id, session_id
|
||||
)
|
53
etc/clickhouse/queries/key_metrics_periodic__v1.liquid
Executable file
53
etc/clickhouse/queries/key_metrics_periodic__v1.liquid
Executable file
|
@ -0,0 +1,53 @@
|
|||
SELECT
|
||||
{% if granularity == 'Hour' %}
|
||||
toStartOfHour(timestamp)
|
||||
{% elsif granularity == 'Day' %}
|
||||
toStartOfDay(timestamp)
|
||||
{% else %}
|
||||
toStartOfMonth(timestamp)
|
||||
{% endif %} AS Period,
|
||||
{% if granularity == 'Month' %}
|
||||
uniqExact(user_id) / (date_diff('day', Period, toLastDayOfMonth(Period)) + 1)
|
||||
{% else %}
|
||||
uniqExact(user_id)
|
||||
{% endif %} AS Users,
|
||||
uniqExact(session_id) AS Sessions,
|
||||
count() AS Events
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP by Period
|
||||
ORDER BY Period ASC
|
||||
WITH FILL
|
||||
{% if date_from and date_to %}
|
||||
{% if granularity == 'Hour' %}
|
||||
FROM toStartOfHour(toDateTime('{{date_from}}')) TO toStartOfHour(toDateTime('{{date_to}}'))
|
||||
{% elsif granularity == 'Day' %}
|
||||
FROM toStartOfDay(toDateTime('{{date_from}}')) TO toStartOfDay(toDateTime('{{date_to}}'))
|
||||
{% else %}
|
||||
FROM toStartOfMonth(toDateTime('{{date_from}}')) TO toStartOfMonth(toDateTime('{{date_to}}'))
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
STEP
|
||||
{% if granularity == 'Hour' %}
|
||||
toIntervalHour(1)
|
||||
{% elsif granularity == 'Day' %}
|
||||
toIntervalDay(1)
|
||||
{% else %}
|
||||
toIntervalMonth(1)
|
||||
{% endif %}
|
56
etc/clickhouse/queries/key_metrics_periodic__v2.liquid
Executable file
56
etc/clickhouse/queries/key_metrics_periodic__v2.liquid
Executable file
|
@ -0,0 +1,56 @@
|
|||
SELECT
|
||||
{% if granularity == 'Hour' %}
|
||||
toStartOfHour(timestamp)
|
||||
{% elsif granularity == 'Day' %}
|
||||
toStartOfDay(timestamp)
|
||||
{% else %}
|
||||
toStartOfMonth(timestamp)
|
||||
{% endif %} AS Period,
|
||||
{% if granularity == 'Month' %}
|
||||
uniqExact(user_id) / (date_diff('day', Period, toLastDayOfMonth(Period)) + 1)
|
||||
{% else %}
|
||||
uniqExact(user_id)
|
||||
{% endif %} AS Users,
|
||||
uniqExact(session_id) AS Sessions,
|
||||
count() AS Events
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if device_model %}
|
||||
AND device_model = '{{device_model}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP by Period
|
||||
ORDER BY Period ASC
|
||||
WITH FILL
|
||||
{% if date_from and date_to %}
|
||||
{% if granularity == 'Hour' %}
|
||||
FROM toStartOfHour(toDateTime('{{date_from}}')) TO toStartOfHour(toDateTime('{{date_to}}'))
|
||||
{% elsif granularity == 'Day' %}
|
||||
FROM toStartOfDay(toDateTime('{{date_from}}')) TO toStartOfDay(toDateTime('{{date_to}}'))
|
||||
{% else %}
|
||||
FROM toStartOfMonth(toDateTime('{{date_from}}')) TO toStartOfMonth(toDateTime('{{date_to}}'))
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
STEP
|
||||
{% if granularity == 'Hour' %}
|
||||
toIntervalHour(1)
|
||||
{% elsif granularity == 'Day' %}
|
||||
toIntervalDay(1)
|
||||
{% else %}
|
||||
toIntervalMonth(1)
|
||||
{% endif %}
|
14
etc/clickhouse/queries/live_geo__v1.liquid
Executable file
14
etc/clickhouse/queries/live_geo__v1.liquid
Executable file
|
@ -0,0 +1,14 @@
|
|||
SELECT uniq(user_id) as Users,
|
||||
country_code as CountryCode,
|
||||
region_name as RegionName
|
||||
FROM (
|
||||
SELECT session_id,
|
||||
min(min_timestamp) AS timestamp,
|
||||
any(user_id) AS user_id,
|
||||
any(country_code) AS country_code,
|
||||
any(region_name) AS region_name
|
||||
FROM sessions_live_v1
|
||||
WHERE app_id = '{{app_id}}'
|
||||
GROUP BY session_id
|
||||
HAVING timestamp >= now() - INTERVAL 1 HOUR
|
||||
) GROUP BY country_code, region_name
|
18
etc/clickhouse/queries/live_session_details__v1.liquid
Executable file
18
etc/clickhouse/queries/live_session_details__v1.liquid
Executable file
|
@ -0,0 +1,18 @@
|
|||
SELECT session_id as Id,
|
||||
countMerge(events_count) as EventsCount,
|
||||
min(min_timestamp) as StartedAt,
|
||||
max(max_timestamp) - min(min_timestamp) as Duration,
|
||||
countMerge(events_count) as EventsCount,
|
||||
any(app_version) as AppVersion,
|
||||
any(country_code) as CountryCode,
|
||||
any(region_name) as RegionName,
|
||||
any(os_name) as OsName,
|
||||
any(os_version) as OsVersion,
|
||||
groupArrayMerge(events_name) as EventsName,
|
||||
groupArrayMerge(events_timestamp) as EventsTimestamp,
|
||||
groupArrayMerge(events_string_props) as EventsStringProps,
|
||||
groupArrayMerge(events_numeric_props) as EventsNumericProps
|
||||
FROM sessions_live_v1
|
||||
WHERE app_id = '{{app_id}}'
|
||||
AND session_id = '{{session_id}}'
|
||||
GROUP BY session_id
|
15
etc/clickhouse/queries/live_sessions__v1.liquid
Executable file
15
etc/clickhouse/queries/live_sessions__v1.liquid
Executable file
|
@ -0,0 +1,15 @@
|
|||
SELECT session_id as Id,
|
||||
min(min_timestamp) as StartedAt,
|
||||
max(max_timestamp) - min(min_timestamp) as Duration,
|
||||
countMerge(events_count) as EventsCount,
|
||||
any(app_version) as AppVersion,
|
||||
any(country_code) as CountryCode,
|
||||
any(region_name) as RegionName,
|
||||
any(os_name) as OsName,
|
||||
any(os_version) as OsVersion
|
||||
FROM sessions_live_v1
|
||||
WHERE app_id = '{{app_id}}'
|
||||
GROUP BY session_id
|
||||
HAVING StartedAt >= now() - INTERVAL 1 HOUR
|
||||
ORDER BY Duration DESC
|
||||
LIMIT 6
|
9
etc/clickhouse/queries/monthly_usage__v1.liquid
Executable file
9
etc/clickhouse/queries/monthly_usage__v1.liquid
Executable file
|
@ -0,0 +1,9 @@
|
|||
SELECT toYear(Date) as Year, toMonth(Date) as Month, Events
|
||||
FROM (
|
||||
SELECT period as Date,
|
||||
countMerge(events) as Events
|
||||
FROM monthly_usage_v1
|
||||
WHERE app_id = '{{app_id}}'
|
||||
GROUP BY period
|
||||
ORDER BY period WITH FILL TO toStartOfMonth(now() + INTERVAL 1 MONTH) STEP toIntervalMonth(1)
|
||||
) ORDER BY Year DESC, Month DESC
|
26
etc/clickhouse/queries/top_n__v1.liquid
Executable file
26
etc/clickhouse/queries/top_n__v1.liquid
Executable file
|
@ -0,0 +1,26 @@
|
|||
SELECT {{name_column}} as Name,
|
||||
{% if value_column == 'UniqueSessions' %}
|
||||
uniqExact(session_id) as Value
|
||||
{% else %}
|
||||
count() as Value
|
||||
{% endif %}
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP BY Name
|
||||
ORDER BY Value DESC
|
29
etc/clickhouse/queries/top_n__v2.liquid
Executable file
29
etc/clickhouse/queries/top_n__v2.liquid
Executable file
|
@ -0,0 +1,29 @@
|
|||
SELECT {{name_column}} as Name,
|
||||
{% if value_column == 'UniqueSessions' %}
|
||||
uniqExact(session_id) as Value
|
||||
{% else %}
|
||||
count() as Value
|
||||
{% endif %}
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if device_model %}
|
||||
AND device_model = '{{device_model}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
GROUP BY Name
|
||||
ORDER BY Value DESC
|
34
etc/clickhouse/queries/top_props__v1.liquid
Executable file
34
etc/clickhouse/queries/top_props__v1.liquid
Executable file
|
@ -0,0 +1,34 @@
|
|||
SELECT string_arr.1 AS StringKey,
|
||||
string_arr.2 AS StringValue,
|
||||
numeric_arr.1 AS NumericKey,
|
||||
count() AS Events,
|
||||
median(numeric_arr.2) AS Median,
|
||||
min(numeric_arr.2) AS Min,
|
||||
max(numeric_arr.2) AS Max,
|
||||
sum(numeric_arr.2) AS Sum
|
||||
FROM (
|
||||
SELECT * FROM (
|
||||
SELECT JSONExtractKeysAndValues(string_props, 'String') AS string_arr,
|
||||
JSONExtractKeysAndValues(numeric_props, 'Float') AS numeric_arr
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
) LEFT ARRAY JOIN string_arr
|
||||
) LEFT ARRAY JOIN numeric_arr
|
||||
GROUP BY StringKey, StringValue, NumericKey
|
||||
ORDER BY StringKey, Events DESC
|
37
etc/clickhouse/queries/top_props__v2.liquid
Executable file
37
etc/clickhouse/queries/top_props__v2.liquid
Executable file
|
@ -0,0 +1,37 @@
|
|||
SELECT string_arr.1 AS StringKey,
|
||||
string_arr.2 AS StringValue,
|
||||
numeric_arr.1 AS NumericKey,
|
||||
count() AS Events,
|
||||
median(numeric_arr.2) AS Median,
|
||||
min(numeric_arr.2) AS Min,
|
||||
max(numeric_arr.2) AS Max,
|
||||
sum(numeric_arr.2) AS Sum
|
||||
FROM (
|
||||
SELECT * FROM (
|
||||
SELECT JSONExtractKeysAndValues(string_props, 'String') AS string_arr,
|
||||
JSONExtractKeysAndValues(numeric_props, 'Float') AS numeric_arr
|
||||
FROM events
|
||||
PREWHERE app_id = '{{app_id}}'
|
||||
WHERE 1 = 1
|
||||
{% if date_from and date_to %}
|
||||
AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}'
|
||||
{% endif %}
|
||||
{% if country_code %}
|
||||
AND country_code = '{{country_code}}'
|
||||
{% endif %}
|
||||
{% if event_name %}
|
||||
AND event_name = '{{event_name}}'
|
||||
{% endif %}
|
||||
{% if os_name %}
|
||||
AND os_name = '{{os_name}}'
|
||||
{% endif %}
|
||||
{% if device_model %}
|
||||
AND device_model = '{{device_model}}'
|
||||
{% endif %}
|
||||
{% if app_version %}
|
||||
AND app_version = '{{app_version}}'
|
||||
{% endif %}
|
||||
) LEFT ARRAY JOIN string_arr
|
||||
) LEFT ARRAY JOIN numeric_arr
|
||||
GROUP BY StringKey, StringValue, NumericKey
|
||||
ORDER BY StringKey, Events DESC
|
BIN
etc/geoip/GeoLite2-City.mmdb
Executable file
BIN
etc/geoip/GeoLite2-City.mmdb
Executable file
Binary file not shown.
After Width: | Height: | Size: 68 MiB |
14886
etc/geoip/coordinates.json
Executable file
14886
etc/geoip/coordinates.json
Executable file
File diff suppressed because it is too large
Load diff
3411
etc/geoip/iso3166-2.json
Executable file
3411
etc/geoip/iso3166-2.json
Executable file
File diff suppressed because it is too large
Load diff
48
src/Aptabase.csproj
Executable file
48
src/Aptabase.csproj
Executable file
|
@ -0,0 +1,48 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateProgramFile>false</GenerateProgramFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="8.0.5" />
|
||||
<PackageReference Include="Npgsql.DependencyInjection" Version="8.0.5" />
|
||||
<PackageReference Include="ClickHouse.Client" Version="7.8.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Nanoid" Version="3.1.0" />
|
||||
<PackageReference Include="FastHashes" Version="3.5.0" />
|
||||
<PackageReference Include="FluentMigrator" Version="6.2.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner" Version="6.2.0" />
|
||||
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="6.2.0" />
|
||||
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.30" />
|
||||
<PackageReference Include="AWSSDK.SSO" Version="3.7.400.40" />
|
||||
<PackageReference Include="AWSSDK.SSOOIDC" Version="3.7.400.40" />
|
||||
<PackageReference Include="Amazon.AspNetCore.DataProtection.SSM" Version="3.2.1" />
|
||||
<PackageReference Include="Sgbj.Cron.CronTimer" Version="1.0.2" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
|
||||
<PackageReference Include="Yoh.Text.Json.NamingPolicies" Version="1.1.2" />
|
||||
<PackageReference Include="Scriban" Version="5.10.0" />
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="8.2.2" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="assets\Templates\*.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../etc/geoip/**" CopyToOutputDirectory="PreserveNewest" LinkBase="etc/geoip" />
|
||||
<None Include="../etc/clickhouse/**" CopyToOutputDirectory="PreserveNewest" LinkBase="etc/clickhouse" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
36
src/Data/ClickHouseMigrationRunner.cs
Executable file
36
src/Data/ClickHouseMigrationRunner.cs
Executable file
|
@ -0,0 +1,36 @@
|
|||
using Dapper;
|
||||
using ClickHouse.Client.ADO;
|
||||
using Aptabase.Features;
|
||||
|
||||
namespace Aptabase.Data;
|
||||
|
||||
public interface IClickHouseMigrationRunner
|
||||
{
|
||||
void MigrateUp();
|
||||
}
|
||||
|
||||
public class ClickHouseMigrationRunner : IClickHouseMigrationRunner
|
||||
{
|
||||
private readonly ClickHouseConnection _conn;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly ILogger<ClickHouseMigrationRunner> _logger;
|
||||
|
||||
public ClickHouseMigrationRunner(ClickHouseConnection conn, EnvSettings env, ILogger<ClickHouseMigrationRunner> logger)
|
||||
{
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_conn = conn ?? throw new ArgumentNullException(nameof(conn));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void MigrateUp()
|
||||
{
|
||||
var pathToMigrations = Path.Combine(_env.EtcDirectoryPath, "clickhouse");
|
||||
var files = Directory.GetFiles(pathToMigrations).OrderBy(x => x);
|
||||
foreach (string file in files)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
_logger.LogDebug($"Executing ClickHouse migration: {file}");
|
||||
_conn.Execute(content);
|
||||
}
|
||||
}
|
||||
}
|
20
src/Data/DbContext.cs
Executable file
20
src/Data/DbContext.cs
Executable file
|
@ -0,0 +1,20 @@
|
|||
using System.Data.Common;
|
||||
|
||||
namespace Aptabase.Data;
|
||||
|
||||
public interface IDbContext
|
||||
{
|
||||
DbConnection Connection { get; }
|
||||
}
|
||||
|
||||
public class DbContext : IDbContext
|
||||
{
|
||||
private readonly DbDataSource _ds;
|
||||
|
||||
public DbContext(DbDataSource ds)
|
||||
{
|
||||
_ds = ds ?? throw new ArgumentNullException(nameof(ds));
|
||||
}
|
||||
|
||||
public DbConnection Connection => _ds.CreateConnection();
|
||||
}
|
44
src/Data/Migrations/0001_InitialSetup.cs
Executable file
44
src/Data/Migrations/0001_InitialSetup.cs
Executable file
|
@ -0,0 +1,44 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0001)]
|
||||
public class InitialSetup : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("apps")
|
||||
.WithNanoIdColumn("id").PrimaryKey()
|
||||
.WithNanoIdColumn("owner_id")
|
||||
.WithColumn("name").AsString(40).NotNullable()
|
||||
.WithColumn("app_key").AsString(20).NotNullable()
|
||||
.WithColumn("deleted_at").AsDateTime().Nullable()
|
||||
.WithTimestamps();
|
||||
|
||||
Create.Index("idx_apps_owner")
|
||||
.OnTable("apps")
|
||||
.OnColumn("owner_id").Ascending();
|
||||
|
||||
Create.Index("idx_apps_key")
|
||||
.OnTable("apps")
|
||||
.OnColumn("app_key").Ascending()
|
||||
.WithOptions().Unique();
|
||||
|
||||
Create.Table("users")
|
||||
.WithNanoIdColumn("id").PrimaryKey()
|
||||
.WithColumn("name").AsString(40).NotNullable()
|
||||
.WithColumn("email").AsString(300).NotNullable()
|
||||
.WithTimestamps();
|
||||
|
||||
Create.Index("idx_users_email")
|
||||
.OnTable("users")
|
||||
.OnColumn("email")
|
||||
.Unique();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table("apps");
|
||||
Delete.Table("users");
|
||||
}
|
||||
}
|
21
src/Data/Migrations/0002_UserProvider.cs
Executable file
21
src/Data/Migrations/0002_UserProvider.cs
Executable file
|
@ -0,0 +1,21 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0002)]
|
||||
public class UserProvider : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("user_providers")
|
||||
.WithColumn("provider_name").AsString(40).NotNullable().PrimaryKey()
|
||||
.WithColumn("provider_uid").AsString(40).NotNullable().PrimaryKey()
|
||||
.WithNanoIdColumn("user_id").ForeignKey("users", "id")
|
||||
.WithTimestamps();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table("user_providers");
|
||||
}
|
||||
}
|
25
src/Data/Migrations/0003_AddBlobs.cs
Executable file
25
src/Data/Migrations/0003_AddBlobs.cs
Executable file
|
@ -0,0 +1,25 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0003)]
|
||||
public class AddBlobs : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("blobs")
|
||||
.WithColumn("path").AsString(80).NotNullable().PrimaryKey()
|
||||
.WithColumn("content").AsBinary().NotNullable()
|
||||
.WithColumn("content_type").AsString(80).NotNullable()
|
||||
.WithTimestamps();
|
||||
|
||||
Alter.Table("apps")
|
||||
.AddColumn("icon_path").AsString(80).Nullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Column("icon_path").FromTable("apps");
|
||||
Delete.Table("blobs");
|
||||
}
|
||||
}
|
21
src/Data/Migrations/0004_AddAppSalt.cs
Executable file
21
src/Data/Migrations/0004_AddAppSalt.cs
Executable file
|
@ -0,0 +1,21 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0004)]
|
||||
public class AddAppSalt : Migration
|
||||
{
|
||||
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("app_salts")
|
||||
.WithNanoIdColumn("app_id").ForeignKey("apps", "id").PrimaryKey()
|
||||
.WithColumn("date").AsString(10).NotNullable().PrimaryKey()
|
||||
.WithColumn("salt").AsBinary(16).NotNullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table("app_salts");
|
||||
}
|
||||
}
|
21
src/Data/Migrations/0005_AppShares.cs
Executable file
21
src/Data/Migrations/0005_AppShares.cs
Executable file
|
@ -0,0 +1,21 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0005)]
|
||||
public class AppShares : Migration
|
||||
{
|
||||
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("app_shares")
|
||||
.WithNanoIdColumn("app_id").ForeignKey("apps", "id").PrimaryKey()
|
||||
.WithColumn("email").AsString(300).NotNullable().PrimaryKey()
|
||||
.WithTimestamps();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table("app_shares");
|
||||
}
|
||||
}
|
30
src/Data/Migrations/0006_AddSubscriptions.cs
Executable file
30
src/Data/Migrations/0006_AddSubscriptions.cs
Executable file
|
@ -0,0 +1,30 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0006)]
|
||||
public class AddSubscriptions : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("subscriptions")
|
||||
.WithColumn("id").AsInt64().NotNullable().PrimaryKey()
|
||||
.WithNanoIdColumn("owner_id").NotNullable()
|
||||
.WithColumn("customer_id").AsInt64().NotNullable()
|
||||
.WithColumn("product_id").AsInt64().NotNullable()
|
||||
.WithColumn("variant_id").AsInt64().NotNullable()
|
||||
.WithColumn("status").AsString().NotNullable()
|
||||
.WithColumn("ends_at").AsDateTimeOffset().Nullable()
|
||||
.WithTimestamps();
|
||||
|
||||
Create.Index("idx_subscriptions_owner")
|
||||
.OnTable("subscriptions")
|
||||
.OnColumn("owner_id");
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Index("idx_subscriptions_owner");
|
||||
Delete.Table("billing");
|
||||
}
|
||||
}
|
23
src/Data/Migrations/0007_AddOnboardingFlag.cs
Executable file
23
src/Data/Migrations/0007_AddOnboardingFlag.cs
Executable file
|
@ -0,0 +1,23 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0007)]
|
||||
public class AddOnboardingFlag : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Alter.Table("apps")
|
||||
.AddColumn("has_events").AsBoolean().Nullable();
|
||||
|
||||
Execute.Sql("UPDATE apps SET has_events = true");
|
||||
|
||||
Alter.Table("apps")
|
||||
.AlterColumn("has_events").AsBoolean().NotNullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Column("has_events").FromTable("apps");
|
||||
}
|
||||
}
|
18
src/Data/Migrations/0008_AddLockReason.cs
Executable file
18
src/Data/Migrations/0008_AddLockReason.cs
Executable file
|
@ -0,0 +1,18 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0008)]
|
||||
public class AddLockReason : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Alter.Table("users")
|
||||
.AddColumn("lock_reason").AsFixedLengthString(1).Nullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Column("lock_reason").FromTable("users");
|
||||
}
|
||||
}
|
20
src/Data/Migrations/0009_AddFreeTrial.cs
Executable file
20
src/Data/Migrations/0009_AddFreeTrial.cs
Executable file
|
@ -0,0 +1,20 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0009)]
|
||||
public class AddFreeTrial : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Alter.Table("users")
|
||||
.AddColumn("free_trial_ends_at").AsDateTimeOffset().Nullable()
|
||||
.AddColumn("free_quota").AsInt64().Nullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Column("free_trial_ends_at").FromTable("users");
|
||||
Delete.Column("free_quota").FromTable("users");
|
||||
}
|
||||
}
|
20
src/Data/Migrations/0010_AddCache.cs
Executable file
20
src/Data/Migrations/0010_AddCache.cs
Executable file
|
@ -0,0 +1,20 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0010)]
|
||||
public class AddCache : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("cache")
|
||||
.WithColumn("key").AsString(100).NotNullable().PrimaryKey()
|
||||
.WithColumn("value").AsCustom("TEXT").NotNullable()
|
||||
.WithColumn("expires_at").AsDateTimeOffset().NotNullable();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table("cache");
|
||||
}
|
||||
}
|
31
src/Data/Migrations/0011_AddFeatureFlags.cs
Executable file
31
src/Data/Migrations/0011_AddFeatureFlags.cs
Executable file
|
@ -0,0 +1,31 @@
|
|||
using FluentMigrator;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[Migration(0011)]
|
||||
public class AddFeatureFlags : Migration
|
||||
{
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table("feature_flags")
|
||||
.WithNanoIdColumn("id").PrimaryKey()
|
||||
.WithNanoIdColumn("app_id").ForeignKey("apps", "id")
|
||||
.WithColumn("key").AsString(255).NotNullable()
|
||||
.WithColumn("value").AsString(4000).NotNullable()
|
||||
.WithColumn("environment").AsString(50).NotNullable()
|
||||
.WithColumn("conditions").AsString(4000).NotNullable()
|
||||
.WithTimestamps();
|
||||
|
||||
Create.Index("idx_feature_flags_app_key_env")
|
||||
.OnTable("feature_flags")
|
||||
.OnColumn("app_id").Ascending()
|
||||
.OnColumn("key").Ascending()
|
||||
.OnColumn("environment").Ascending();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Index("idx_feature_flags_app_key_env");
|
||||
Delete.Table("feature_flags");
|
||||
}
|
||||
}
|
22
src/Data/Migrations/MigrationExtensions.cs
Executable file
22
src/Data/Migrations/MigrationExtensions.cs
Executable file
|
@ -0,0 +1,22 @@
|
|||
using FluentMigrator;
|
||||
using FluentMigrator.Builders.Create.Table;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
internal static class MigrationExtensions
|
||||
{
|
||||
public static ICreateTableColumnOptionOrWithColumnSyntax WithNanoIdColumn(this ICreateTableWithColumnSyntax tableWithColumnSyntax, string name)
|
||||
{
|
||||
return tableWithColumnSyntax
|
||||
.WithColumn(name)
|
||||
.AsString(22)
|
||||
.NotNullable();
|
||||
}
|
||||
|
||||
public static ICreateTableColumnOptionOrWithColumnSyntax WithTimestamps(this ICreateTableWithColumnSyntax tableWithColumnSyntax)
|
||||
{
|
||||
return tableWithColumnSyntax
|
||||
.WithColumn("created_at").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime).NotNullable()
|
||||
.WithColumn("modified_at").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime).NotNullable();
|
||||
}
|
||||
}
|
17
src/Data/Migrations/VersionInfoTable.cs
Executable file
17
src/Data/Migrations/VersionInfoTable.cs
Executable file
|
@ -0,0 +1,17 @@
|
|||
using FluentMigrator.Runner.VersionTableInfo;
|
||||
|
||||
namespace Aptabase.Data.Migrations;
|
||||
|
||||
[VersionTableMetaData]
|
||||
public class VersionTable : IVersionTableMetaData
|
||||
{
|
||||
public string ColumnName => "version";
|
||||
public string SchemaName => "";
|
||||
public string TableName => "migration_history";
|
||||
public string UniqueIndexName => "idx_version";
|
||||
public string AppliedOnColumnName => "applied_at";
|
||||
public string DescriptionColumnName => "description";
|
||||
public object? ApplicationContext { get; set; }
|
||||
public bool OwnsSchema => true;
|
||||
public bool CreateWithPrimaryKey => false;
|
||||
}
|
13
src/Data/NanoId.cs
Executable file
13
src/Data/NanoId.cs
Executable file
|
@ -0,0 +1,13 @@
|
|||
using NanoidDotNet;
|
||||
|
||||
namespace Aptabase.Data;
|
||||
|
||||
public class NanoId
|
||||
{
|
||||
private const string ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
private const string NUMBERS = "0123456789";
|
||||
|
||||
public static string New(string preffix) => $"{preffix}{Nanoid.Generate(ALPHABET, 22 - preffix.Length)}";
|
||||
public static string New() => New("");
|
||||
public static string Numbers(int len) => Nanoid.Generate(NUMBERS, len);
|
||||
}
|
57
src/Extensions/ExceptionMiddleware.cs
Executable file
57
src/Extensions/ExceptionMiddleware.cs
Executable file
|
@ -0,0 +1,57 @@
|
|||
// Custom Exception Middleware to catch common exceptions caused by client disconnects
|
||||
// Some conditions can be removed on .NET 8 because of https://github.com/dotnet/aspnetcore/pull/46330
|
||||
using System.Net;
|
||||
|
||||
public class ExceptionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (TaskCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
context.Response.StatusCode = 418; // I'm a teapot
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
context.Response.StatusCode = 418; // I'm a teapot
|
||||
}
|
||||
catch (BadHttpRequestException ex) when (context.RequestAborted.IsCancellationRequested || ex.StatusCode == (int)HttpStatusCode.RequestTimeout)
|
||||
{
|
||||
context.Response.StatusCode = 418; // I'm a teapot
|
||||
}
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
catch (Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException)
|
||||
{
|
||||
context.Response.StatusCode = 418; // I'm a teapot
|
||||
}
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
context.Response.StatusCode = (int)(ex.StatusCode ?? HttpStatusCode.InternalServerError);
|
||||
|
||||
_logger.LogError(ex, "Dependency error on {Path}", context.Request.Path.Value);
|
||||
}
|
||||
catch (Microsoft.AspNetCore.Authentication.AuthenticationFailureException)
|
||||
{
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
|
||||
_logger.LogError(ex, "Unexpected error on {Path}", context.Request.Path.Value);
|
||||
}
|
||||
}
|
||||
}
|
36
src/Extensions/HttpExtensions.cs
Executable file
36
src/Extensions/HttpExtensions.cs
Executable file
|
@ -0,0 +1,36 @@
|
|||
namespace Microsoft.AspNetCore.Http;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
public static string ResolveClientIpAddress(this HttpContext httpContext)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue("X-Real-Ip", out var ip) && !string.IsNullOrEmpty(ip))
|
||||
return ip.ToString();
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedIp) && !string.IsNullOrEmpty(forwardedIp))
|
||||
return forwardedIp.ToString();
|
||||
|
||||
var cfViewerAddress = httpContext.Request.Headers["CloudFront-Viewer-Address"];
|
||||
if (cfViewerAddress.Count > 0)
|
||||
{
|
||||
var parts = (cfViewerAddress[0] ?? string.Empty).Split(":");
|
||||
if (parts.Length == 1)
|
||||
return parts[0];
|
||||
|
||||
if (parts.Length >= 1)
|
||||
return string.Join(":", parts[0..^1]);
|
||||
}
|
||||
|
||||
return httpContext.Connection.RemoteIpAddress?.ToString() ?? "";
|
||||
}
|
||||
|
||||
public static async Task EnsureSuccessWithLog(this HttpResponseMessage response, ILogger logger)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
logger.LogError("Request failed with {StatusCode} with body {ResponseBody}", response.StatusCode, responseBody);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
}
|
15
src/Extensions/StringExtensions.cs
Executable file
15
src/Extensions/StringExtensions.cs
Executable file
|
@ -0,0 +1,15 @@
|
|||
namespace System;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static string Truncate(this string value, int maxLength, string suffix = "...")
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
|
||||
if (value.Length <= maxLength)
|
||||
return value;
|
||||
|
||||
return $"{value[..(maxLength - suffix.Length)]}{suffix}";
|
||||
}
|
||||
}
|
108
src/Extensions/TelemetryExtensions.cs
Executable file
108
src/Extensions/TelemetryExtensions.cs
Executable file
|
@ -0,0 +1,108 @@
|
|||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddAspNetCoreInstrumentation()
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks("/alive", new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
74
src/Features/Apps/AppQueries.cs
Executable file
74
src/Features/Apps/AppQueries.cs
Executable file
|
@ -0,0 +1,74 @@
|
|||
using Aptabase.Data;
|
||||
using Dapper;
|
||||
|
||||
namespace Aptabase.Features.Apps;
|
||||
|
||||
public class Application
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string AppKey { get; set; } = "";
|
||||
public string IconPath { get; set; } = "";
|
||||
public bool HasEvents { get; set; } = false;
|
||||
public bool HasOwnership { get; set; } = false;
|
||||
public char? LockReason { get; set; }
|
||||
}
|
||||
|
||||
public interface IAppQueries
|
||||
{
|
||||
Task<Application?> GetActiveAppByAppKey(string appKey, CancellationToken cancellationToken);
|
||||
Task MaskAsOnboarded(string appId, CancellationToken cancellationToken);
|
||||
Task<Application?> GetOwnedAppAsync(string appId, string userId);
|
||||
}
|
||||
|
||||
public class AppQueries : IAppQueries
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
|
||||
public AppQueries(IDbContext db)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
}
|
||||
|
||||
public Task<Application?> GetActiveAppByAppKey(string appKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition(@"
|
||||
SELECT a.id, a.name, a.icon_path,
|
||||
a.app_key, a.has_events,
|
||||
u.lock_reason
|
||||
FROM apps a
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE a.app_key = @appKey AND a.deleted_at IS NULL",
|
||||
new { appKey },
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
return _db.Connection.QueryFirstOrDefaultAsync<Application?>(cmd);
|
||||
}
|
||||
|
||||
public async Task MaskAsOnboarded(string appId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition($"UPDATE apps SET has_events = true WHERE id = @appId",
|
||||
new { appId },
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
await _db.Connection.ExecuteAsync(cmd);
|
||||
}
|
||||
|
||||
public async Task<Application?> GetOwnedAppAsync(string appId, string userId)
|
||||
{
|
||||
return await _db.Connection.QueryFirstOrDefaultAsync<Application>(
|
||||
@"SELECT a.id, a.name, a.icon_path, a.app_key, true as has_ownership,
|
||||
a.has_events, u.lock_reason
|
||||
FROM apps a
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE a.id = @appId
|
||||
AND a.owner_id = @userId
|
||||
AND a.deleted_at IS NULL",
|
||||
new { appId, userId }
|
||||
);
|
||||
}
|
||||
}
|
222
src/Features/Apps/AppsController.cs
Executable file
222
src/Features/Apps/AppsController.cs
Executable file
|
@ -0,0 +1,222 @@
|
|||
using Dapper;
|
||||
using Aptabase.Data;
|
||||
using Aptabase.Features.Blob;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Aptabase.Features.Authentication;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Aptabase.Features.Apps;
|
||||
|
||||
public class ApplicationShare
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateAppRequestBody
|
||||
{
|
||||
[Required]
|
||||
[StringLength(40, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpdateAppRequestBody
|
||||
{
|
||||
public string Icon { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(40, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
[ApiController, IsAuthenticated]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class AppsController : Controller
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly IBlobService _blobService;
|
||||
|
||||
public AppsController(IDbContext db, EnvSettings env, IBlobService blobService)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_blobService = blobService ?? throw new ArgumentNullException(nameof(blobService));
|
||||
}
|
||||
|
||||
[HttpGet("/api/_apps")]
|
||||
public async Task<IActionResult> ListApps()
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var apps = await _db.Connection.QueryAsync<Application>(
|
||||
@"SELECT a.id, a.name, a.icon_path, a.app_key,
|
||||
a.owner_id = @userId AS has_ownership, a.has_events,
|
||||
u.lock_reason
|
||||
FROM apps a
|
||||
LEFT JOIN app_shares s
|
||||
ON s.app_id = a.id
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE (a.owner_id = @userId OR s.email = @userEmail)
|
||||
AND a.deleted_at IS NULL
|
||||
GROUP BY a.id, a.name, a.icon_path, a.app_key, u.lock_reason
|
||||
ORDER by a.name", new { userId = user.Id, userEmail = user.Email });
|
||||
|
||||
return Ok(apps);
|
||||
}
|
||||
|
||||
[HttpPost("/api/_apps")]
|
||||
public async Task<IActionResult> Create([FromBody] CreateAppRequestBody body)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
var app = new Application
|
||||
{
|
||||
Id = NanoId.New(),
|
||||
Name = body.Name,
|
||||
AppKey = $"A-{_env.Region}-{NanoId.Numbers(10)}"
|
||||
};
|
||||
|
||||
await _db.Connection.ExecuteScalarAsync<string>(@"
|
||||
INSERT INTO apps (id, owner_id, name, app_key, has_events)
|
||||
VALUES (@appId, @ownerId, @name, @appKey, false)",
|
||||
new
|
||||
{
|
||||
appId = app.Id,
|
||||
ownerId = user.Id,
|
||||
name = app.Name,
|
||||
appKey = app.AppKey
|
||||
});
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpGet("/api/_apps/{appId}")]
|
||||
public async Task<IActionResult> GetAppById(string appId)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var app = await _db.Connection.QueryFirstOrDefaultAsync<Application>(
|
||||
@"SELECT a.id, a.name, a.icon_path, a.app_key,
|
||||
a.owner_id = @userId as has_ownership, a.has_events,
|
||||
u.lock_reason
|
||||
FROM apps a
|
||||
LEFT JOIN app_shares s
|
||||
ON s.app_id = a.id
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE a.id = @appId
|
||||
AND (a.owner_id = @userId OR s.email = @userEmail)
|
||||
AND a.deleted_at IS NULL
|
||||
GROUP BY a.id, a.name, a.icon_path, a.app_key, u.lock_reason
|
||||
ORDER by a.name",
|
||||
new { appId, userId = user.Id, userEmail = user.Email }
|
||||
);
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpPut("/api/_apps/{appId}")]
|
||||
public async Task<IActionResult> Update(string appId, [FromBody] UpdateAppRequestBody body, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = await GetOwnedApp(appId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
if (!string.IsNullOrEmpty(body.Icon))
|
||||
{
|
||||
var content = Convert.FromBase64String(body.Icon);
|
||||
app.IconPath = await _blobService.UploadAsync("icons", content, "image/png", cancellationToken);
|
||||
}
|
||||
|
||||
app.Name = body.Name;
|
||||
await _db.Connection.ExecuteScalarAsync<string>("UPDATE apps SET name = @name, icon_path = @iconPath WHERE id = @appId", new
|
||||
{
|
||||
appId = app.Id,
|
||||
name = app.Name,
|
||||
iconPath = app.IconPath,
|
||||
});
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/_apps/{appId}")]
|
||||
public async Task<IActionResult> Delete(string appId)
|
||||
{
|
||||
var app = await GetOwnedApp(appId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
await _db.Connection.ExecuteScalarAsync<string>("UPDATE apps SET deleted_at = now() WHERE id = @appId", new
|
||||
{
|
||||
appId = app.Id,
|
||||
});
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/_apps/{appId}/shares")]
|
||||
public async Task<IActionResult> ListAppShares(string appId)
|
||||
{
|
||||
var app = await GetOwnedApp(appId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var shares = await _db.Connection.QueryAsync<ApplicationShare>(
|
||||
@"SELECT email, created_at
|
||||
FROM app_shares
|
||||
WHERE app_id = @appId", new { appId });
|
||||
return Ok(shares);
|
||||
}
|
||||
|
||||
[HttpPut("/api/_apps/{appId}/shares/{email}")]
|
||||
public async Task<IActionResult> ShareApp(string appId, string email)
|
||||
{
|
||||
var app = await GetOwnedApp(appId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
await _db.Connection.ExecuteScalarAsync<string>(@"
|
||||
INSERT INTO app_shares (app_id, email)
|
||||
VALUES (@appId, @email)
|
||||
ON CONFLICT DO NOTHING", new
|
||||
{
|
||||
appId,
|
||||
email = email.ToLower(),
|
||||
});
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpDelete("/api/_apps/{appId}/shares/{email}")]
|
||||
public async Task<IActionResult> UnshareApp(string appId, string email)
|
||||
{
|
||||
var app = await GetOwnedApp(appId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
await _db.Connection.ExecuteScalarAsync<string>(@"DELETE FROM app_shares WHERE app_id = @appId AND email = @email", new
|
||||
{
|
||||
appId,
|
||||
email,
|
||||
});
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
private async Task<Application?> GetOwnedApp(string appId)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
return await _db.Connection.QueryFirstOrDefaultAsync<Application>(
|
||||
@"SELECT a.id, a.name, a.icon_path, a.app_key, true as has_ownership,
|
||||
a.has_events, u.lock_reason
|
||||
FROM apps a
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE a.id = @appId
|
||||
AND a.owner_id = @userId
|
||||
AND a.deleted_at IS NULL",
|
||||
new { appId, userId = user.Id }
|
||||
);
|
||||
}
|
||||
}
|
140
src/Features/Authentication/AuthController.cs
Executable file
140
src/Features/Authentication/AuthController.cs
Executable file
|
@ -0,0 +1,140 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public class SignInBodyRequest
|
||||
{
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
||||
public class RegisterBodyRequest
|
||||
{
|
||||
[StringLength(40, MinimumLength = 2)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class AuthController : Controller
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IAuthTokenManager _tokenManager;
|
||||
|
||||
public AuthController(
|
||||
ILogger<AuthController> logger,
|
||||
EnvSettings env,
|
||||
IAuthService authService,
|
||||
IAuthTokenManager tokenManager)
|
||||
{
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
|
||||
_tokenManager = tokenManager ?? throw new ArgumentNullException(nameof(tokenManager));
|
||||
}
|
||||
|
||||
[HttpPost("/api/_auth/signin")]
|
||||
public async Task<IActionResult> SignIn([FromBody] SignInBodyRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
var found = await _authService.SendSignInEmailAsync(body.Email.Trim(), cancellationToken);
|
||||
|
||||
if (!found)
|
||||
return NotFound(new { });
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/_auth/register")]
|
||||
[EnableRateLimiting("SignUp")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterBodyRequest body, CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.SendRegisterEmailAsync(body.Name.Trim(), body.Email.Trim(), cancellationToken);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/_auth/github")]
|
||||
public IActionResult GitHub()
|
||||
{
|
||||
return Challenge(new AuthenticationProperties { RedirectUri = $"{_env.SelfBaseUrl}/" }, "github");
|
||||
}
|
||||
|
||||
[HttpGet("/api/_auth/google")]
|
||||
public IActionResult Google()
|
||||
{
|
||||
return Challenge(new AuthenticationProperties { RedirectUri = $"{_env.SelfBaseUrl}/" }, "google");
|
||||
}
|
||||
|
||||
[HttpGet("/api/_auth/me")]
|
||||
[IsAuthenticated]
|
||||
[EnableCors("AllowAptabaseCom")]
|
||||
public async Task<IActionResult> Me(CancellationToken cancellationToken)
|
||||
{
|
||||
var identity = this.GetCurrentUserIdentity();
|
||||
var user = await _authService.FindUserByIdAsync(identity.Id, cancellationToken);
|
||||
if (user is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[HttpPost("/api/_auth/account/delete")]
|
||||
[IsAuthenticated]
|
||||
[EnableCors("AllowAptabaseCom")]
|
||||
public async Task<IActionResult> DeleteAccount(CancellationToken cancellationToken)
|
||||
{
|
||||
var identity = this.GetCurrentUserIdentity();
|
||||
var user = await _authService.FindUserByIdAsync(identity.Id, cancellationToken);
|
||||
if (user is null)
|
||||
return NotFound();
|
||||
|
||||
await _authService.DeleteUserByIdAsync(identity.Id, cancellationToken);
|
||||
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
[HttpPost("/api/_auth/signout")]
|
||||
public async Task<IActionResult> ForceSignOut()
|
||||
{
|
||||
await _authService.SignOutAsync();
|
||||
return Redirect($"{_env.SelfBaseUrl}/auth");
|
||||
}
|
||||
|
||||
[HttpGet("/api/_auth/continue")]
|
||||
public async Task<IActionResult> HandleMagicLink([FromQuery] string token, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = _tokenManager.ParseAuthToken(token);
|
||||
var user = await _authService.FindUserByEmailAsync(result.Email, cancellationToken);
|
||||
|
||||
if (result.Type == AuthTokenType.Register && user == null)
|
||||
user = await _authService.CreateAccountAsync(result.Name, result.Email, cancellationToken);
|
||||
|
||||
if (user != null)
|
||||
await _authService.SignInAsync(user);
|
||||
else
|
||||
_logger.LogError("Tried to authenticate user with email {email}, but account was not found", result.Email);
|
||||
}
|
||||
catch (SecurityTokenExpiredException)
|
||||
{
|
||||
return Redirect($"{_env.SelfBaseUrl}/auth?error=expired");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to validate auth token");
|
||||
return Redirect($"{_env.SelfBaseUrl}/auth?error=invalid");
|
||||
}
|
||||
|
||||
return Redirect($"{_env.SelfBaseUrl}/");
|
||||
}
|
||||
}
|
27
src/Features/Authentication/AuthExtensions.cs
Executable file
27
src/Features/Authentication/AuthExtensions.cs
Executable file
|
@ -0,0 +1,27 @@
|
|||
using Aptabase.Features.Authentication;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc;
|
||||
|
||||
public static class AuthExtensions
|
||||
{
|
||||
public static bool IsAuthenticated(this HttpContext context)
|
||||
{
|
||||
return context.User.Identity?.IsAuthenticated ?? false;
|
||||
}
|
||||
|
||||
public static UserIdentity GetCurrentUserIdentity(this Controller controller)
|
||||
{
|
||||
return controller.HttpContext.GetCurrentUserIdentity();
|
||||
}
|
||||
|
||||
public static UserIdentity GetCurrentUserIdentity(this HttpContext context)
|
||||
{
|
||||
if (!context.IsAuthenticated())
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var id = context.User.FindFirst(x => x.Type == "id")?.Value ?? "";
|
||||
var email = context.User.FindFirst(x => x.Type == "email")?.Value ?? "";
|
||||
var name = context.User.FindFirst(x => x.Type == "name")?.Value ?? "";
|
||||
return new UserIdentity(id, name, email);
|
||||
}
|
||||
}
|
221
src/Features/Authentication/AuthService.cs
Executable file
221
src/Features/Authentication/AuthService.cs
Executable file
|
@ -0,0 +1,221 @@
|
|||
using Aptabase.Features.Notification;
|
||||
using Aptabase.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using System.Security.Claims;
|
||||
using Dapper;
|
||||
|
||||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<bool> SendSignInEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task SendRegisterEmailAsync(string name, string email, CancellationToken cancellationToken);
|
||||
Task SignInAsync(UserAccount user);
|
||||
Task SignOutAsync();
|
||||
Task<UserAccount?> FindUserByIdAsync(string id, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken);
|
||||
Task<UserAccount?> FindUserByOAuthProviderAsync(string providerName, string providerUid, CancellationToken cancellationToken);
|
||||
Task<UserAccount> FindOrCreateAccountWithOAuthAsync(string name, string email, string providerName, string providerUid, CancellationToken cancellationToken);
|
||||
Task<UserAccount> CreateAccountAsync(string name, string email, CancellationToken cancellationToken);
|
||||
Task AttachUserAuthProviderAsync(UserAccount user, string providerName, string providerUid, CancellationToken cancellationToken);
|
||||
Task DeleteUserByIdAsync(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IDbContext _db;
|
||||
private readonly IAuthTokenManager _tokenManager;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly IEmailClient _emailClient;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public AuthService(
|
||||
ILogger<AuthService> logger,
|
||||
IDbContext db,
|
||||
EnvSettings env,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IEmailClient emailClient,
|
||||
IAuthTokenManager tokenManager)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_tokenManager = tokenManager ?? throw new ArgumentNullException(nameof(tokenManager));
|
||||
_emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient));
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db)); ;
|
||||
}
|
||||
|
||||
public async Task<bool> SendSignInEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await FindUserByEmailAsync(email, cancellationToken);
|
||||
if (user != null)
|
||||
{
|
||||
var token = _tokenManager.CreateAuthToken(AuthTokenType.SignIn, user.Name, user.Email);
|
||||
await _emailClient.SendEmailAsync(email, "Log in to Aptabase", "SignIn", new()
|
||||
{
|
||||
{ "name", user.Name },
|
||||
{ "url", GenerateAuthUrl(token) }
|
||||
}, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task SendRegisterEmailAsync(string name, string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await FindUserByEmailAsync(email, cancellationToken);
|
||||
if (user != null)
|
||||
{
|
||||
await SendSignInEmailAsync(email, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = _tokenManager.CreateAuthToken(AuthTokenType.Register, name, email);
|
||||
|
||||
await _emailClient.SendEmailAsync(email, "Confirm your registration", "Register", new()
|
||||
{
|
||||
{ "name", name },
|
||||
{ "url", GenerateAuthUrl(token) }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserAccount> CreateAccountAsync(string name, string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = NanoId.New();
|
||||
var cmd = new CommandDefinition(
|
||||
"INSERT INTO users (id, name, email, free_quota) VALUES (@userId, @name, @email, 20000)",
|
||||
new { userId, name, email = email.ToLower() },
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
await _db.Connection.ExecuteAsync(cmd);
|
||||
|
||||
return new UserAccount(new UserIdentity(userId, name, email));
|
||||
}
|
||||
|
||||
public async Task SignOutAsync()
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext == null)
|
||||
return;
|
||||
|
||||
await _httpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
public async Task SignInAsync(UserAccount user)
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext == null)
|
||||
{
|
||||
_logger.LogError("Tried to authenticate without an active HTTP Context");
|
||||
return;
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("id", user.Id),
|
||||
new("name", user.Name),
|
||||
new("email", user.Email),
|
||||
};
|
||||
|
||||
var claimsIdentity = new ClaimsIdentity(
|
||||
claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
var authProperties = new AuthenticationProperties
|
||||
{
|
||||
ExpiresUtc = DateTime.UtcNow.AddDays(365),
|
||||
IsPersistent = true,
|
||||
};
|
||||
|
||||
await _httpContextAccessor.HttpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(claimsIdentity),
|
||||
authProperties);
|
||||
}
|
||||
|
||||
public Task DeleteUserByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = @"
|
||||
BEGIN;
|
||||
DELETE FROM public.app_shares
|
||||
WHERE app_id IN (SELECT id FROM public.apps WHERE owner_id = @userId);
|
||||
|
||||
DELETE FROM public.app_salts
|
||||
WHERE app_id IN (SELECT id FROM public.apps WHERE owner_id = @userId);
|
||||
|
||||
DELETE FROM public.apps
|
||||
WHERE owner_id = @userId;
|
||||
|
||||
DELETE FROM public.user_providers
|
||||
WHERE user_id = @userId;
|
||||
|
||||
DELETE FROM public.subscriptions
|
||||
WHERE owner_id = @userId;
|
||||
|
||||
DELETE FROM public.users
|
||||
WHERE id = @userId;
|
||||
COMMIT;";
|
||||
|
||||
var cmd = new CommandDefinition(
|
||||
sql,
|
||||
new { userId = id },
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
|
||||
return _db.Connection.ExecuteAsync(cmd);
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindUserByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition($"SELECT id, name, email, lock_reason FROM users WHERE id = @id", new { id }, cancellationToken: cancellationToken);
|
||||
return await _db.Connection.QuerySingleOrDefaultAsync<UserAccount>(cmd);
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindUserByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition($"SELECT id, name, email, lock_reason FROM users WHERE email = @email", new { email = email.ToLower() }, cancellationToken: cancellationToken);
|
||||
return await _db.Connection.QuerySingleOrDefaultAsync<UserAccount>(cmd);
|
||||
}
|
||||
|
||||
public async Task<UserAccount> FindOrCreateAccountWithOAuthAsync(string name, string email, string providerName, string providerUid, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await FindUserByOAuthProviderAsync(providerName, providerUid, cancellationToken);
|
||||
if (user is not null)
|
||||
return user;
|
||||
|
||||
user = await FindUserByEmailAsync(email, cancellationToken);
|
||||
if (user is not null)
|
||||
{
|
||||
await AttachUserAuthProviderAsync(user, providerName, providerUid, cancellationToken);
|
||||
return user;
|
||||
}
|
||||
|
||||
user = await CreateAccountAsync(name, email, cancellationToken);
|
||||
await AttachUserAuthProviderAsync(user, providerName, providerUid, cancellationToken);
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<UserAccount?> FindUserByOAuthProviderAsync(string providerName, string providerUid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition($@"
|
||||
SELECT u.id, u.name, u.email, u.lock_reason
|
||||
FROM user_providers up
|
||||
INNER JOIN users u
|
||||
ON u.id = up.user_id
|
||||
WHERE up.provider_name = @name
|
||||
AND up.provider_uid = @uid", new { name = providerName, uid = providerUid }, cancellationToken: cancellationToken);
|
||||
return await _db.Connection.QuerySingleOrDefaultAsync<UserAccount>(cmd);
|
||||
}
|
||||
|
||||
public Task AttachUserAuthProviderAsync(UserAccount user, string providerName, string providerUid, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition($@"
|
||||
INSERT INTO user_providers (provider_name, provider_uid, user_id)
|
||||
VALUES (@providerName, @providerUid, @userId)", new { userId = user.Id, providerName, providerUid }, cancellationToken: cancellationToken);
|
||||
return _db.Connection.ExecuteAsync(cmd);
|
||||
}
|
||||
|
||||
private string GenerateAuthUrl(string token) => $"{_env.SelfBaseUrl}/api/_auth/continue?token={token}";
|
||||
}
|
84
src/Features/Authentication/AuthTokenManager.cs
Executable file
84
src/Features/Authentication/AuthTokenManager.cs
Executable file
|
@ -0,0 +1,84 @@
|
|||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public enum AuthTokenType
|
||||
{
|
||||
SignIn,
|
||||
Register
|
||||
}
|
||||
|
||||
public class ParsedAuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
||||
public interface IAuthTokenManager
|
||||
{
|
||||
string CreateAuthToken(AuthTokenType type, string name, string email);
|
||||
ParsedAuthToken ParseAuthToken(string token);
|
||||
}
|
||||
|
||||
public class AuthTokenManager : IAuthTokenManager
|
||||
{
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler();
|
||||
private readonly SymmetricSecurityKey _signingKey;
|
||||
private readonly string _issuer;
|
||||
|
||||
public AuthTokenManager(EnvSettings env)
|
||||
{
|
||||
_issuer = $"aptabase-{env.Region.ToLower()}";
|
||||
_signingKey = new SymmetricSecurityKey(env.AuthSecret);
|
||||
}
|
||||
|
||||
public string CreateAuthToken(AuthTokenType type, string name, string email)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("type", type.ToString()),
|
||||
new Claim("name", name),
|
||||
new Claim("email", email)
|
||||
};
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Issuer = _issuer,
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.Add(TimeSpan.FromMinutes(15)),
|
||||
SigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256)
|
||||
};
|
||||
|
||||
var token = _tokenHandler.CreateToken(tokenDescriptor);
|
||||
return _tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
public ParsedAuthToken ParseAuthToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
throw new ArgumentNullException(nameof(token));
|
||||
|
||||
_tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
|
||||
ValidIssuer = _issuer,
|
||||
ValidateIssuer = true,
|
||||
IssuerSigningKey = _signingKey,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
}, out SecurityToken validatedToken);
|
||||
|
||||
var jwtToken = (JwtSecurityToken)validatedToken;
|
||||
|
||||
return new ParsedAuthToken
|
||||
{
|
||||
Type = (AuthTokenType)Enum.Parse(typeof(AuthTokenType), jwtToken.Claims.First(x => x.Type == "type").Value),
|
||||
Name = jwtToken.Claims.First(x => x.Type == "name").Value,
|
||||
Email = jwtToken.Claims.First(x => x.Type == "email").Value
|
||||
};
|
||||
}
|
||||
}
|
18
src/Features/Authentication/IsAuthenticated.cs
Executable file
18
src/Features/Authentication/IsAuthenticated.cs
Executable file
|
@ -0,0 +1,18 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public class IsAuthenticatedAttribute : ActionFilterAttribute
|
||||
{
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
if (!context.HttpContext.IsAuthenticated())
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnActionExecuting(context);
|
||||
}
|
||||
}
|
164
src/Features/Authentication/OAuthExtensions.cs
Executable file
164
src/Features/Authentication/OAuthExtensions.cs
Executable file
|
@ -0,0 +1,164 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Aptabase.Features;
|
||||
using Aptabase.Features.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
|
||||
namespace Microsoft.AspNetCore.Authentication;
|
||||
|
||||
public static class OAuthExtensions
|
||||
{
|
||||
public class GitHubUser
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
[JsonPropertyName("login")]
|
||||
public string Login { get; set; } = "";
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
|
||||
public class GitHubEmail
|
||||
{
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = "";
|
||||
[JsonPropertyName("primary")]
|
||||
public bool Primary { get; set; }
|
||||
[JsonPropertyName("verified")]
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
public class GoogleUser
|
||||
{
|
||||
[JsonPropertyName("sub")]
|
||||
public string Id { get; set; } = "";
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
[JsonPropertyName("email")]
|
||||
public string Email { get; set; } = "";
|
||||
[JsonPropertyName("email_verified")]
|
||||
public bool EmailVerified { get; set; }
|
||||
}
|
||||
|
||||
public static AuthenticationBuilder AddGitHub(this AuthenticationBuilder builder, EnvSettings env)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(env.OAuthGitHubClientId))
|
||||
return builder;
|
||||
|
||||
return builder.AddOAuth("github", o =>
|
||||
{
|
||||
o.ClientId = env.OAuthGitHubClientId;
|
||||
o.ClientSecret = env.OAuthGitHubClientSecret;
|
||||
o.CallbackPath = new PathString("/api/_auth/github/callback");
|
||||
o.Scope.Add("read:user");
|
||||
o.Scope.Add("user:email");
|
||||
o.CorrelationCookie.SameSite = env.IsDevelopment ? SameSiteMode.Unspecified : SameSiteMode.None;
|
||||
o.CorrelationCookie.HttpOnly = true;
|
||||
o.CorrelationCookie.IsEssential = true;
|
||||
o.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
|
||||
o.TokenEndpoint = "https://github.com/login/oauth/access_token";
|
||||
o.UserInformationEndpoint = "https://api.github.com/user";
|
||||
o.ClaimActions.MapJsonKey("id", "id");
|
||||
o.ClaimActions.MapJsonKey("name", "name");
|
||||
o.ClaimActions.MapJsonKey("email", "email");
|
||||
o.Events = new OAuthEvents
|
||||
{
|
||||
OnAccessDenied = context =>
|
||||
{
|
||||
context.HandleResponse();
|
||||
context.HttpContext.Response.Redirect($"{env.SelfBaseUrl}/auth");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var ghUser = await MakeOAuthRequest<GitHubUser>(context, context.Options.UserInformationEndpoint);
|
||||
if (ghUser is null)
|
||||
throw new Exception("Failed to retrieve GitHub user information.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ghUser.Name))
|
||||
ghUser.Name = ghUser.Login;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ghUser.Email))
|
||||
ghUser.Email = await GetGitHubPreferredEmail(context);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ghUser.Email))
|
||||
throw new Exception("Could not find a verified email, can't login with GitHub.");
|
||||
|
||||
var authService = context.HttpContext.RequestServices.GetRequiredService<IAuthService>();
|
||||
var user = await authService.FindOrCreateAccountWithOAuthAsync(ghUser.Name, ghUser.Email, "github", ghUser.Id.ToString(), context.HttpContext.RequestAborted);
|
||||
context.RunClaimActions(JsonSerializer.SerializeToElement(new { id = user.Id, name = user.Name, email = user.Email }));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, EnvSettings env)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(env.OAuthGoogleClientId))
|
||||
return builder;
|
||||
|
||||
return builder.AddOAuth("google", o =>
|
||||
{
|
||||
o.ClientId = env.OAuthGoogleClientId;
|
||||
o.ClientSecret = env.OAuthGoogleClientSecret;
|
||||
o.Scope.Add("openid");
|
||||
o.Scope.Add("profile");
|
||||
o.Scope.Add("email");
|
||||
o.CallbackPath = new PathString("/api/_auth/google/callback");
|
||||
o.CorrelationCookie.SameSite = env.IsDevelopment ? SameSiteMode.Unspecified : SameSiteMode.None;
|
||||
o.CorrelationCookie.HttpOnly = true;
|
||||
o.CorrelationCookie.IsEssential = true;
|
||||
o.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
o.AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
o.TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
o.UserInformationEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo";
|
||||
o.ClaimActions.MapJsonKey("id", "id");
|
||||
o.ClaimActions.MapJsonKey("name", "name");
|
||||
o.ClaimActions.MapJsonKey("email", "email");
|
||||
o.Events = new OAuthEvents
|
||||
{
|
||||
OnAccessDenied = context =>
|
||||
{
|
||||
context.HandleResponse();
|
||||
context.HttpContext.Response.Redirect($"{env.SelfBaseUrl}/auth");
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var googleUser = await MakeOAuthRequest<GoogleUser>(context, context.Options.UserInformationEndpoint);
|
||||
if (googleUser is null)
|
||||
throw new Exception("Failed to retrieve Google user information.");
|
||||
|
||||
if (!googleUser.EmailVerified)
|
||||
throw new Exception("Email not verified, can't login with Google.");
|
||||
|
||||
var authService = context.HttpContext.RequestServices.GetRequiredService<IAuthService>();
|
||||
var user = await authService.FindOrCreateAccountWithOAuthAsync(googleUser.Name, googleUser.Email, "google", googleUser.Id, context.HttpContext.RequestAborted);
|
||||
context.RunClaimActions(JsonSerializer.SerializeToElement(new { id = user.Id, name = user.Name, email = user.Email }));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<T?> MakeOAuthRequest<T>(OAuthCreatingTicketContext context, string endpoint)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||
using var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<T>();
|
||||
}
|
||||
|
||||
private static async Task<string> GetGitHubPreferredEmail(OAuthCreatingTicketContext context)
|
||||
{
|
||||
var emails = await MakeOAuthRequest<GitHubEmail[]>(context, "https://api.github.com/user/emails");
|
||||
return emails?.Where(e => e.Verified).OrderBy(e => e.Primary ? 0 : 1).FirstOrDefault()?.Email ?? "";
|
||||
}
|
||||
|
||||
}
|
36
src/Features/Authentication/UserAccount.cs
Executable file
36
src/Features/Authentication/UserAccount.cs
Executable file
|
@ -0,0 +1,36 @@
|
|||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public class UserAccount
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string AvatarUrl => GetAvatarUrl();
|
||||
public char? LockReason { get; set; }
|
||||
|
||||
public UserAccount()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public UserAccount(UserIdentity identity)
|
||||
{
|
||||
Id = identity.Id;
|
||||
Name = identity.Name;
|
||||
Email = identity.Email;
|
||||
}
|
||||
|
||||
private static MD5 md5 = MD5.Create();
|
||||
private string GetAvatarUrl()
|
||||
{
|
||||
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(Email.Trim().ToLower()));
|
||||
var sb = new StringBuilder();
|
||||
foreach (var b in hash)
|
||||
sb.Append(b.ToString("x2"));
|
||||
|
||||
return $"https://md.sudovanilla.org/images/icons/Aptabase.jpg";
|
||||
}
|
||||
}
|
15
src/Features/Authentication/UserIdentifier.cs
Executable file
15
src/Features/Authentication/UserIdentifier.cs
Executable file
|
@ -0,0 +1,15 @@
|
|||
namespace Aptabase.Features.Authentication;
|
||||
|
||||
public struct UserIdentity
|
||||
{
|
||||
public string Id { get; private set; } = "";
|
||||
public string Name { get; private set; } = "";
|
||||
public string Email { get; private set; } = "";
|
||||
|
||||
public UserIdentity(string id, string name, string email)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Email = email;
|
||||
}
|
||||
}
|
93
src/Features/Billing/BillingController.cs
Executable file
93
src/Features/Billing/BillingController.cs
Executable file
|
@ -0,0 +1,93 @@
|
|||
using Aptabase.Features.Authentication;
|
||||
using Aptabase.Features.Billing.LemonSqueezy;
|
||||
using Aptabase.Features.Stats;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
[ApiController, IsAuthenticated]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class BillingController : Controller
|
||||
{
|
||||
private readonly IQueryClient _queryClient;
|
||||
private readonly IBillingQueries _billingQueries;
|
||||
private readonly LemonSqueezyClient _lsClient;
|
||||
|
||||
public BillingController(IBillingQueries billingQueries, LemonSqueezyClient lsClient, IQueryClient queryClient)
|
||||
{
|
||||
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
|
||||
_lsClient = lsClient ?? throw new ArgumentNullException(nameof(lsClient));
|
||||
_queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient));
|
||||
}
|
||||
|
||||
[HttpGet("/api/_billing")]
|
||||
public async Task<IActionResult> BillingState(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
var appIds = await _billingQueries.GetOwnedAppIds(user);
|
||||
|
||||
var usage = await _queryClient.NamedQuerySingleAsync<BillingUsage>("get_billing_usage__v1", new {
|
||||
app_ids = appIds,
|
||||
}, cancellationToken);
|
||||
|
||||
var sub = await _billingQueries.GetUserSubscription(user);
|
||||
var plan = sub is null || sub.Status == "expired"
|
||||
? SubscriptionPlan.GetFreeVariant(await _billingQueries.GetUserFreeTierOrTrial(user))
|
||||
: SubscriptionPlan.GetByVariantId(sub.VariantId);
|
||||
|
||||
var state = (usage?.Count ?? 0) < plan.MonthlyEvents ? "OK" : "OVERUSE";
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
State = state,
|
||||
Usage = usage?.Count ?? 0,
|
||||
DateTime.UtcNow.Month,
|
||||
DateTime.UtcNow.Year,
|
||||
Subscription = sub != null ? new
|
||||
{
|
||||
sub.Status,
|
||||
sub.EndsAt,
|
||||
} : null,
|
||||
Plan = plan
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/_billing/historical")]
|
||||
public async Task<IActionResult> HistoricalUsage(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
var appIds = await _billingQueries.GetOwnedAppIds(user);
|
||||
|
||||
var rows = await _queryClient.NamedQueryAsync<BillingHistoricUsage>("billing_historical_usage__v1", new
|
||||
{
|
||||
app_ids = appIds,
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("/api/_billing/checkout")]
|
||||
public async Task<IActionResult> GenerateCheckoutUrl(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
var url = await _lsClient.CreateCheckout(user, cancellationToken);
|
||||
|
||||
return Ok(new { url });
|
||||
}
|
||||
|
||||
[HttpPost("/api/_billing/portal")]
|
||||
public async Task<IActionResult> GeneratePortalUrl(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
var sub = await _billingQueries.GetUserSubscription(user);
|
||||
|
||||
if (sub is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var url = await _lsClient.GetBillingPortalUrl(sub.Id, cancellationToken);
|
||||
|
||||
return Ok(new { url });
|
||||
}
|
||||
}
|
122
src/Features/Billing/BillingQueries.cs
Executable file
122
src/Features/Billing/BillingQueries.cs
Executable file
|
@ -0,0 +1,122 @@
|
|||
using Aptabase.Data;
|
||||
using Aptabase.Features.Authentication;
|
||||
using Aptabase.Features.Stats;
|
||||
using Dapper;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public interface IBillingQueries
|
||||
{
|
||||
Task<UserIdentity[]> GetTrialsDueSoon();
|
||||
Task<Subscription?> GetUserSubscription(UserIdentity user);
|
||||
Task<FreeSubscription> GetUserFreeTierOrTrial(UserIdentity user);
|
||||
Task<string[]> GetOwnedAppIds(UserIdentity user);
|
||||
Task<int> LockUsersWithExpiredTrials();
|
||||
Task<int> LockUser(string userId, string reason);
|
||||
Task<int> UnlockOveruseAccounts();
|
||||
Task<IEnumerable<BillingUsageByApp>> GetBillingUsageByApp(int year, int month);
|
||||
Task<IEnumerable<UserQuota>> GetUserQuotaForApps(string[] appIds);
|
||||
}
|
||||
|
||||
public class BillingQueries : IBillingQueries
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
private readonly IQueryClient _queryClient;
|
||||
|
||||
public BillingQueries(IDbContext db, IQueryClient queryClient)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient));
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetUserSubscription(UserIdentity user)
|
||||
{
|
||||
return await _db.Connection.QueryFirstOrDefaultAsync<Subscription>(
|
||||
@"SELECT * FROM subscriptions
|
||||
WHERE owner_id = @userId
|
||||
ORDER BY created_at DESC LIMIT 1",
|
||||
new { userId = user.Id });
|
||||
}
|
||||
|
||||
public async Task<FreeSubscription> GetUserFreeTierOrTrial(UserIdentity user)
|
||||
{
|
||||
return await _db.Connection.QueryFirstAsync<FreeSubscription>(
|
||||
@"SELECT free_quota, free_trial_ends_at FROM users WHERE id = @userId",
|
||||
new { userId = user.Id });
|
||||
}
|
||||
|
||||
public async Task<string[]> GetOwnedAppIds(UserIdentity user)
|
||||
{
|
||||
var releaseAppIds = await _db.Connection.QueryAsync<string>(@"SELECT id FROM apps WHERE owner_id = @userId", new { userId = user.Id });
|
||||
var debugAppIds = releaseAppIds.Select(id => $"{id}_DEBUG");
|
||||
return releaseAppIds.Concat(debugAppIds).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BillingUsageByApp>> GetBillingUsageByApp(int year, int month)
|
||||
{
|
||||
var period = $"{year}-{month.ToString().PadLeft(2, '0')}-01";
|
||||
return await _queryClient.NamedQueryAsync<BillingUsageByApp>("billing_usage_per_app__v1", new { period }, default);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UserQuota>> GetUserQuotaForApps(string[] appIds)
|
||||
{
|
||||
return await _db.Connection.QueryAsync<UserQuota>(
|
||||
@"SELECT u.id AS id,
|
||||
u.email AS email,
|
||||
u.name AS name,
|
||||
ARRAY_AGG(a.id) as app_ids,
|
||||
u.free_quota,
|
||||
s.variant_id
|
||||
FROM users u
|
||||
LEFT JOIN subscriptions s
|
||||
ON s.owner_id = u.id
|
||||
AND s.status != 'expired'
|
||||
INNER JOIN apps a
|
||||
ON a.owner_id = u.id
|
||||
AND a.id = ANY(@appIds)
|
||||
WHERE u.lock_reason IS NULL
|
||||
GROUP BY u.id, u.email, u.name, u.free_quota, s.variant_id", new { appIds });
|
||||
}
|
||||
|
||||
public async Task<UserIdentity[]> GetTrialsDueSoon()
|
||||
{
|
||||
var start = DateTime.UtcNow.AddDays(5).Date;
|
||||
var end = start.AddDays(1).Date;
|
||||
|
||||
var users = await _db.Connection.QueryAsync<UserIdentity>(
|
||||
@"SELECT DISTINCT u.id, u.name, u.email
|
||||
FROM users u
|
||||
LEFT JOIN subscriptions s
|
||||
ON s.owner_id = u.id
|
||||
INNER JOIN apps a
|
||||
ON a.owner_id = u.id
|
||||
AND a.has_events = true
|
||||
WHERE u.free_trial_ends_at BETWEEN @start AND @end
|
||||
AND s.id IS NULL", new { start, end });
|
||||
return users.ToArray();
|
||||
}
|
||||
|
||||
public async Task<int> LockUsersWithExpiredTrials()
|
||||
{
|
||||
var count = await _db.Connection.ExecuteAsync(
|
||||
@"UPDATE users u
|
||||
SET lock_reason = 'T'
|
||||
WHERE u.free_quota IS NULL
|
||||
AND u.free_trial_ends_at <= now()
|
||||
AND u.lock_reason IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.owner_id = u.id)");
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> LockUser(string userId, string reason)
|
||||
{
|
||||
return await _db.Connection.ExecuteAsync(
|
||||
@"UPDATE users SET lock_reason = @reason WHERE id = @userId",
|
||||
new { userId, reason });
|
||||
}
|
||||
|
||||
public async Task<int> UnlockOveruseAccounts()
|
||||
{
|
||||
return await _db.Connection.ExecuteAsync(@"UPDATE users SET lock_reason = NULL WHERE lock_reason = 'O'");
|
||||
}
|
||||
}
|
36
src/Features/Billing/BillingUsage.cs
Executable file
36
src/Features/Billing/BillingUsage.cs
Executable file
|
@ -0,0 +1,36 @@
|
|||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class BillingUsage
|
||||
{
|
||||
public long Count { get; set; }
|
||||
}
|
||||
|
||||
public class BillingUsageByApp
|
||||
{
|
||||
public string AppId { get; set; } = "";
|
||||
public long Count { get; set; }
|
||||
}
|
||||
|
||||
public class UserQuota
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string[] AppIds { get; set; } = Array.Empty<string>();
|
||||
public long? FreeQuota { get; set; }
|
||||
public long? VariantID { get; set; }
|
||||
|
||||
public long GetQuota()
|
||||
{
|
||||
if (VariantID is not null)
|
||||
return SubscriptionPlan.GetByVariantId(VariantID.Value).MonthlyEvents;
|
||||
|
||||
return FreeQuota ?? SubscriptionPlan.FreeTrialMonthlyQuota;
|
||||
}
|
||||
}
|
||||
|
||||
public class BillingHistoricUsage
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public long Events { get; set; }
|
||||
}
|
79
src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs
Executable file
79
src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs
Executable file
|
@ -0,0 +1,79 @@
|
|||
using System.Text.Json;
|
||||
using Aptabase.Features.Authentication;
|
||||
using Yoh.Text.Json.NamingPolicies;
|
||||
|
||||
namespace Aptabase.Features.Billing.LemonSqueezy;
|
||||
|
||||
public class LemonSqueezyClient
|
||||
{
|
||||
private HttpClient _httpClient;
|
||||
private EnvSettings _env;
|
||||
private ILogger _logger;
|
||||
|
||||
public static readonly JsonSerializerOptions JsonSettings = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower
|
||||
};
|
||||
|
||||
public LemonSqueezyClient(IHttpClientFactory factory, EnvSettings env, ILogger<LemonSqueezyClient> logger)
|
||||
{
|
||||
_httpClient = factory.CreateClient("LemonSqueezy");
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<string?> CreateCheckout(UserIdentity user, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = new {
|
||||
data = new {
|
||||
type = "checkouts",
|
||||
attributes = new {
|
||||
checkout_data = new {
|
||||
email = user.Email,
|
||||
custom = new {
|
||||
user_id = user.Id,
|
||||
region = _env.Region
|
||||
}
|
||||
},
|
||||
product_options = new {
|
||||
redirect_url = $"{_env.SelfBaseUrl}/billing",
|
||||
},
|
||||
checkout_options = new {
|
||||
embed = false,
|
||||
dark = false
|
||||
}
|
||||
},
|
||||
relationships = new {
|
||||
store = new {
|
||||
data = new {
|
||||
type = "stores",
|
||||
id = "30508"
|
||||
}
|
||||
},
|
||||
variant = new {
|
||||
data = new {
|
||||
type = "variants",
|
||||
id = _env.IsProduction ? "103474" : "85183"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("/v1/checkouts", body, JsonSettings, cancellationToken);
|
||||
|
||||
await response.EnsureSuccessWithLog(_logger);
|
||||
var result = await response.Content.ReadFromJsonAsync<GetResponse<Resource<CheckoutAttributes>>>(JsonSettings);
|
||||
return result?.Data.Attributes.Url ?? "";
|
||||
}
|
||||
|
||||
public async Task<string> GetBillingPortalUrl(long subscriptionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/v1/subscriptions/{subscriptionId}", cancellationToken);
|
||||
|
||||
await response.EnsureSuccessWithLog(_logger);
|
||||
var result = await response.Content.ReadFromJsonAsync<GetResponse<Resource<SubscriptionAttributes>>>(JsonSettings);
|
||||
return result?.Data.Attributes.Urls.CustomerPortal ?? "";
|
||||
}
|
||||
}
|
16
src/Features/Billing/LemonSqueezy/LemonSqueezyExtensions.cs
Executable file
16
src/Features/Billing/LemonSqueezy/LemonSqueezyExtensions.cs
Executable file
|
@ -0,0 +1,16 @@
|
|||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Aptabase.Features.Billing.LemonSqueezy;
|
||||
|
||||
public static class LemonSqueezyExtensions
|
||||
{
|
||||
public static void AddLemonSqueezy(this IServiceCollection services, EnvSettings env)
|
||||
{
|
||||
services.AddSingleton<LemonSqueezyClient>();
|
||||
services.AddHttpClient("LemonSqueezy", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.lemonsqueezy.com");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", env.LemonSqueezyApiKey);
|
||||
});
|
||||
}
|
||||
}
|
38
src/Features/Billing/LemonSqueezy/Models.cs
Executable file
38
src/Features/Billing/LemonSqueezy/Models.cs
Executable file
|
@ -0,0 +1,38 @@
|
|||
namespace Aptabase.Features.Billing.LemonSqueezy;
|
||||
|
||||
public class PagedList<T> where T : new()
|
||||
{
|
||||
public IEnumerable<Resource<T>> Data { get; set; } = Enumerable.Empty<Resource<T>>();
|
||||
}
|
||||
|
||||
public class Resource<T> where T : new()
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public T Attributes { get; set; } = new T();
|
||||
}
|
||||
|
||||
public class GetResponse<T> where T : new()
|
||||
{
|
||||
public T Data { get; set; } = new T();
|
||||
}
|
||||
|
||||
public class CheckoutAttributes
|
||||
{
|
||||
public string Url { get; set; } = "";
|
||||
}
|
||||
|
||||
public class SubscriptionAttributes
|
||||
{
|
||||
public int ProductID { get; set; }
|
||||
public int VariantID { get; set; }
|
||||
public int CustomerID { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public SubscriptionUrls Urls { get; set; } = new SubscriptionUrls();
|
||||
}
|
||||
|
||||
public class SubscriptionUrls
|
||||
{
|
||||
public string CustomerPortal { get; set; } = "";
|
||||
}
|
22
src/Features/Billing/LemonSqueezy/WebhookEvent.cs
Executable file
22
src/Features/Billing/LemonSqueezy/WebhookEvent.cs
Executable file
|
@ -0,0 +1,22 @@
|
|||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Aptabase.Features.Billing.LemonSqueezy;
|
||||
|
||||
public class WebhookEvent
|
||||
{
|
||||
public WebhookEventMeta Meta { get; set; } = new WebhookEventMeta();
|
||||
public WebhookEventData Data { get; set; } = new WebhookEventData();
|
||||
}
|
||||
|
||||
public class WebhookEventData
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public JsonObject Attributes { get; set; } = new JsonObject();
|
||||
}
|
||||
|
||||
public class WebhookEventMeta
|
||||
{
|
||||
public string EventName { get; set; } = "";
|
||||
public Dictionary<string, string> CustomData { get; set; } = new Dictionary<string, string>();
|
||||
}
|
163
src/Features/Billing/LemonSqueezyWebhookController.cs
Executable file
163
src/Features/Billing/LemonSqueezyWebhookController.cs
Executable file
|
@ -0,0 +1,163 @@
|
|||
using Aptabase.Data;
|
||||
using Aptabase.Features.Billing.LemonSqueezy;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
[ApiController]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class LemonSqueezyWebhookController : Controller
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
private readonly LemonSqueezyClient _lsClient;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly string _region;
|
||||
private readonly byte[] _signingSecret;
|
||||
|
||||
public LemonSqueezyWebhookController(EnvSettings env, IDbContext db, LemonSqueezyClient lsClient, ILogger<LemonSqueezyWebhookController> logger)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_lsClient = lsClient ?? throw new ArgumentNullException(nameof(lsClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_region = env.Region;
|
||||
_signingSecret = Encoding.UTF8.GetBytes(env.LemonSqueezySigningSecret);
|
||||
}
|
||||
|
||||
[HttpPost("/webhook/lemonsqueezy")]
|
||||
public async Task<IActionResult> Post(CancellationToken cancellationToken)
|
||||
{
|
||||
var (isValid, body) = await ValidateSignatureAsync(HttpContext);
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
_logger.LogWarning("Invalid LemonSqueezy Signature");
|
||||
|
||||
return Unauthorized(new { message = "Invalid LemonSqueezy Signature" });
|
||||
}
|
||||
|
||||
var ev = JsonSerializer.Deserialize<WebhookEvent>(body, LemonSqueezyClient.JsonSettings);
|
||||
|
||||
if (ev == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid JSON Body: {Body}", body);
|
||||
|
||||
return BadRequest(new { message = "Invalid JSON Body" });
|
||||
}
|
||||
|
||||
if (ev.Meta.CustomData.TryGetValue("region", out var eventRegion) == false)
|
||||
{
|
||||
_logger.LogError("LemonSqueezy event is missing region");
|
||||
|
||||
return BadRequest(new { message = "Missing 'region' on meta.custom_data" });
|
||||
}
|
||||
|
||||
if (eventRegion != _region)
|
||||
{
|
||||
return Ok(new { message = "Ignoring event from different region" });
|
||||
}
|
||||
|
||||
var task = ev.Meta.EventName switch
|
||||
{
|
||||
"subscription_created" => HandleSubscriptionCreatedOrUpdated(ev, cancellationToken),
|
||||
"subscription_updated" => HandleSubscriptionCreatedOrUpdated(ev, cancellationToken),
|
||||
_ => HandleUnknownEvent(ev),
|
||||
};
|
||||
|
||||
return await task;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleSubscriptionCreatedOrUpdated([FromBody] WebhookEvent ev, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.Deserialize<SubscriptionAttributes>(ev.Data.Attributes, LemonSqueezyClient.JsonSettings);
|
||||
|
||||
if (body == null)
|
||||
{
|
||||
return BadRequest(new { message = "Event body is null" });
|
||||
}
|
||||
|
||||
var subId = Convert.ToInt64(ev.Data.Id);
|
||||
var ownerId = ev.Meta.CustomData["user_id"];
|
||||
|
||||
if (string.IsNullOrEmpty(ownerId))
|
||||
{
|
||||
_logger.LogError("LemonSqueezy event is missing user_id");
|
||||
|
||||
return BadRequest(new { message = "Missing 'user_id' on meta.custom_data" });
|
||||
}
|
||||
|
||||
await _db.Connection.ExecuteAsync(
|
||||
@"UPDATE users SET lock_reason = null WHERE id = @ownerId",
|
||||
new { ownerId }
|
||||
);
|
||||
|
||||
if (body.Status == "expired")
|
||||
{
|
||||
await _db.Connection.ExecuteAsync(
|
||||
@"DELETE FROM subscriptions WHERE id = @subId",
|
||||
new
|
||||
{
|
||||
subId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await _db.Connection.ExecuteAsync(
|
||||
@"INSERT INTO subscriptions
|
||||
(id, owner_id, customer_id, product_id, variant_id, status, ends_at)
|
||||
VALUES
|
||||
(@subId, @ownerId, @customerId, @productId, @variantId, @status, @endsAt)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET product_id = @productId,
|
||||
variant_id = @variantId,
|
||||
status = @status,
|
||||
ends_at = @endsAt,
|
||||
modified_at = now()",
|
||||
new
|
||||
{
|
||||
subId,
|
||||
ownerId,
|
||||
customerId = body.CustomerID,
|
||||
productId = body.ProductID,
|
||||
variantId = body.VariantID,
|
||||
status = body.Status,
|
||||
endsAt = body.EndsAt,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new {});
|
||||
}
|
||||
|
||||
private async Task<(bool, string)> ValidateSignatureAsync(HttpContext http)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
string body = await reader.ReadToEndAsync();
|
||||
|
||||
using var hmac = new HMACSHA256(_signingSecret);
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
var digest = BitConverter.ToString(hash).Replace("-", string.Empty).ToLower();
|
||||
var digestBytes = Encoding.UTF8.GetBytes(digest);
|
||||
|
||||
var signature = http.Request.Headers["X-Signature"].ToString() ?? string.Empty;
|
||||
var signatureBytes = Encoding.UTF8.GetBytes(signature);
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(digestBytes, signatureBytes))
|
||||
{
|
||||
return (false, string.Empty);
|
||||
}
|
||||
|
||||
return (true, body);
|
||||
}
|
||||
|
||||
private Task<IActionResult> HandleUnknownEvent(WebhookEvent ev)
|
||||
{
|
||||
_logger.LogError("Unknown LemonSqueezy event: {EventName}", ev.Meta.EventName);
|
||||
|
||||
return Task.FromResult<IActionResult>(BadRequest(new { message = "Unknown event" }));
|
||||
}
|
||||
}
|
100
src/Features/Billing/OveruseNotificationCronJob.cs
Executable file
100
src/Features/Billing/OveruseNotificationCronJob.cs
Executable file
|
@ -0,0 +1,100 @@
|
|||
using Aptabase.Features.Notification;
|
||||
using Features.Cache;
|
||||
using Sgbj.Cron;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class OveruseNotificationCronJob : BackgroundService
|
||||
{
|
||||
private readonly IBillingQueries _billingQueries;
|
||||
private readonly IEmailClient _emailClient;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly ICache _cache;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public OveruseNotificationCronJob(IBillingQueries billingQueries, IEmailClient emailClient, EnvSettings env, ICache cache, ILogger<OveruseNotificationCronJob> logger)
|
||||
{
|
||||
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
|
||||
_emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient));
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("OveruseNotificationCronJob is starting.");
|
||||
|
||||
using var timer = new CronTimer("10 * * * *", TimeZoneInfo.Utc);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Looking for accounts with events overuse.");
|
||||
var usagePerApp = await _billingQueries.GetBillingUsageByApp(DateTime.UtcNow.Year, DateTime.UtcNow.Month);
|
||||
var users = await _billingQueries.GetUserQuotaForApps(usagePerApp.Select(x => x.AppId).ToArray());
|
||||
foreach (var user in users)
|
||||
{
|
||||
try
|
||||
{
|
||||
var quota = user.GetQuota();
|
||||
var usage = usagePerApp.Where(x => user.AppIds.Contains(x.AppId)).Sum(x => x.Count);
|
||||
var perc = usage * 1.0 / quota;
|
||||
_logger.LogInformation("User {UserId} has used {Usage} ({Perc:P}) out of {Quota} events.", user.Id, usage, perc, quota);
|
||||
|
||||
var (subject, templateName) = GetSubjectTemplateName(perc);
|
||||
if (string.IsNullOrEmpty(templateName) || string.IsNullOrEmpty(subject))
|
||||
continue; // No need to send notification
|
||||
|
||||
var cacheKey = $"OveruseNotification.{user.Id}.{templateName}.{DateTime.UtcNow.Year}-{DateTime.UtcNow.Month.ToString().PadLeft(2, '0')}";
|
||||
if (await _cache.Exists(cacheKey))
|
||||
continue; // Already sent notification
|
||||
|
||||
await _emailClient.SendEmailAsync(user.Email, subject, templateName, new()
|
||||
{
|
||||
{ "name", user.Name.Split(" ").ElementAtOrDefault(0) ?? user.Name },
|
||||
{ "quota", quota.ToString("N0") },
|
||||
{ "url", _env.SelfBaseUrl },
|
||||
}, cancellationToken);
|
||||
|
||||
if (usage >= quota)
|
||||
{
|
||||
_logger.LogInformation("User {UserId} has reached the limit, pausing ingestion.", user.Id);
|
||||
await _billingQueries.LockUser(user.Id, "O");
|
||||
}
|
||||
|
||||
await _cache.Set(cacheKey, DateTime.UtcNow.ToString("o"), TimeSpan.FromDays(10));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OveruseNotificationCronJob failed to process usage for user {UserId}.", user.Id);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Finished processing overuse notifications.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("OveruseNotificationCronJob stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OveruseNotificationCronJob crashed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string?, string?) GetSubjectTemplateName(double perc)
|
||||
{
|
||||
if (perc >= 1)
|
||||
return ("ACTION REQUIRED: Event ingestion paused", "UsageLevel100");
|
||||
if (perc >= 0.9)
|
||||
return ("WARNING: You have used 90% of your monthly limit, incoming events will be dropped soon", "UsageLevel90");
|
||||
if (perc >= 0.8)
|
||||
return ("You have used 80% of your monthly limit, incoming events will be dropped soon", "UsageLevel80");
|
||||
return (null, null);
|
||||
}
|
||||
}
|
43
src/Features/Billing/ResetOveruseCronJob.cs
Executable file
43
src/Features/Billing/ResetOveruseCronJob.cs
Executable file
|
@ -0,0 +1,43 @@
|
|||
using Sgbj.Cron;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class ResetOveruseCronJob : BackgroundService
|
||||
{
|
||||
private readonly IBillingQueries _billingQueries;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ResetOveruseCronJob(IBillingQueries billingQueries, ILogger<OveruseNotificationCronJob> logger)
|
||||
{
|
||||
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("ResetOveruseCronJob is starting.");
|
||||
|
||||
using var timer = new CronTimer("5 0 1 * *", TimeZoneInfo.Utc);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Unlocking accounts with overuse.");
|
||||
var count = await _billingQueries.UnlockOveruseAccounts();
|
||||
_logger.LogInformation("Unlocked {count} accounts with overuse status.", count);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("ResetOveruseCronJob stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "ResetOveruseCronJob crashed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
87
src/Features/Billing/Subscription.cs
Executable file
87
src/Features/Billing/Subscription.cs
Executable file
|
@ -0,0 +1,87 @@
|
|||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class Subscription
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string OwnerId { get; set; } = "";
|
||||
public long CustomerId { get; set; }
|
||||
public long ProductId { get; set; }
|
||||
public long VariantId { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime ModifiedAt { get; set; }
|
||||
}
|
||||
|
||||
public class FreeSubscription
|
||||
{
|
||||
public long? FreeQuota { get; set; }
|
||||
public DateTime? FreeTrialEndsAt { get; set; }
|
||||
}
|
||||
|
||||
public class SubscriptionPlan
|
||||
{
|
||||
public const int FreeTrialMonthlyQuota = 1_000_000;
|
||||
|
||||
public string Name { get; }
|
||||
public int MonthlyPrice { get; }
|
||||
public int MonthlyEvents { get; }
|
||||
public long VariantId { get; }
|
||||
public DateTime? FreeTrialEndsAt { get; }
|
||||
|
||||
public SubscriptionPlan(string name, int monthlyEvents, int monthlyPrice, long variantId, DateTime? freeTrialEndsAt)
|
||||
{
|
||||
Name = name;
|
||||
MonthlyEvents = monthlyEvents;
|
||||
MonthlyPrice = monthlyPrice;
|
||||
VariantId = variantId;
|
||||
FreeTrialEndsAt = freeTrialEndsAt;
|
||||
}
|
||||
|
||||
public static SubscriptionPlan GetFreeVariant(FreeSubscription sub)
|
||||
{
|
||||
if (sub.FreeQuota.HasValue)
|
||||
return new SubscriptionPlan("Free Plan", (int)sub.FreeQuota.Value, 0, 0, null);
|
||||
|
||||
if (sub.FreeTrialEndsAt.HasValue)
|
||||
return new SubscriptionPlan("Free Trial", FreeTrialMonthlyQuota, 0, 0, sub.FreeTrialEndsAt);
|
||||
|
||||
throw new InvalidOperationException("No free subscription found");
|
||||
}
|
||||
|
||||
public static SubscriptionPlan GetByVariantId(long variantId)
|
||||
{
|
||||
return ProductionPlans.FirstOrDefault(plan => plan.VariantId == variantId)
|
||||
?? DevelopmentPlans.FirstOrDefault(plan => plan.VariantId == variantId)
|
||||
?? throw new InvalidOperationException($"Subscription Variant not found for ID {variantId}");
|
||||
}
|
||||
|
||||
private static readonly SubscriptionPlan[] DevelopmentPlans =
|
||||
[
|
||||
new SubscriptionPlan("200k Plan", 200_000, 10, 85183, null),
|
||||
new SubscriptionPlan("1M Plan", 1_000_000, 20, 85184, null),
|
||||
new SubscriptionPlan("2M Plan", 2_000_000, 40, 85185, null),
|
||||
new SubscriptionPlan("5M Plan", 5_000_000, 75, 85187, null),
|
||||
new SubscriptionPlan("10M Plan", 10_000_000, 140, 85188, null),
|
||||
new SubscriptionPlan("20M Plan", 20_000_000, 240, 85190, null),
|
||||
new SubscriptionPlan("30M Plan", 30_000_000, 300, 85192, null),
|
||||
new SubscriptionPlan("50M Plan", 50_000_000, 450, 85194, null),
|
||||
];
|
||||
|
||||
private static readonly SubscriptionPlan[] ProductionPlans =
|
||||
[
|
||||
new SubscriptionPlan("200k Plan", 200_000, 10, 103474, null),
|
||||
new SubscriptionPlan("1M Plan", 1_000_000, 20, 103475, null),
|
||||
new SubscriptionPlan("2M Plan", 2_000_000, 40, 103476, null),
|
||||
new SubscriptionPlan("5M Plan", 5_000_000, 75, 103477, null),
|
||||
new SubscriptionPlan("10M Plan", 10_000_000, 140, 103478, null),
|
||||
new SubscriptionPlan("20M Plan", 20_000_000, 240, 103479, null),
|
||||
new SubscriptionPlan("30M Plan", 30_000_000, 300, 103480, null),
|
||||
new SubscriptionPlan("50M Plan", 50_000_000, 450, 103481, null),
|
||||
new SubscriptionPlan("100M Plan", 100_000_000, 750, 614418, null),
|
||||
new SubscriptionPlan("200M Plan", 200_000_000, 1200, 614417, null),
|
||||
new SubscriptionPlan("500M Plan", 500_000_000, 1500, 636430, null),
|
||||
|
||||
];
|
||||
}
|
43
src/Features/Billing/TrialExpiredCronJob.cs
Executable file
43
src/Features/Billing/TrialExpiredCronJob.cs
Executable file
|
@ -0,0 +1,43 @@
|
|||
using Sgbj.Cron;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class TrialExpiredCronJob : BackgroundService
|
||||
{
|
||||
private readonly IBillingQueries _billingQueries;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public TrialExpiredCronJob(IBillingQueries billingQueries, ILogger<TrialExpiredCronJob> logger)
|
||||
{
|
||||
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("TrialExpiredCronJob is starting.");
|
||||
|
||||
using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Searching for users with expired trial.");
|
||||
var count = await _billingQueries.LockUsersWithExpiredTrials();
|
||||
_logger.LogInformation("Locked {count} users with expired trial.", count);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("TrialExpiredCronJob stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "TrialExpiredCronJob crashed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
58
src/Features/Billing/TrialNotificationCronJob.cs
Executable file
58
src/Features/Billing/TrialNotificationCronJob.cs
Executable file
|
@ -0,0 +1,58 @@
|
|||
using Aptabase.Features.Notification;
|
||||
using Sgbj.Cron;
|
||||
|
||||
namespace Aptabase.Features.Billing;
|
||||
|
||||
public class TrialNotificationCronJob : BackgroundService
|
||||
{
|
||||
private readonly IBillingQueries _billingQueries;
|
||||
private readonly IEmailClient _emailClient;
|
||||
private readonly EnvSettings _env;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public TrialNotificationCronJob(IBillingQueries billingQueries, IEmailClient emailClient, EnvSettings env, ILogger<TrialNotificationCronJob> logger)
|
||||
{
|
||||
_billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries));
|
||||
_emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient));
|
||||
_env = env ?? throw new ArgumentNullException(nameof(env));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("TrialNotificationCronJob is starting.");
|
||||
|
||||
using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
_logger.LogInformation("Searching for users to notify about trial expiration.");
|
||||
var users = await _billingQueries.GetTrialsDueSoon();
|
||||
foreach (var user in users)
|
||||
{
|
||||
_logger.LogInformation("Sending trial notification to {name} ({user})", user.Name, user.Email);
|
||||
await _emailClient.SendEmailAsync(user.Email, "Your Trial ends in 5 days", "TrialEndsSoon", new()
|
||||
{
|
||||
{ "name", user.Name },
|
||||
{ "url", _env.SelfBaseUrl },
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Sent trial notifications to {count} users", users.Length);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("TrialNotificationCronJob stopped.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "TrialNotificationCronJob crashed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
36
src/Features/Blob/DatabaseBlobService.cs
Executable file
36
src/Features/Blob/DatabaseBlobService.cs
Executable file
|
@ -0,0 +1,36 @@
|
|||
using Aptabase.Data;
|
||||
using ClickHouse.Client.Utility;
|
||||
using Dapper;
|
||||
|
||||
namespace Aptabase.Features.Blob;
|
||||
|
||||
public class DatabaseBlobService : IBlobService
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
|
||||
public DatabaseBlobService(IDbContext db)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
}
|
||||
|
||||
public async Task<string> UploadAsync(string prefix, byte[] content, string contentType, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = $"{prefix}/{Guid.NewGuid().ToString()}.png";
|
||||
|
||||
var cmd = new CommandDefinition("INSERT INTO blobs (path, content, content_type) VALUES (@path, @content, @contentType)", new {
|
||||
path,
|
||||
content = content,
|
||||
contentType = "image/png"
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
await _db.Connection.ExecuteScalarAsync(cmd);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
public async Task<Blob?> DownloadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var cmd = new CommandDefinition("SELECT path, content, content_type FROM blobs WHERE path = @path", new { path }, cancellationToken: cancellationToken);
|
||||
return await _db.Connection.QuerySingleOrDefaultAsync<Blob>(cmd);
|
||||
}
|
||||
}
|
14
src/Features/Blob/IBlobService.cs
Executable file
14
src/Features/Blob/IBlobService.cs
Executable file
|
@ -0,0 +1,14 @@
|
|||
namespace Aptabase.Features.Blob;
|
||||
|
||||
public class Blob
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public byte[] Content { get; set; } = new byte[0];
|
||||
public string ContentType { get; set; } = "";
|
||||
}
|
||||
|
||||
public interface IBlobService
|
||||
{
|
||||
Task<string> UploadAsync(string prefix, byte[] content, string contentType, CancellationToken cancellationToken);
|
||||
Task<Blob?> DownloadAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
33
src/Features/Blob/UploadsController.cs
Executable file
33
src/Features/Blob/UploadsController.cs
Executable file
|
@ -0,0 +1,33 @@
|
|||
using Aptabase.Features.Blob;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Aptabase.Features.Blob;
|
||||
|
||||
public class UploadedFile
|
||||
{
|
||||
public byte[] Content { get; set; } = new byte[0];
|
||||
public string ContentType { get; set; } = "";
|
||||
}
|
||||
|
||||
[ApiController, AllowAnonymous]
|
||||
public class UploadsController : Controller
|
||||
{
|
||||
private readonly IBlobService _blobService;
|
||||
|
||||
public UploadsController(IBlobService blobService)
|
||||
{
|
||||
_blobService = blobService ?? throw new ArgumentNullException(nameof(blobService));
|
||||
}
|
||||
|
||||
[HttpGet("/uploads/{**path}")]
|
||||
[ResponseCache(Duration = 365 * 24 * 60 * 60)] // Cache for 1 year because uploaded files are immutable
|
||||
public async Task<IActionResult> ReadFile(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var file = await _blobService.DownloadAsync(path, cancellationToken);
|
||||
if (file == null)
|
||||
return NotFound();
|
||||
|
||||
return File(file.Content, file.ContentType);
|
||||
}
|
||||
}
|
43
src/Features/Cache/CacheService.cs
Executable file
43
src/Features/Cache/CacheService.cs
Executable file
|
@ -0,0 +1,43 @@
|
|||
using Aptabase.Data;
|
||||
using Dapper;
|
||||
|
||||
namespace Features.Cache;
|
||||
|
||||
public interface ICache
|
||||
{
|
||||
Task<string?> Get(string key);
|
||||
Task Set(string key, string value, TimeSpan ttl);
|
||||
Task<bool> Exists(string key);
|
||||
}
|
||||
|
||||
public class DatabaseCache : ICache
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
|
||||
public DatabaseCache(IDbContext db)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
}
|
||||
|
||||
public async Task<string?> Get(string key)
|
||||
{
|
||||
return await _db.Connection.ExecuteScalarAsync<string>(
|
||||
"SELECT value FROM cache WHERE key = @key AND expires_at > now()",
|
||||
new { key });
|
||||
}
|
||||
|
||||
public async Task Set(string key, string value, TimeSpan ttl)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.Add(ttl);
|
||||
await _db.Connection.ExecuteAsync(
|
||||
"INSERT INTO cache (key, value, expires_at) VALUES (@key, @value, @expiresAt) " +
|
||||
"ON CONFLICT (key) DO UPDATE SET value = @value, expires_at = @expiresAt",
|
||||
new { key, value, expiresAt });
|
||||
}
|
||||
|
||||
public async Task<bool> Exists(string key)
|
||||
{
|
||||
var value = await Get(key);
|
||||
return !string.IsNullOrEmpty(value);
|
||||
}
|
||||
}
|
153
src/Features/EnvSettings.cs
Executable file
153
src/Features/EnvSettings.cs
Executable file
|
@ -0,0 +1,153 @@
|
|||
using System.Text;
|
||||
|
||||
namespace Aptabase.Features;
|
||||
|
||||
public class EnvSettings
|
||||
{
|
||||
// The base URL of the application, used for generating links
|
||||
// E.g: https://analytics.yourdomain.com
|
||||
// Variable Name: BASE_URL
|
||||
public string SelfBaseUrl { get; private set; } = "";
|
||||
|
||||
// The full connection string to postgres using .NET format
|
||||
// E.g: Server=localhost;Port=5444;User Id=aptabase;Password=aptabase_pw;Database=aptabase
|
||||
// Variable Name: DATABASE_URL
|
||||
public string ConnectionString { get; private set; } = "";
|
||||
|
||||
// The full connection string to ClickHouse using .NET format
|
||||
// E.g: Host=my.clickhouse;Protocol=https;Port=12345;Username=user
|
||||
// Variable Name: CLICKHOUSE_URL
|
||||
public string ClickHouseConnectionString { get; private set; } = "";
|
||||
|
||||
// The base URI of the Tinybird API
|
||||
// E.g: https://api.tinybird.co
|
||||
// Variable Name: TINYBIRD_BASE_URL
|
||||
public string TinybirdBaseUrl { get; private set; } = "";
|
||||
|
||||
// The token for Tinybird API, must have write and read access
|
||||
// E.g: p.eyJ1Ijo...
|
||||
// Variable Name: TINYBIRD_TOKEN
|
||||
public string TinybirdToken { get; private set; } = "";
|
||||
|
||||
// A random secret key used for signing auth tokens
|
||||
// E.g: GMvqFuPEiRZt6RtaB5OT
|
||||
// Variable Name: AUTH_SECRET
|
||||
public byte[] AuthSecret { get; private set; } = [];
|
||||
|
||||
public string? MailCatcherConnectionString { get; private set; }
|
||||
|
||||
// The host of the SMTP server
|
||||
// E.g: smtp.someprovider.com
|
||||
// Variable Name: SMTP_HOST
|
||||
public string SmtpHost { get; private set; } = "";
|
||||
|
||||
// The port used for sending emails via SMTP
|
||||
// E.g: 587
|
||||
// Variable Name: SMTP_PORT
|
||||
public int SmtpPort { get; private set; } = 0;
|
||||
|
||||
// The username for the SMTP server
|
||||
// Variable Name: SMTP_USERNAME
|
||||
public string SmtpUsername { get; private set; } = "";
|
||||
|
||||
// The password for the SMTP server
|
||||
// Variable Name: SMTP_PASSWORD
|
||||
public string SmtpPassword { get; private set; } = "";
|
||||
|
||||
// Address to send the email from
|
||||
// E.g.: notification@yourdomain.com
|
||||
// Variable Name: SMTP_FROM_ADDRESS
|
||||
public string SmtpFromAddress { get; private set; } = "";
|
||||
|
||||
// The GitHub Client ID for OAuth
|
||||
// Variable Name: OAUTH_GITHUB_CLIENT_ID
|
||||
public string OAuthGitHubClientId { get; private set; } = "";
|
||||
|
||||
// The GitHub Client Secret for OAuth
|
||||
// Variable Name: OAUTH_GITHUB_CLIENT_SECRET
|
||||
public string OAuthGitHubClientSecret { get; private set; } = "";
|
||||
|
||||
// The Google Client ID for OAuth
|
||||
// Variable Name: OAUTH_GOOGLE_CLIENT_ID
|
||||
public string OAuthGoogleClientId { get; private set; } = "";
|
||||
|
||||
// The Google Client Secret for OAuth
|
||||
// Variable Name: OAUTH_GOOGLE_CLIENT_SECRET
|
||||
public string OAuthGoogleClientSecret { get; private set; } = "";
|
||||
|
||||
// The following properties are derived from the other settings
|
||||
public bool IsManagedCloud => Region == "EU" || Region == "US";
|
||||
public bool IsBillingEnabled => IsManagedCloud || IsDevelopment;
|
||||
public bool IsProduction => !IsDevelopment;
|
||||
public bool IsDevelopment { get; private set; }
|
||||
public string Region { get; private set; } = "";
|
||||
public string LemonSqueezyApiKey { get; private set; } = "";
|
||||
public string LemonSqueezySigningSecret { get; private set; } = "";
|
||||
public string EtcDirectoryPath { get; private set; } = "";
|
||||
|
||||
public static EnvSettings Load()
|
||||
{
|
||||
var isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development";
|
||||
var region = isDevelopment ? "DEV" : Get("REGION").ToUpper();
|
||||
if (string.IsNullOrEmpty(region))
|
||||
region = "ZV"; // Self Hosted
|
||||
|
||||
return new EnvSettings
|
||||
{
|
||||
IsDevelopment = isDevelopment,
|
||||
Region = region,
|
||||
SelfBaseUrl = MustGet("BASE_URL"),
|
||||
ConnectionString = GetOrNull("ConnectionStrings__postgresdb") ?? MustGet("DATABASE_URL"),
|
||||
ClickHouseConnectionString = GetOrNull("ConnectionStrings__clickhousedb") ?? Get("CLICKHOUSE_URL"),
|
||||
TinybirdBaseUrl = Get("TINYBIRD_BASE_URL"),
|
||||
TinybirdToken = Get("TINYBIRD_TOKEN"),
|
||||
AuthSecret = Encoding.ASCII.GetBytes(MustGet("AUTH_SECRET")),
|
||||
LemonSqueezyApiKey = Get("LEMONSQUEEZY_API_KEY"),
|
||||
LemonSqueezySigningSecret = Get("LEMONSQUEEZY_SIGNING_SECRET"),
|
||||
|
||||
SmtpHost = Get("SMTP_HOST"),
|
||||
SmtpPort = GetInt("SMTP_PORT"),
|
||||
SmtpUsername = Get("SMTP_USERNAME"),
|
||||
SmtpPassword = Get("SMTP_PASSWORD"),
|
||||
SmtpFromAddress = Get("SMTP_FROM_ADDRESS"),
|
||||
MailCatcherConnectionString = GetOrNull("ConnectionStrings__mailcatcher"),
|
||||
|
||||
OAuthGitHubClientId = Get("OAUTH_GITHUB_CLIENT_ID"),
|
||||
OAuthGitHubClientSecret = Get("OAUTH_GITHUB_CLIENT_SECRET"),
|
||||
OAuthGoogleClientId = Get("OAUTH_GOOGLE_CLIENT_ID"),
|
||||
OAuthGoogleClientSecret = Get("OAUTH_GOOGLE_CLIENT_SECRET"),
|
||||
|
||||
// On the container, the etc directory is mounted at ./etc
|
||||
// But during development, it's at ../etc
|
||||
EtcDirectoryPath = Directory.Exists("./etc") ? "./etc" : "../etc"
|
||||
};
|
||||
}
|
||||
|
||||
private EnvSettings()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private static string? GetOrNull(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name);
|
||||
}
|
||||
|
||||
private static string Get(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name) ?? "";
|
||||
}
|
||||
|
||||
private static int GetInt(string name)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name) ?? "";
|
||||
if (int.TryParse(value, out var result))
|
||||
return result;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string MustGet(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name) ?? throw new Exception($"Missing {name} environment variable");
|
||||
}
|
||||
}
|
436
src/Features/Export/ExportController.cs
Executable file
436
src/Features/Export/ExportController.cs
Executable file
|
@ -0,0 +1,436 @@
|
|||
using Aptabase.Features.Authentication;
|
||||
using Aptabase.Features.Stats;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Aptabase.Features.Export;
|
||||
|
||||
public class DownloadRequest
|
||||
{
|
||||
public string BuildMode { get; set; } = "";
|
||||
public string AppId { get; set; } = "";
|
||||
public string AppName { get; set; } = "";
|
||||
public string Format { get; set; } = "";
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
}
|
||||
|
||||
public class MonthlyUsage
|
||||
{
|
||||
public int Year { get; set; }
|
||||
public int Month { get; set; }
|
||||
public long Events { get; set; }
|
||||
}
|
||||
|
||||
[ApiController, IsAuthenticated, HasReadAccessToApp]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public partial class ExportController(IQueryClient queryClient, ILogger<ExportController> logger) : Controller
|
||||
{
|
||||
private readonly IQueryClient _queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient));
|
||||
private readonly ILogger<ExportController> _logger = logger;
|
||||
|
||||
[HttpGet("/api/_export/usage")]
|
||||
public async Task<IActionResult> MonthlyUsage([FromQuery] string buildMode, [FromQuery] string appId, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await _queryClient.NamedQueryAsync<MonthlyUsage>("monthly_usage__v1", new {
|
||||
app_id = GetAppId(buildMode, appId)
|
||||
}, cancellationToken);
|
||||
|
||||
if (buildMode.Equals("debug", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(rows.Take(5));
|
||||
}
|
||||
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpGet("/api/_export/download")]
|
||||
public async Task<IActionResult> Download([FromQuery] DownloadRequest body)
|
||||
{
|
||||
if (!body.StartDate.HasValue || !body.EndDate.HasValue)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var startDate = body.StartDate.Value;
|
||||
var endDate = body.EndDate.Value;
|
||||
|
||||
var (formatName, contentType, fileExtension) = GetFormat(body.Format);
|
||||
var appName = UnsafeCharacters().Replace(body.AppName, "").ToLower();
|
||||
var fileName = $"{appName}-{body.BuildMode.ToLower()}-{startDate:yyyy-MM-dd}.{fileExtension}";
|
||||
|
||||
var countQuery = $@"SELECT COUNT(*) as event_count
|
||||
FROM events
|
||||
WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}'
|
||||
AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}'
|
||||
FORMAT JSON";
|
||||
|
||||
using var stream = await _queryClient.StreamResponseAsync(countQuery, HttpContext.RequestAborted);
|
||||
long eventCount = 0;
|
||||
using var reader = new StreamReader(stream);
|
||||
var jsonString = await reader.ReadToEndAsync();
|
||||
using var doc = JsonDocument.Parse(jsonString);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("data", out JsonElement data) &&
|
||||
data.GetArrayLength() > 0 &&
|
||||
data[0].TryGetProperty("event_count", out JsonElement countElement))
|
||||
{
|
||||
switch (countElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
_ = countElement.TryGetInt64(out eventCount);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
var countString = countElement.GetString();
|
||||
_ = long.TryParse(countString, out eventCount);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected event_count type: {countElement.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
if (formatName == "Parquet")
|
||||
{
|
||||
var query = $@"SELECT timestamp, user_id, session_id,
|
||||
event_name,
|
||||
replace(replace(string_props, '\u0022', '\''), '\u0027', '\'') as string_props,
|
||||
numeric_props,
|
||||
os_name, os_version,
|
||||
locale, app_version, app_build_number,
|
||||
engine_name, engine_version,
|
||||
country_code, {COUNTRY_NAME_COLUMN}, region_name
|
||||
FROM events
|
||||
WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}'
|
||||
AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}'
|
||||
ORDER BY timestamp DESC
|
||||
FORMAT {formatName}";
|
||||
|
||||
return File(await _queryClient.StreamResponseAsync(query, HttpContext.RequestAborted), contentType, fileName);
|
||||
}
|
||||
|
||||
return new StreamingFileResult(async (stream, httpContext, cancellationToken) =>
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status200OK;
|
||||
|
||||
// todo use s3 buckets
|
||||
try
|
||||
{
|
||||
const int pageSize = 100000;
|
||||
long processedRecords = 0;
|
||||
|
||||
while (processedRecords < eventCount && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var query = $@"
|
||||
SELECT timestamp, user_id, session_id,
|
||||
event_name,
|
||||
replace(replace(string_props, '\u0022', '\''), '\u0027', '\'') as string_props,
|
||||
numeric_props,
|
||||
os_name, os_version,
|
||||
locale, app_version, app_build_number,
|
||||
engine_name, engine_version,
|
||||
country_code, {COUNTRY_NAME_COLUMN}, region_name
|
||||
FROM events
|
||||
WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}'
|
||||
AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {pageSize}
|
||||
OFFSET {processedRecords}
|
||||
FORMAT {formatName}";
|
||||
|
||||
using var pageStream = await _queryClient.StreamResponseAsync(query, cancellationToken);
|
||||
await pageStream.CopyToAsync(stream, cancellationToken);
|
||||
|
||||
processedRecords += pageSize;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while streaming download");
|
||||
|
||||
if (!httpContext.Response.HasStarted)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsync("An error occurred while processing your request.", cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
}, contentType, fileName);
|
||||
}
|
||||
|
||||
private static string GetAppId(string buildMode, string appId)
|
||||
{
|
||||
return buildMode.ToLower() switch
|
||||
{
|
||||
"debug" => $"{appId}_DEBUG",
|
||||
_ => appId,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string, string, string) GetFormat(string format)
|
||||
{
|
||||
return format.ToLower() switch
|
||||
{
|
||||
"parquet" => ("Parquet", "application/octet-stream", "parquet"),
|
||||
_ => ("CSVWithNames", "text/csv", "csv"),
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex("[^a-zA-Z0-9]")]
|
||||
private static partial Regex UnsafeCharacters();
|
||||
|
||||
private static readonly string COUNTRY_NAME_COLUMN = @"
|
||||
CASE country_code
|
||||
WHEN 'AF' THEN 'Afghanistan'
|
||||
WHEN 'AX' THEN 'Aland Islands'
|
||||
WHEN 'AL' THEN 'Albania'
|
||||
WHEN 'DZ' THEN 'Algeria'
|
||||
WHEN 'AS' THEN 'American Samoa'
|
||||
WHEN 'AD' THEN 'Andorra'
|
||||
WHEN 'AO' THEN 'Angola'
|
||||
WHEN 'AI' THEN 'Anguilla'
|
||||
WHEN 'AQ' THEN 'Antarctica'
|
||||
WHEN 'AG' THEN 'Antigua And Barbuda'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'AM' THEN 'Armenia'
|
||||
WHEN 'AW' THEN 'Aruba'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'AZ' THEN 'Azerbaijan'
|
||||
WHEN 'BS' THEN 'Bahamas'
|
||||
WHEN 'BH' THEN 'Bahrain'
|
||||
WHEN 'BD' THEN 'Bangladesh'
|
||||
WHEN 'BB' THEN 'Barbados'
|
||||
WHEN 'BY' THEN 'Belarus'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'BZ' THEN 'Belize'
|
||||
WHEN 'BJ' THEN 'Benin'
|
||||
WHEN 'BM' THEN 'Bermuda'
|
||||
WHEN 'BT' THEN 'Bhutan'
|
||||
WHEN 'BO' THEN 'Bolivia'
|
||||
WHEN 'BA' THEN 'Bosnia And Herzegovina'
|
||||
WHEN 'BW' THEN 'Botswana'
|
||||
WHEN 'BV' THEN 'Bouvet Island'
|
||||
WHEN 'BR' THEN 'Brazil'
|
||||
WHEN 'IO' THEN 'British Indian Ocean Territory'
|
||||
WHEN 'BN' THEN 'Brunei Darussalam'
|
||||
WHEN 'BQ' THEN 'Bonaire, Saba, Sint Eustatius'
|
||||
WHEN 'BG' THEN 'Bulgaria'
|
||||
WHEN 'BF' THEN 'Burkina Faso'
|
||||
WHEN 'BI' THEN 'Burundi'
|
||||
WHEN 'KH' THEN 'Cambodia'
|
||||
WHEN 'CM' THEN 'Cameroon'
|
||||
WHEN 'CA' THEN 'Canada'
|
||||
WHEN 'CV' THEN 'Cape Verde'
|
||||
WHEN 'KY' THEN 'Cayman Islands'
|
||||
WHEN 'CF' THEN 'Central African Republic'
|
||||
WHEN 'TD' THEN 'Chad'
|
||||
WHEN 'CL' THEN 'Chile'
|
||||
WHEN 'CN' THEN 'China'
|
||||
WHEN 'CX' THEN 'Christmas Island'
|
||||
WHEN 'CC' THEN 'Cocos (Keeling) Islands'
|
||||
WHEN 'CO' THEN 'Colombia'
|
||||
WHEN 'KM' THEN 'Comoros'
|
||||
WHEN 'CG' THEN 'Congo'
|
||||
WHEN 'CD' THEN 'Congo, Democratic Republic'
|
||||
WHEN 'CK' THEN 'Cook Islands'
|
||||
WHEN 'CR' THEN 'Costa Rica'
|
||||
WHEN 'CI' THEN 'Cote D\' Ivoire'
|
||||
WHEN 'HR' THEN 'Croatia'
|
||||
WHEN 'CU' THEN 'Cuba'
|
||||
WHEN 'CY' THEN 'Cyprus'
|
||||
WHEN 'CZ' THEN 'Czech Republic'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'DJ' THEN 'Djibouti'
|
||||
WHEN 'DM' THEN 'Dominica'
|
||||
WHEN 'DO' THEN 'Dominican Republic'
|
||||
WHEN 'EC' THEN 'Ecuador'
|
||||
WHEN 'EG' THEN 'Egypt'
|
||||
WHEN 'SV' THEN 'El Salvador'
|
||||
WHEN 'GQ' THEN 'Equatorial Guinea'
|
||||
WHEN 'ER' THEN 'Eritrea'
|
||||
WHEN 'EE' THEN 'Estonia'
|
||||
WHEN 'ET' THEN 'Ethiopia'
|
||||
WHEN 'FK' THEN 'Falkland Islands (Malvinas)'
|
||||
WHEN 'FO' THEN 'Faroe Islands'
|
||||
WHEN 'FJ' THEN 'Fiji'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'GF' THEN 'French Guiana'
|
||||
WHEN 'PF' THEN 'French Polynesia'
|
||||
WHEN 'TF' THEN 'French Southern Territories'
|
||||
WHEN 'GA' THEN 'Gabon'
|
||||
WHEN 'GM' THEN 'Gambia'
|
||||
WHEN 'GE' THEN 'Georgia'
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'GH' THEN 'Ghana'
|
||||
WHEN 'GI' THEN 'Gibraltar'
|
||||
WHEN 'GR' THEN 'Greece'
|
||||
WHEN 'GL' THEN 'Greenland'
|
||||
WHEN 'GD' THEN 'Grenada'
|
||||
WHEN 'GP' THEN 'Guadeloupe'
|
||||
WHEN 'GU' THEN 'Guam'
|
||||
WHEN 'GT' THEN 'Guatemala'
|
||||
WHEN 'GG' THEN 'Guernsey'
|
||||
WHEN 'GN' THEN 'Guinea'
|
||||
WHEN 'GW' THEN 'Guinea-Bissau'
|
||||
WHEN 'GY' THEN 'Guyana'
|
||||
WHEN 'HT' THEN 'Haiti'
|
||||
WHEN 'HM' THEN 'Heard Island & Mcdonald Islands'
|
||||
WHEN 'VA' THEN 'Holy See (Vatican City State)'
|
||||
WHEN 'HN' THEN 'Honduras'
|
||||
WHEN 'HK' THEN 'Hong Kong'
|
||||
WHEN 'HU' THEN 'Hungary'
|
||||
WHEN 'IS' THEN 'Iceland'
|
||||
WHEN 'IN' THEN 'India'
|
||||
WHEN 'ID' THEN 'Indonesia'
|
||||
WHEN 'IR' THEN 'Iran'
|
||||
WHEN 'IQ' THEN 'Iraq'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
WHEN 'IM' THEN 'Isle Of Man'
|
||||
WHEN 'IL' THEN 'Israel'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'JM' THEN 'Jamaica'
|
||||
WHEN 'JP' THEN 'Japan'
|
||||
WHEN 'JE' THEN 'Jersey'
|
||||
WHEN 'JO' THEN 'Jordan'
|
||||
WHEN 'KZ' THEN 'Kazakhstan'
|
||||
WHEN 'KE' THEN 'Kenya'
|
||||
WHEN 'KI' THEN 'Kiribati'
|
||||
WHEN 'KR' THEN 'Korea'
|
||||
WHEN 'KP' THEN 'North Korea'
|
||||
WHEN 'KW' THEN 'Kuwait'
|
||||
WHEN 'KG' THEN 'Kyrgyzstan'
|
||||
WHEN 'LA' THEN 'Lao People\'s Democratic Republic'
|
||||
WHEN 'LV' THEN 'Latvia'
|
||||
WHEN 'LB' THEN 'Lebanon'
|
||||
WHEN 'LS' THEN 'Lesotho'
|
||||
WHEN 'LR' THEN 'Liberia'
|
||||
WHEN 'LY' THEN 'Libyan Arab Jamahiriya'
|
||||
WHEN 'LI' THEN 'Liechtenstein'
|
||||
WHEN 'LT' THEN 'Lithuania'
|
||||
WHEN 'LU' THEN 'Luxembourg'
|
||||
WHEN 'MO' THEN 'Macao'
|
||||
WHEN 'MK' THEN 'Macedonia'
|
||||
WHEN 'MG' THEN 'Madagascar'
|
||||
WHEN 'MW' THEN 'Malawi'
|
||||
WHEN 'MY' THEN 'Malaysia'
|
||||
WHEN 'MV' THEN 'Maldives'
|
||||
WHEN 'ML' THEN 'Mali'
|
||||
WHEN 'MT' THEN 'Malta'
|
||||
WHEN 'MH' THEN 'Marshall Islands'
|
||||
WHEN 'MQ' THEN 'Martinique'
|
||||
WHEN 'MR' THEN 'Mauritania'
|
||||
WHEN 'MU' THEN 'Mauritius'
|
||||
WHEN 'YT' THEN 'Mayotte'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'FM' THEN 'Micronesia, Federated States Of'
|
||||
WHEN 'MD' THEN 'Moldova'
|
||||
WHEN 'MC' THEN 'Monaco'
|
||||
WHEN 'MN' THEN 'Mongolia'
|
||||
WHEN 'ME' THEN 'Montenegro'
|
||||
WHEN 'MS' THEN 'Montserrat'
|
||||
WHEN 'MA' THEN 'Morocco'
|
||||
WHEN 'MZ' THEN 'Mozambique'
|
||||
WHEN 'MM' THEN 'Myanmar'
|
||||
WHEN 'NA' THEN 'Namibia'
|
||||
WHEN 'NR' THEN 'Nauru'
|
||||
WHEN 'NP' THEN 'Nepal'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'AN' THEN 'Netherlands Antilles'
|
||||
WHEN 'NC' THEN 'New Caledonia'
|
||||
WHEN 'NZ' THEN 'New Zealand'
|
||||
WHEN 'NI' THEN 'Nicaragua'
|
||||
WHEN 'NE' THEN 'Niger'
|
||||
WHEN 'NG' THEN 'Nigeria'
|
||||
WHEN 'NU' THEN 'Niue'
|
||||
WHEN 'NF' THEN 'Norfolk Island'
|
||||
WHEN 'MP' THEN 'Northern Mariana Islands'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'OM' THEN 'Oman'
|
||||
WHEN 'PK' THEN 'Pakistan'
|
||||
WHEN 'PW' THEN 'Palau'
|
||||
WHEN 'PS' THEN 'Palestine'
|
||||
WHEN 'PA' THEN 'Panama'
|
||||
WHEN 'PG' THEN 'Papua New Guinea'
|
||||
WHEN 'PY' THEN 'Paraguay'
|
||||
WHEN 'PE' THEN 'Peru'
|
||||
WHEN 'PH' THEN 'Philippines'
|
||||
WHEN 'PN' THEN 'Pitcairn'
|
||||
WHEN 'PL' THEN 'Poland'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'PR' THEN 'Puerto Rico'
|
||||
WHEN 'QA' THEN 'Qatar'
|
||||
WHEN 'RE' THEN 'Reunion'
|
||||
WHEN 'RO' THEN 'Romania'
|
||||
WHEN 'RU' THEN 'Russian Federation'
|
||||
WHEN 'RW' THEN 'Rwanda'
|
||||
WHEN 'BL' THEN 'Saint Barthelemy'
|
||||
WHEN 'SH' THEN 'Saint Helena'
|
||||
WHEN 'KN' THEN 'Saint Kitts And Nevis'
|
||||
WHEN 'LC' THEN 'Saint Lucia'
|
||||
WHEN 'MF' THEN 'Saint Martin'
|
||||
WHEN 'PM' THEN 'Saint Pierre And Miquelon'
|
||||
WHEN 'VC' THEN 'Saint Vincent And Grenadines'
|
||||
WHEN 'WS' THEN 'Samoa'
|
||||
WHEN 'SM' THEN 'San Marino'
|
||||
WHEN 'ST' THEN 'Sao Tome And Principe'
|
||||
WHEN 'SA' THEN 'Saudi Arabia'
|
||||
WHEN 'SN' THEN 'Senegal'
|
||||
WHEN 'RS' THEN 'Serbia'
|
||||
WHEN 'SC' THEN 'Seychelles'
|
||||
WHEN 'SL' THEN 'Sierra Leone'
|
||||
WHEN 'SG' THEN 'Singapore'
|
||||
WHEN 'SK' THEN 'Slovakia'
|
||||
WHEN 'SI' THEN 'Slovenia'
|
||||
WHEN 'SB' THEN 'Solomon Islands'
|
||||
WHEN 'SO' THEN 'Somalia'
|
||||
WHEN 'ZA' THEN 'South Africa'
|
||||
WHEN 'GS' THEN 'South Georgia And Sandwich Isl.'
|
||||
WHEN 'SS' THEN 'South Sudan'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'LK' THEN 'Sri Lanka'
|
||||
WHEN 'SD' THEN 'Sudan'
|
||||
WHEN 'SR' THEN 'Suriname'
|
||||
WHEN 'SJ' THEN 'Svalbard And Jan Mayen'
|
||||
WHEN 'SZ' THEN 'Swaziland'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'SY' THEN 'Syrian Arab Republic'
|
||||
WHEN 'TW' THEN 'Taiwan'
|
||||
WHEN 'TJ' THEN 'Tajikistan'
|
||||
WHEN 'TZ' THEN 'Tanzania'
|
||||
WHEN 'TH' THEN 'Thailand'
|
||||
WHEN 'TL' THEN 'Timor-Leste'
|
||||
WHEN 'TG' THEN 'Togo'
|
||||
WHEN 'TK' THEN 'Tokelau'
|
||||
WHEN 'TO' THEN 'Tonga'
|
||||
WHEN 'TT' THEN 'Trinidad And Tobago'
|
||||
WHEN 'TN' THEN 'Tunisia'
|
||||
WHEN 'TR' THEN 'Turkey'
|
||||
WHEN 'TM' THEN 'Turkmenistan'
|
||||
WHEN 'TC' THEN 'Turks And Caicos Islands'
|
||||
WHEN 'TV' THEN 'Tuvalu'
|
||||
WHEN 'UG' THEN 'Uganda'
|
||||
WHEN 'UA' THEN 'Ukraine'
|
||||
WHEN 'AE' THEN 'United Arab Emirates'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'UM' THEN 'United States Outlying Islands'
|
||||
WHEN 'UY' THEN 'Uruguay'
|
||||
WHEN 'UZ' THEN 'Uzbekistan'
|
||||
WHEN 'VU' THEN 'Vanuatu'
|
||||
WHEN 'VE' THEN 'Venezuela'
|
||||
WHEN 'VN' THEN 'Vietnam'
|
||||
WHEN 'VG' THEN 'Virgin Islands, British'
|
||||
WHEN 'VI' THEN 'Virgin Islands, U.S.'
|
||||
WHEN 'WF' THEN 'Wallis And Futuna'
|
||||
WHEN 'EH' THEN 'Western Sahara'
|
||||
WHEN 'YE' THEN 'Yemen'
|
||||
WHEN 'ZM' THEN 'Zambia'
|
||||
WHEN 'ZW' THEN 'Zimbabwe'
|
||||
ELSE ''
|
||||
END AS country_name";
|
||||
|
||||
}
|
21
src/Features/Export/StreamingFileResult.cs
Executable file
21
src/Features/Export/StreamingFileResult.cs
Executable file
|
@ -0,0 +1,21 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Aptabase.Features.Export;
|
||||
|
||||
public class StreamingFileResult(Func<Stream, HttpContext, CancellationToken, Task> streamContent, string contentType, string fileName) : IActionResult
|
||||
{
|
||||
private readonly Func<Stream, HttpContext, CancellationToken, Task> _streamContent = streamContent;
|
||||
private readonly string _contentType = contentType;
|
||||
private readonly string _fileName = fileName;
|
||||
|
||||
public async Task ExecuteResultAsync(ActionContext context)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var response = httpContext.Response;
|
||||
|
||||
response.Headers.TryAdd("Content-Disposition", new[] { $"attachment; filename={_fileName}" });
|
||||
response.ContentType = _contentType;
|
||||
|
||||
await _streamContent(response.Body, httpContext, httpContext.RequestAborted);
|
||||
}
|
||||
}
|
11
src/Features/FeatureFlags/FeatureFlag.cs
Executable file
11
src/Features/FeatureFlags/FeatureFlag.cs
Executable file
|
@ -0,0 +1,11 @@
|
|||
namespace Aptabase.Features.FeatureFlags
|
||||
{
|
||||
public class FeatureFlag
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? AppId { get; set; }
|
||||
public string Key { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public string Environment { get; set; } = "";
|
||||
}
|
||||
}
|
105
src/Features/FeatureFlags/FeatureFlagStateController.cs
Executable file
105
src/Features/FeatureFlags/FeatureFlagStateController.cs
Executable file
|
@ -0,0 +1,105 @@
|
|||
using Aptabase.Data;
|
||||
using Aptabase.Features.Ingestion;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Aptabase.Features.FeatureFlags;
|
||||
|
||||
public class GetFeatureFlagBody
|
||||
{
|
||||
public SystemProperties SystemProps { get; set; } = new();
|
||||
public JsonDocument? Props { get; set; }
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class FeatureFlagStateController : Controller
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IIngestionCache _cache;
|
||||
private readonly IDbContext _db;
|
||||
|
||||
public FeatureFlagStateController(
|
||||
IDbContext db,
|
||||
IIngestionCache cache,
|
||||
ILogger<FeatureFlagStateController> logger)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v0/feature-flags/{featureFlagKey}")]
|
||||
[EnableCors("AllowAny")]
|
||||
[EnableRateLimiting("FeatureFlags")]
|
||||
public async Task<IActionResult> Single(
|
||||
string featureFlagKey,
|
||||
[FromHeader(Name = "App-Key")] string? appKey,
|
||||
[FromHeader(Name = "User-Agent")] string? userAgent,
|
||||
[FromBody] GetFeatureFlagBody body)
|
||||
{
|
||||
appKey = appKey?.ToUpper() ?? "";
|
||||
|
||||
var app = await _cache.FindByAppKey(appKey, HttpContext.RequestAborted);
|
||||
|
||||
if (string.IsNullOrEmpty(app.Id))
|
||||
{
|
||||
return NotFound($"Appplication not found with given app key: {appKey}");
|
||||
}
|
||||
|
||||
if (app.IsLocked)
|
||||
{
|
||||
return BadRequest($"Owner account is locked.");
|
||||
}
|
||||
|
||||
var flag = await _db.Connection.QueryFirstOrDefaultAsync<FeatureFlag>(@"
|
||||
SELECT f.key, f.value
|
||||
FROM feature_flags f
|
||||
WHERE f.app_id = @appId and f.key = @key",
|
||||
new
|
||||
{
|
||||
appId = app.Id,
|
||||
key = featureFlagKey,
|
||||
});
|
||||
|
||||
return Ok(flag);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v0/feature-flags")]
|
||||
[EnableCors("AllowAny")]
|
||||
[EnableRateLimiting("FeatureFlags")]
|
||||
public async Task<IActionResult> All(
|
||||
[FromHeader(Name = "App-Key")] string? appKey,
|
||||
[FromHeader(Name = "User-Agent")] string? userAgent,
|
||||
[FromBody] GetFeatureFlagBody body)
|
||||
{
|
||||
appKey = appKey?.ToUpper() ?? "";
|
||||
|
||||
var app = await _cache.FindByAppKey(appKey, HttpContext.RequestAborted);
|
||||
|
||||
if (string.IsNullOrEmpty(app.Id))
|
||||
{
|
||||
return NotFound($"Appplication not found with given app key: {appKey}");
|
||||
}
|
||||
|
||||
if (app.IsLocked)
|
||||
{
|
||||
return BadRequest($"Owner account is locked.");
|
||||
}
|
||||
|
||||
var flags = await _db.Connection.QueryAsync<FeatureFlag>(@"
|
||||
SELECT f.key, f.value
|
||||
FROM feature_flags f
|
||||
WHERE f.app_id = @appId
|
||||
LIMIT 50",
|
||||
new
|
||||
{
|
||||
appId = app.Id,
|
||||
});
|
||||
|
||||
return Ok(flags);
|
||||
}
|
||||
}
|
173
src/Features/FeatureFlags/FeatureFlagsController.cs
Executable file
173
src/Features/FeatureFlags/FeatureFlagsController.cs
Executable file
|
@ -0,0 +1,173 @@
|
|||
using Aptabase.Data;
|
||||
using Aptabase.Features.Apps;
|
||||
using Aptabase.Features.Authentication;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Aptabase.Features.FeatureFlags;
|
||||
|
||||
public class CreateFeatureFlagRequestBody : UpdateFeatureFlagRequestBody
|
||||
{
|
||||
public string AppId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpdateFeatureFlagRequestBody
|
||||
{
|
||||
[Required]
|
||||
[StringLength(255, MinimumLength = 2)]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(4000, MinimumLength = 1)]
|
||||
public string Value { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(50, MinimumLength = 2)]
|
||||
public string Environment { get; set; } = "";
|
||||
}
|
||||
|
||||
[ApiController, IsAuthenticated]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class FeatureFlagsController : Controller
|
||||
{
|
||||
private readonly IDbContext _db;
|
||||
private readonly IAppQueries _appQueries;
|
||||
|
||||
public FeatureFlagsController(IDbContext db, IAppQueries appQueries)
|
||||
{
|
||||
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
_appQueries = appQueries ?? throw new ArgumentNullException(nameof(appQueries));
|
||||
}
|
||||
|
||||
[HttpGet("/api/_flags/{appId}")]
|
||||
public async Task<IActionResult> List(string appId)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var app = await _appQueries.GetOwnedAppAsync(appId, user.Id);
|
||||
|
||||
if (app == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var flags = await _db.Connection.QueryAsync<FeatureFlag>(@"
|
||||
SELECT f.id, f.app_id, f.key, f.value, f.environment, f.conditions
|
||||
FROM feature_flags f
|
||||
WHERE f.app_id = @appId
|
||||
LIMIT 50",
|
||||
new
|
||||
{
|
||||
appId,
|
||||
});
|
||||
|
||||
return Ok(flags);
|
||||
}
|
||||
|
||||
[HttpPost("/api/_flags")]
|
||||
public async Task<IActionResult> Create([FromBody] CreateFeatureFlagRequestBody body)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var app = await _appQueries.GetOwnedAppAsync(body.AppId, user.Id);
|
||||
|
||||
if (app == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var flag = new FeatureFlag
|
||||
{
|
||||
Id = NanoId.New(),
|
||||
AppId = body.AppId,
|
||||
Key = body.Key,
|
||||
Value = body.Value,
|
||||
Environment = body.Environment,
|
||||
};
|
||||
|
||||
await _db.Connection.ExecuteAsync(@"
|
||||
INSERT INTO feature_flags (id, app_id, key, value, environment, created_at, modified_at)
|
||||
VALUES (@id, @appId, @key, @value, @environment, now(), now())",
|
||||
new
|
||||
{
|
||||
id = flag.Id,
|
||||
appId = flag.AppId,
|
||||
key = flag.Key,
|
||||
value = flag.Value,
|
||||
environment = flag.Environment,
|
||||
});
|
||||
|
||||
return Ok(app);
|
||||
}
|
||||
|
||||
[HttpPut("/api/_flags/{flagId}")]
|
||||
public async Task<IActionResult> Update(string flagId, [FromBody] UpdateFeatureFlagRequestBody body)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var flag = await GetOwnedFeatureFlagAsync(flagId, user.Id);
|
||||
|
||||
if (flag == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
flag.Key = body.Key;
|
||||
flag.Value = body.Value;
|
||||
flag.Environment = body.Environment;
|
||||
|
||||
await _db.Connection.ExecuteAsync(
|
||||
"UPDATE feature_flags SET Key = @key, Value = @value, Environment = @environment, WHERE id = @flagId",
|
||||
new
|
||||
{
|
||||
flagId,
|
||||
key = flag.Key,
|
||||
value = flag.Value,
|
||||
environment = flag.Environment,
|
||||
});
|
||||
|
||||
return Ok(flag);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/_flags/{flagId}")]
|
||||
public async Task<IActionResult> Delete(string flagId)
|
||||
{
|
||||
var user = this.GetCurrentUserIdentity();
|
||||
|
||||
var flag = await GetOwnedFeatureFlagAsync(flagId, user.Id);
|
||||
|
||||
if (flag == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _db.Connection.ExecuteAsync(
|
||||
"DELETE FROM feature_flags WHERE id = @flagId",
|
||||
new
|
||||
{
|
||||
flagId,
|
||||
});
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private Task<FeatureFlag?> GetOwnedFeatureFlagAsync(string flagId, string userId)
|
||||
{
|
||||
return _db.Connection.QueryFirstOrDefaultAsync<FeatureFlag>(@"
|
||||
SELECT f.id, f.app_id, f.key, f.value, f.environment, f.conditions
|
||||
FROM feature_flags f
|
||||
JOIN apps a
|
||||
ON f.app_id = a.id
|
||||
INNER JOIN users u
|
||||
ON u.id = a.owner_id
|
||||
WHERE f.id = @flagId
|
||||
AND a.owner_id = @userId
|
||||
AND a.deleted_at IS NULL",
|
||||
new
|
||||
{
|
||||
flagId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
44
src/Features/GeoIP/CloudGeoClient.cs
Executable file
44
src/Features/GeoIP/CloudGeoClient.cs
Executable file
|
@ -0,0 +1,44 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Aptabase.Features.GeoIP;
|
||||
|
||||
public class CloudGeoClient : GeoIPClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _regions;
|
||||
|
||||
public CloudGeoClient(EnvSettings env)
|
||||
: base(env)
|
||||
{
|
||||
var text = File.ReadAllText(Path.Combine(env.EtcDirectoryPath, "geoip/iso3166-2.json"));
|
||||
var regions = JsonSerializer.Deserialize<Dictionary<string, string>>(text);
|
||||
if (regions == null)
|
||||
throw new Exception("Failed to deserialize geoip/iso3166-2.json");
|
||||
|
||||
_regions = regions;
|
||||
}
|
||||
|
||||
public override GeoLocation GetClientLocation(HttpContext httpContext)
|
||||
{
|
||||
var countryCode = GetHeader(httpContext, "cdn-requestcountrycode", "CloudFront-Viewer-Country", "CF-IPCountry");
|
||||
var regionCode = GetHeader(httpContext, "cdn-requeststatecode", "Cloudfront-Viewer-Country-Region");
|
||||
var regionName = _regions.TryGetValue($"{countryCode}-{regionCode}", out var name) ? name : "";
|
||||
|
||||
return new GeoLocation
|
||||
{
|
||||
CountryCode = countryCode ?? "",
|
||||
RegionName = regionName ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetHeader(HttpContext httpContext, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var value = httpContext.Request.Headers[name].ToString()?.ToUpper() ?? "";
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
28
src/Features/GeoIP/DatabaseGeoClient.cs
Executable file
28
src/Features/GeoIP/DatabaseGeoClient.cs
Executable file
|
@ -0,0 +1,28 @@
|
|||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace Aptabase.Features.GeoIP;
|
||||
|
||||
public class DatabaseGeoClient : GeoIPClient
|
||||
{
|
||||
private readonly DatabaseReader _db;
|
||||
|
||||
public DatabaseGeoClient(EnvSettings env)
|
||||
: base(env)
|
||||
{
|
||||
_db = new DatabaseReader(Path.Combine(env.EtcDirectoryPath, "geoip/GeoLite2-City.mmdb"));
|
||||
}
|
||||
|
||||
public override GeoLocation GetClientLocation(HttpContext httpContext)
|
||||
{
|
||||
var ip = httpContext.ResolveClientIpAddress();
|
||||
|
||||
if (string.IsNullOrEmpty(ip))
|
||||
return GeoLocation.Empty;
|
||||
|
||||
return _db.TryCity(ip, out var city) ? new GeoLocation
|
||||
{
|
||||
CountryCode = city?.Country?.IsoCode?.ToUpper() ?? "",
|
||||
RegionName = city?.MostSpecificSubdivision.Name ?? ""
|
||||
} : GeoLocation.Empty;
|
||||
}
|
||||
}
|
15
src/Features/GeoIP/GeoIPExtensions.cs
Executable file
15
src/Features/GeoIP/GeoIPExtensions.cs
Executable file
|
@ -0,0 +1,15 @@
|
|||
namespace Aptabase.Features.GeoIP;
|
||||
|
||||
public static class GeoIPExtensions
|
||||
{
|
||||
public static void AddGeoIPClient(this IServiceCollection services, EnvSettings env)
|
||||
{
|
||||
if (env.IsManagedCloud)
|
||||
{
|
||||
services.AddSingleton<GeoIPClient, CloudGeoClient>();
|
||||
return;
|
||||
}
|
||||
|
||||
services.AddSingleton<GeoIPClient, DatabaseGeoClient>();
|
||||
}
|
||||
}
|
54
src/Features/GeoIP/IGeoIPClient.cs
Executable file
54
src/Features/GeoIP/IGeoIPClient.cs
Executable file
|
@ -0,0 +1,54 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aptabase.Features.GeoIP;
|
||||
|
||||
public readonly struct GeoLocation
|
||||
{
|
||||
public readonly string CountryCode { get; init; }
|
||||
public readonly string RegionName { get; init; }
|
||||
|
||||
public static GeoLocation Empty => new()
|
||||
{
|
||||
CountryCode = "",
|
||||
RegionName = ""
|
||||
};
|
||||
}
|
||||
|
||||
public readonly struct Coordinates
|
||||
{
|
||||
[JsonPropertyName("lat")]
|
||||
public readonly double Latitude { get; init; }
|
||||
[JsonPropertyName("lng")]
|
||||
public readonly double Longitude { get; init; }
|
||||
}
|
||||
|
||||
public abstract class GeoIPClient
|
||||
{
|
||||
public abstract GeoLocation GetClientLocation(HttpContext httpContext);
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, Coordinates>> _coordinates;
|
||||
|
||||
public GeoIPClient(EnvSettings env)
|
||||
{
|
||||
var text = File.ReadAllText(Path.Combine(env.EtcDirectoryPath, "geoip/coordinates.json"));
|
||||
var coordinates = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, Coordinates>>>(text);
|
||||
if (coordinates == null)
|
||||
throw new Exception("Failed to deserialize coordinates.json");
|
||||
|
||||
_coordinates = coordinates;
|
||||
}
|
||||
|
||||
public (double, double) GetLatLng(string countryCode, string regionName)
|
||||
{
|
||||
if (_coordinates.TryGetValue(countryCode, out var regions))
|
||||
{
|
||||
if (regions?.TryGetValue(regionName, out var coordinates) == true)
|
||||
{
|
||||
return (coordinates.Latitude, coordinates.Longitude);
|
||||
}
|
||||
}
|
||||
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
100
src/Features/Ingestion/Buffer/ClickHouseIngestionClient.cs
Executable file
100
src/Features/Ingestion/Buffer/ClickHouseIngestionClient.cs
Executable file
|
@ -0,0 +1,100 @@
|
|||
using Dapper;
|
||||
using ClickHouse.Client.Copy;
|
||||
using ClickHouse.Client.ADO;
|
||||
|
||||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public class ClickHouseIngestionClient : IIngestionClient
|
||||
{
|
||||
private readonly ClickHouseConnection _conn;
|
||||
|
||||
private readonly string[] COLUMNS = [
|
||||
"app_id",
|
||||
"timestamp",
|
||||
"event_name",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"os_name",
|
||||
"os_version",
|
||||
"device_model",
|
||||
"locale",
|
||||
"app_version",
|
||||
"app_build_number",
|
||||
"engine_name",
|
||||
"engine_version",
|
||||
"sdk_version",
|
||||
"country_code",
|
||||
"region_name",
|
||||
"city",
|
||||
"string_props",
|
||||
"numeric_props",
|
||||
"ttl"
|
||||
];
|
||||
|
||||
public ClickHouseIngestionClient(ClickHouseConnection conn, ILogger<ClickHouseIngestionClient> logger)
|
||||
{
|
||||
_conn = conn ?? throw new ArgumentNullException(nameof(conn));
|
||||
}
|
||||
|
||||
public async Task<long> SendEventAsync(EventRow row)
|
||||
{
|
||||
return await _conn.ExecuteAsync($@"INSERT INTO events ({string.Join(",", COLUMNS)}) VALUES (@{string.Join(", @", COLUMNS)})", new {
|
||||
app_id = row.AppId,
|
||||
timestamp = row.Timestamp,
|
||||
event_name = row.EventName,
|
||||
user_id = row.UserId,
|
||||
session_id = row.SessionId,
|
||||
os_name = row.OSName,
|
||||
os_version = row.OSVersion,
|
||||
device_model = row.DeviceModel,
|
||||
locale = row.Locale,
|
||||
app_version = row.AppVersion,
|
||||
app_build_number = row.AppBuildNumber,
|
||||
engine_name = row.EngineName,
|
||||
engine_version = row.EngineVersion,
|
||||
sdk_version = row.SdkVersion,
|
||||
country_code = row.CountryCode,
|
||||
region_name = row.RegionName,
|
||||
city = row.City,
|
||||
string_props = row.StringProps,
|
||||
numeric_props = row.NumericProps,
|
||||
ttl = row.TTL,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<long> BulkSendEventAsync(IEnumerable<EventRow> rows, CancellationToken ct = default)
|
||||
{
|
||||
using var bulkCopy = new ClickHouseBulkCopy(_conn)
|
||||
{
|
||||
DestinationTableName = "events",
|
||||
BatchSize = 1000,
|
||||
ColumnNames = COLUMNS,
|
||||
};
|
||||
|
||||
var values = rows.Select(row => new object[] {
|
||||
row.AppId,
|
||||
row.Timestamp,
|
||||
row.EventName,
|
||||
row.UserId,
|
||||
row.SessionId,
|
||||
row.OSName,
|
||||
row.OSVersion,
|
||||
row.DeviceModel,
|
||||
row.Locale,
|
||||
row.AppVersion,
|
||||
row.AppBuildNumber,
|
||||
row.EngineName,
|
||||
row.EngineVersion,
|
||||
row.SdkVersion,
|
||||
row.CountryCode,
|
||||
row.RegionName,
|
||||
row.City,
|
||||
row.StringProps,
|
||||
row.NumericProps,
|
||||
row.TTL,
|
||||
});
|
||||
await bulkCopy.InitAsync();
|
||||
await bulkCopy.WriteToServerAsync(values, ct);
|
||||
return bulkCopy.RowsWritten;
|
||||
}
|
||||
}
|
75
src/Features/Ingestion/Buffer/EventBackgroundWritter.cs
Executable file
75
src/Features/Ingestion/Buffer/EventBackgroundWritter.cs
Executable file
|
@ -0,0 +1,75 @@
|
|||
|
||||
|
||||
using System.Diagnostics;
|
||||
using Aptabase.Features.Privacy;
|
||||
|
||||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public class EventBackgroundWritter : BackgroundService
|
||||
{
|
||||
private readonly IEventBuffer _buffer;
|
||||
private readonly IIngestionClient _client;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IUserHasher _hasher;
|
||||
private readonly Stopwatch _watch = new();
|
||||
|
||||
public EventBackgroundWritter(IEventBuffer buffer, IUserHasher hasher, IIngestionClient client, ILogger<EventBackgroundWritter> logger)
|
||||
{
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
_buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("EventBackgroundWritter is starting.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FlushEvents();
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// We need to wait a few seconds when cancellation is requested
|
||||
// because some events may be added to the buffer after the cancellation
|
||||
// After flushing we can safely exit
|
||||
_logger.LogInformation("EventBackgroundWritter is stopping.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
await FlushEvents();
|
||||
_logger.LogInformation("EventBackgroundWritter stopped.");
|
||||
}
|
||||
|
||||
public int Count() => _buffer.TakeAll().Length;
|
||||
|
||||
public async Task FlushEvents()
|
||||
{
|
||||
var events = _buffer.TakeAll();
|
||||
if (events.Length == 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
_watch.Restart();
|
||||
|
||||
var rows = await Task.WhenAll(events.Select(ToEventRow));
|
||||
|
||||
await _client.BulkSendEventAsync(rows);
|
||||
_watch.Stop();
|
||||
_logger.LogInformation("Flushed {Count} events in {TimeMs}ms.", events.Length, _watch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send events. {Count} events were discarded.", events.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<EventRow> ToEventRow(TrackingEvent e)
|
||||
{
|
||||
var userId = await _hasher.CalculateHash(e.Timestamp, e.AppId, e.SessionId, e.ClientIpAddress, e.UserAgent);
|
||||
return new EventRow(ref e, userId);
|
||||
}
|
||||
}
|
89
src/Features/Ingestion/Buffer/EventRow.cs
Executable file
89
src/Features/Ingestion/Buffer/EventRow.cs
Executable file
|
@ -0,0 +1,89 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public readonly struct EventRow
|
||||
{
|
||||
// Events from Debug builds are kept for 6 months
|
||||
public static readonly TimeSpan DebugTTL = TimeSpan.FromDays(182);
|
||||
// Events from Release builds are kept for 5 years
|
||||
public static readonly TimeSpan ReleaseTTL = TimeSpan.FromDays(5 * 365);
|
||||
|
||||
public readonly string AppId;
|
||||
public readonly DateTime Timestamp;
|
||||
public readonly string EventName;
|
||||
public readonly string UserId;
|
||||
public readonly string SessionId;
|
||||
public readonly string OSName;
|
||||
public readonly string OSVersion;
|
||||
public readonly string DeviceModel;
|
||||
public readonly string Locale;
|
||||
public readonly string AppVersion;
|
||||
public readonly string AppBuildNumber;
|
||||
public readonly string EngineName;
|
||||
public readonly string EngineVersion;
|
||||
public readonly string SdkVersion;
|
||||
public readonly string CountryCode;
|
||||
public readonly string RegionName;
|
||||
public readonly string City;
|
||||
public readonly string StringProps;
|
||||
public readonly string NumericProps;
|
||||
public readonly DateTime TTL;
|
||||
|
||||
public EventRow(ref TrackingEvent e, string userId)
|
||||
{
|
||||
var ttl = e.IsDebug ? DebugTTL : ReleaseTTL;
|
||||
|
||||
AppId = e.IsDebug ? $"{e.AppId}_DEBUG" : e.AppId;
|
||||
Timestamp = e.Timestamp;
|
||||
EventName = e.EventName;
|
||||
UserId = userId;
|
||||
SessionId = e.SessionId;
|
||||
OSName = e.OSName;
|
||||
OSVersion = e.OSVersion;
|
||||
DeviceModel = e.DeviceModel;
|
||||
Locale = e.Locale;
|
||||
AppVersion = e.AppVersion;
|
||||
AppBuildNumber = e.AppBuildNumber;
|
||||
EngineName = e.EngineName;
|
||||
EngineVersion = e.EngineVersion;
|
||||
SdkVersion = e.SdkVersion;
|
||||
CountryCode = e.CountryCode;
|
||||
RegionName = e.RegionName;
|
||||
City = "";
|
||||
StringProps = e.StringProps;
|
||||
NumericProps = e.NumericProps;
|
||||
TTL = e.Timestamp.Add(ttl);
|
||||
}
|
||||
|
||||
public void WriteJson(StringWriter writer)
|
||||
{
|
||||
writer.Write("{");
|
||||
WriteProperty(writer, "appId", AppId);
|
||||
WriteProperty(writer, "timestamp", Timestamp.ToString("o"));
|
||||
WriteProperty(writer, "eventName", EventName);
|
||||
WriteProperty(writer, "userId", UserId);
|
||||
WriteProperty(writer, "sessionId", SessionId);
|
||||
WriteProperty(writer, "osName", OSName);
|
||||
WriteProperty(writer, "osVersion", OSVersion);
|
||||
WriteProperty(writer, "deviceModel", DeviceModel);
|
||||
WriteProperty(writer, "locale", Locale);
|
||||
WriteProperty(writer, "appVersion", AppVersion);
|
||||
WriteProperty(writer, "appBuildNumber", AppBuildNumber);
|
||||
WriteProperty(writer, "engineName", EngineName);
|
||||
WriteProperty(writer, "engineVersion", EngineVersion);
|
||||
WriteProperty(writer, "sdkVersion", SdkVersion);
|
||||
WriteProperty(writer, "countryCode", CountryCode);
|
||||
WriteProperty(writer, "regionName", RegionName);
|
||||
WriteProperty(writer, "city", "");
|
||||
WriteProperty(writer, "stringProps", StringProps);
|
||||
WriteProperty(writer, "numericProps", NumericProps);
|
||||
WriteProperty(writer, "ttl", TTL.ToString("o"), true);
|
||||
writer.Write("}");
|
||||
}
|
||||
|
||||
private static void WriteProperty(StringWriter writer, string name, string value, bool isLast = false)
|
||||
{
|
||||
writer.Write($"\"{name}\": \"{JsonEncodedText.Encode(value)}\" {(isLast ? "" : ",")}");
|
||||
}
|
||||
}
|
8
src/Features/Ingestion/Buffer/IIngestionClient.cs
Executable file
8
src/Features/Ingestion/Buffer/IIngestionClient.cs
Executable file
|
@ -0,0 +1,8 @@
|
|||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public interface IIngestionClient
|
||||
{
|
||||
Task<long> SendEventAsync(EventRow row);
|
||||
Task<long> BulkSendEventAsync(IEnumerable<EventRow> rows, CancellationToken ct = default);
|
||||
}
|
||||
|
49
src/Features/Ingestion/Buffer/InMemoryEventBuffer.cs
Executable file
49
src/Features/Ingestion/Buffer/InMemoryEventBuffer.cs
Executable file
|
@ -0,0 +1,49 @@
|
|||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public interface IEventBuffer
|
||||
{
|
||||
void Add(ref TrackingEvent @event);
|
||||
void AddRange(ref TrackingEvent[] events);
|
||||
void AddRange(ref IEnumerable<TrackingEvent> events);
|
||||
TrackingEvent[] TakeAll();
|
||||
}
|
||||
|
||||
public class InMemoryEventBuffer : IEventBuffer
|
||||
{
|
||||
private List<TrackingEvent> _buffer = new();
|
||||
private object _lock = new object();
|
||||
|
||||
public void Add(ref TrackingEvent @event)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_buffer.Add(@event);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRange(ref TrackingEvent[] events)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_buffer.AddRange(events);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddRange(ref IEnumerable<TrackingEvent> events)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_buffer.AddRange(events);
|
||||
}
|
||||
}
|
||||
|
||||
public TrackingEvent[] TakeAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _buffer.ToArray();
|
||||
_buffer.Clear();
|
||||
return items;
|
||||
}
|
||||
}
|
||||
}
|
77
src/Features/Ingestion/Buffer/TinybirdIngestionClient.cs
Executable file
77
src/Features/Ingestion/Buffer/TinybirdIngestionClient.cs
Executable file
|
@ -0,0 +1,77 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aptabase.Features.Ingestion.Buffer;
|
||||
|
||||
public class InsertResult
|
||||
{
|
||||
[JsonPropertyName("successful_rows")]
|
||||
public int SuccessfulRows { get; set; }
|
||||
[JsonPropertyName("quarantined_rows")]
|
||||
public int QuarantinedRows { get; set; }
|
||||
}
|
||||
|
||||
public class TinybirdIngestionClient : IIngestionClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan[] _retriesDelay =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(1000),
|
||||
TimeSpan.FromMilliseconds(3000),
|
||||
TimeSpan.FromMilliseconds(5000),
|
||||
];
|
||||
|
||||
public TinybirdIngestionClient(IHttpClientFactory factory, ILogger<TinybirdIngestionClient> logger)
|
||||
{
|
||||
_httpClient = factory.CreateClient("Tinybird");
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
private static string EventsPath => $"/v0/events?name=events&wait=true";
|
||||
|
||||
public Task<long> SendEventAsync(EventRow row)
|
||||
{
|
||||
return PostAsync(EventsPath, [row]);
|
||||
}
|
||||
|
||||
public Task<long> BulkSendEventAsync(IEnumerable<EventRow> rows, CancellationToken ct = default)
|
||||
{
|
||||
return PostAsync(EventsPath, rows, ct);
|
||||
}
|
||||
|
||||
private async Task<long> PostAsync(string path, IEnumerable<EventRow> rows, CancellationToken ct = default)
|
||||
{
|
||||
using var content = SerializeBody(rows);
|
||||
|
||||
for (var i = 0; i < _retriesDelay.Length; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsync(path, content, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<InsertResult>() ?? new InsertResult();
|
||||
return result.SuccessfulRows;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send events to Tinybird. Will retry again after {Delay}ms.", _retriesDelay[i].TotalMilliseconds);
|
||||
await Task.Delay(_retriesDelay[i]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception($"Failed to send events to Tinybird after {_retriesDelay.Length} retries.");
|
||||
}
|
||||
|
||||
private static StringContent SerializeBody(IEnumerable<EventRow> rows)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.WriteJson(writer);
|
||||
writer.Write("\n");
|
||||
}
|
||||
|
||||
return new StringContent(writer.ToString());
|
||||
}
|
||||
}
|
28
src/Features/Ingestion/Buffer/TrackingEvent.cs
Executable file
28
src/Features/Ingestion/Buffer/TrackingEvent.cs
Executable file
|
@ -0,0 +1,28 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
public readonly struct TrackingEvent
|
||||
{
|
||||
public readonly string ClientIpAddress { get; init; }
|
||||
public readonly string UserAgent { get; init; }
|
||||
|
||||
public readonly string AppId { get; init; }
|
||||
public readonly DateTime Timestamp { get; init; }
|
||||
public readonly string EventName { get; init; }
|
||||
public readonly string SessionId { get; init; }
|
||||
public readonly string OSName { get; init; }
|
||||
public readonly string OSVersion { get; init; }
|
||||
public readonly string DeviceModel { get; init; }
|
||||
public readonly string Locale { get; init; }
|
||||
public readonly string AppVersion { get; init; }
|
||||
public readonly string AppBuildNumber { get; init; }
|
||||
public readonly string EngineName { get; init; }
|
||||
public readonly string EngineVersion { get; init; }
|
||||
public readonly string SdkVersion { get; init; }
|
||||
public readonly string CountryCode { get; init; }
|
||||
public readonly string RegionName { get; init; }
|
||||
public readonly string StringProps { get; init; }
|
||||
public readonly string NumericProps { get; init; }
|
||||
public readonly bool IsDebug { get; init; }
|
||||
}
|
193
src/Features/Ingestion/EventBody.cs
Executable file
193
src/Features/Ingestion/EventBody.cs
Executable file
|
@ -0,0 +1,193 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
public class SystemProperties
|
||||
{
|
||||
public bool IsDebug { get; set; }
|
||||
|
||||
[StringLength(30)]
|
||||
public string? OSName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? OSVersion { get; set; }
|
||||
|
||||
[StringLength(10)]
|
||||
public string? Locale { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? AppVersion { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? AppBuildNumber { get; set; }
|
||||
|
||||
[StringLength(30)]
|
||||
public string? EngineName { get; set; }
|
||||
|
||||
[StringLength(30)]
|
||||
public string? EngineVersion { get; set; }
|
||||
|
||||
[Required, StringLength(40)]
|
||||
public string SdkVersion { get; set; } = "";
|
||||
|
||||
[StringLength(100)]
|
||||
public string? DeviceModel { get; set; }
|
||||
}
|
||||
|
||||
public class EventBody
|
||||
{
|
||||
[Required, StringLength(60)]
|
||||
public string EventName { get; set; } = "";
|
||||
|
||||
private DateTime _ts;
|
||||
public DateTime Timestamp
|
||||
{
|
||||
get => _ts;
|
||||
set => _ts = value > DateTime.UtcNow ? DateTime.UtcNow : value;
|
||||
}
|
||||
|
||||
[Required]
|
||||
public string SessionId { get; set; } = "";
|
||||
|
||||
public SystemProperties SystemProps { get; set; } = new();
|
||||
public JsonDocument? Props { get; set; }
|
||||
|
||||
public (JsonObject, JsonObject) SplitProps()
|
||||
{
|
||||
var stringValues = new JsonObject();
|
||||
var numericValues = new JsonObject();
|
||||
|
||||
if (Props != null)
|
||||
{
|
||||
// Sort by key to ensure consistent order might be useful in future!
|
||||
foreach (var property in Props.RootElement.EnumerateObject().OrderBy(x => x.Name))
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.Number)
|
||||
numericValues.Add(property.Name, property.Value.GetDecimal());
|
||||
else if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var propertyValue = property.Value.GetString() ?? "";
|
||||
stringValues.Add(property.Name, propertyValue.Truncate(180, "..."));
|
||||
}
|
||||
else if (property.Value.ValueKind == JsonValueKind.True)
|
||||
stringValues.Add(property.Name, "true");
|
||||
else if (property.Value.ValueKind == JsonValueKind.False)
|
||||
stringValues.Add(property.Name, "false");
|
||||
else if (property.Value.ValueKind == JsonValueKind.Array)
|
||||
stringValues.Add(property.Name, "[Array]");
|
||||
else if (property.Value.ValueKind == JsonValueKind.Object)
|
||||
stringValues.Add(property.Name, "{Object}");
|
||||
else if (property.Value.ValueKind == JsonValueKind.Null)
|
||||
stringValues.Add(property.Name, "");
|
||||
else if (property.Value.ValueKind == JsonValueKind.Undefined)
|
||||
stringValues.Add(property.Name, "");
|
||||
}
|
||||
}
|
||||
|
||||
return (stringValues, numericValues);
|
||||
}
|
||||
|
||||
public (bool, string) IsValid(ILogger logger)
|
||||
{
|
||||
var (valid, msg) = ValidateSessionId(logger);
|
||||
if (!valid)
|
||||
return (false, msg);
|
||||
|
||||
if (Timestamp > DateTime.UtcNow.AddMinutes(10))
|
||||
return (false, "Future events are not allowed.");
|
||||
|
||||
if (Timestamp < DateTime.UtcNow.AddDays(-1))
|
||||
{
|
||||
logger.LogWarning("Event timestamp {EventTimestamp} is too old.", Timestamp);
|
||||
return (false, "Event is too old.");
|
||||
}
|
||||
|
||||
var locale = LocaleFormatter.FormatLocale(SystemProps.Locale);
|
||||
if (locale is null)
|
||||
logger.LogWarning("Invalid locale {Locale} received from {OS} using {SdkVersion}", locale, SystemProps.OSName, SystemProps.SdkVersion);
|
||||
|
||||
SystemProps.Locale = locale;
|
||||
|
||||
(valid, msg) = ValidateProps();
|
||||
if (!valid)
|
||||
return (false, msg);
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
private (bool, string) ValidateSessionId(ILogger logger)
|
||||
{
|
||||
if (ulong.TryParse(SessionId, out var numericSessionId))
|
||||
return ValidateNumericSessionId(numericSessionId, logger);
|
||||
|
||||
if (SessionId.Length > 36)
|
||||
return (false, $"SessionId must be less than or equal to 36 characters, was: {SessionId}");
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
private (bool, string) ValidateNumericSessionId(ulong id, ILogger logger)
|
||||
{
|
||||
var secondsSinceEpoch = id / 100_000_000;
|
||||
var sessionStartedAt = DateTimeOffset.FromUnixTimeSeconds((long)secondsSinceEpoch).UtcDateTime;
|
||||
|
||||
if (sessionStartedAt > DateTime.UtcNow.AddMinutes(10))
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} timestamp {StartedAt} is in future, received from {SdkVersion}.", id, sessionStartedAt, SystemProps.SdkVersion);
|
||||
return (false, "Future sessions are not allowed.");
|
||||
}
|
||||
|
||||
if (sessionStartedAt < DateTime.UtcNow.AddDays(-7))
|
||||
{
|
||||
logger.LogWarning("Session {SessionId} timestamp {StartedAt} is too old, received from {SdkVersion}.", id, sessionStartedAt, SystemProps.SdkVersion);
|
||||
return (false, "Session is too old.");
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
private (bool, string) ValidateProps()
|
||||
{
|
||||
if (Props is not null)
|
||||
{
|
||||
if (Props.RootElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var valueAsString = Props.RootElement.GetString() ?? "";
|
||||
if (TryParseDocument(valueAsString, out var doc) && doc is not null)
|
||||
Props = doc;
|
||||
else
|
||||
return (false, $"Props must be an object or a valid stringified JSON, was: {Props.RootElement.GetRawText()}");
|
||||
}
|
||||
|
||||
if (Props.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return (false, $"Props must be an object or a valid stringified JSON, was: {Props.RootElement.GetRawText()}");
|
||||
|
||||
foreach (var prop in Props.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prop.Name))
|
||||
return (false, "Property key must not be empty.");
|
||||
|
||||
if (prop.Name.Length > 40)
|
||||
return (false, $"Property key '{prop.Name}' must be less than or equal to 40 characters. Props was: {Props.RootElement.GetRawText()}");
|
||||
}
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
private static bool TryParseDocument(string json, out JsonDocument? doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
doc = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
157
src/Features/Ingestion/EventsController.cs
Executable file
157
src/Features/Ingestion/EventsController.cs
Executable file
|
@ -0,0 +1,157 @@
|
|||
using Aptabase.Features.GeoIP;
|
||||
using Aptabase.Features.Ingestion.Buffer;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
[ApiController]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public class EventsController : Controller
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly IIngestionCache _cache;
|
||||
private readonly IEventBuffer _buffer;
|
||||
private readonly GeoIPClient _geoIP;
|
||||
|
||||
public EventsController(IIngestionCache cache,
|
||||
IEventBuffer buffer,
|
||||
GeoIPClient geoIP,
|
||||
ILogger<EventsController> logger)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
|
||||
_geoIP = geoIP ?? throw new ArgumentNullException(nameof(geoIP));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v0/event")]
|
||||
[EnableCors("AllowAny")]
|
||||
[EnableRateLimiting("EventIngestion")]
|
||||
public async Task<IActionResult> Single(
|
||||
[FromHeader(Name = "App-Key")] string? appKey,
|
||||
[FromHeader(Name = "User-Agent")] string? userAgent,
|
||||
[FromBody] EventBody body,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
appKey = appKey?.ToUpper() ?? "";
|
||||
|
||||
var (valid, validationMessage) = body.IsValid(_logger);
|
||||
if (!valid)
|
||||
{
|
||||
_logger.LogWarning($"Dropping event from {appKey} because: {validationMessage}.");
|
||||
return BadRequest(validationMessage);
|
||||
}
|
||||
|
||||
var app = await _cache.FindByAppKey(appKey, cancellationToken);
|
||||
if (string.IsNullOrEmpty(app.Id))
|
||||
return AppNotFound(appKey);
|
||||
|
||||
if (app.IsLocked)
|
||||
return BadRequest($"Owner account is locked.");
|
||||
|
||||
// We never expect the Web SDK to send the OS name, so it's safe to assume that if it's missing the event is coming from a browser
|
||||
var isWeb = string.IsNullOrEmpty(body.SystemProps.OSName);
|
||||
|
||||
// For web events, we need to parse the user agent to get the OS name and version
|
||||
if (isWeb && !string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
var (osName, osVersion) = UserAgentParser.ParseOperatingSystem(userAgent);
|
||||
body.SystemProps.OSName = osName;
|
||||
body.SystemProps.OSVersion = osVersion;
|
||||
|
||||
var (engineName, engineVersion) = UserAgentParser.ParseBrowser(userAgent);
|
||||
body.SystemProps.EngineName = engineName;
|
||||
body.SystemProps.EngineVersion = engineVersion;
|
||||
}
|
||||
|
||||
// We can't rely on User-Agent header sent by the SDK for non-web events, so we fabricate one
|
||||
// This can be removed when this issue is implemented: https://github.com/aptabase/aptabase/issues/23
|
||||
if (!isWeb)
|
||||
userAgent = $"{body.SystemProps.OSName}/{body.SystemProps.OSVersion} {body.SystemProps.EngineName}/{body.SystemProps.EngineVersion} {body.SystemProps.Locale}";
|
||||
|
||||
var clientIp = HttpContext.ResolveClientIpAddress();
|
||||
var location = _geoIP.GetClientLocation(HttpContext);
|
||||
var trackingEvent = NewTrackingEvent(app.Id, location.CountryCode, location.RegionName, clientIp, userAgent ?? "", body);
|
||||
_buffer.Add(ref trackingEvent);
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v0/events")]
|
||||
[EnableRateLimiting("EventIngestion")]
|
||||
public async Task<IActionResult> Multiple(
|
||||
[FromHeader(Name = "App-Key")] string? appKey,
|
||||
[FromHeader(Name = "User-Agent")] string? userAgent,
|
||||
[FromBody] EventBody[] events,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
appKey = appKey?.ToUpper() ?? "";
|
||||
|
||||
if (events.Length > 25)
|
||||
return BadRequest($"Too many events ({events.Length}) in a single request. Maximum is 25.");
|
||||
|
||||
var validEvents = events.Where(e => {
|
||||
var (valid, validationMessage) = e.IsValid(_logger);
|
||||
if (!valid)
|
||||
_logger.LogWarning("Dropping event from {AppKey}. {ValidationMessage}", appKey, validationMessage);
|
||||
return valid;
|
||||
}).ToArray();
|
||||
|
||||
if (!validEvents.Any())
|
||||
return Ok(new { });
|
||||
|
||||
var app = await _cache.FindByAppKey(appKey, cancellationToken);
|
||||
if (string.IsNullOrEmpty(app.Id))
|
||||
return AppNotFound(appKey);
|
||||
|
||||
if (app.IsLocked)
|
||||
return BadRequest($"Owner account is locked.");
|
||||
|
||||
var clientIp = HttpContext.ResolveClientIpAddress();
|
||||
var location = _geoIP.GetClientLocation(HttpContext);
|
||||
var trackingEvents = validEvents.Select(e => NewTrackingEvent(app.Id, location.CountryCode, location.RegionName, clientIp, userAgent ?? "", e));
|
||||
|
||||
_buffer.AddRange(ref trackingEvents);
|
||||
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
private IActionResult AppNotFound(string appKey)
|
||||
{
|
||||
_logger.LogWarning("Appplication not found with given app key: {AppKey}", appKey);
|
||||
return NotFound($"Appplication not found with given app key: {appKey}");
|
||||
}
|
||||
|
||||
private static TrackingEvent NewTrackingEvent(string appId, string countryCode, string regionName, string clientIp, string userAgent, EventBody body)
|
||||
{
|
||||
var (stringProps, numericProps) = body.SplitProps();
|
||||
return new TrackingEvent
|
||||
{
|
||||
ClientIpAddress = clientIp,
|
||||
UserAgent = userAgent,
|
||||
|
||||
AppId = appId,
|
||||
EventName = body.EventName,
|
||||
Timestamp = body.Timestamp.ToUniversalTime(),
|
||||
SessionId = body.SessionId?.ToString() ?? "",
|
||||
OSName = body.SystemProps.OSName ?? "",
|
||||
OSVersion = body.SystemProps.OSVersion ?? "",
|
||||
DeviceModel = body.SystemProps.DeviceModel ?? "",
|
||||
Locale = body.SystemProps.Locale ?? "",
|
||||
AppVersion = body.SystemProps.AppVersion ?? "",
|
||||
EngineName = body.SystemProps.EngineName ?? "",
|
||||
EngineVersion = body.SystemProps.EngineVersion ?? "",
|
||||
AppBuildNumber = body.SystemProps.AppBuildNumber ?? "",
|
||||
SdkVersion = body.SystemProps.SdkVersion ?? "",
|
||||
CountryCode = countryCode,
|
||||
RegionName = regionName,
|
||||
StringProps = stringProps.ToJsonString(),
|
||||
NumericProps = numericProps.ToJsonString(),
|
||||
IsDebug = body.SystemProps.IsDebug,
|
||||
};
|
||||
}
|
||||
}
|
67
src/Features/Ingestion/IngestionCache.cs
Executable file
67
src/Features/Ingestion/IngestionCache.cs
Executable file
|
@ -0,0 +1,67 @@
|
|||
using Aptabase.Features.Apps;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
public class CachedApplication
|
||||
{
|
||||
public string Id { get; private set; }
|
||||
public bool IsLocked { get; private set; }
|
||||
|
||||
public CachedApplication()
|
||||
{
|
||||
Id = string.Empty;
|
||||
}
|
||||
|
||||
public CachedApplication(Application app)
|
||||
{
|
||||
Id = app.Id;
|
||||
IsLocked = app.LockReason.HasValue;
|
||||
}
|
||||
|
||||
public static CachedApplication Empty => new CachedApplication();
|
||||
}
|
||||
|
||||
public interface IIngestionCache
|
||||
{
|
||||
Task<CachedApplication> FindByAppKey(string appKey, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class IngestionCache : IIngestionCache
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IAppQueries _appQueries;
|
||||
|
||||
private readonly TimeSpan SuccessCacheDuration = TimeSpan.FromMinutes(30);
|
||||
private readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public IngestionCache(IMemoryCache cache, IAppQueries appQueries)
|
||||
{
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_appQueries = appQueries ?? throw new ArgumentNullException(nameof(appQueries));
|
||||
}
|
||||
|
||||
public async Task<CachedApplication> FindByAppKey(string appKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(appKey))
|
||||
return CachedApplication.Empty;
|
||||
|
||||
var cacheKey = $"APP-KEY-STATUS-{appKey}";
|
||||
if (_cache.TryGetValue(cacheKey, out CachedApplication? cachedApp) && cachedApp is not null)
|
||||
return cachedApp;
|
||||
|
||||
var app = await _appQueries.GetActiveAppByAppKey(appKey, cancellationToken);
|
||||
if (app is null)
|
||||
{
|
||||
_cache.Set(cacheKey, CachedApplication.Empty, FailureCacheDuration);
|
||||
return CachedApplication.Empty;
|
||||
}
|
||||
|
||||
if (!app.HasEvents)
|
||||
await _appQueries.MaskAsOnboarded(app.Id, cancellationToken);
|
||||
|
||||
cachedApp = new CachedApplication(app);
|
||||
_cache.Set(cacheKey, cachedApp, SuccessCacheDuration);
|
||||
return cachedApp;
|
||||
}
|
||||
}
|
38
src/Features/Ingestion/LocaleFormatter.cs
Executable file
38
src/Features/Ingestion/LocaleFormatter.cs
Executable file
|
@ -0,0 +1,38 @@
|
|||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
public static class LocaleFormatter
|
||||
{
|
||||
|
||||
// List of special locales and the expected format
|
||||
private static Dictionary<string, string> SpecialCases = new()
|
||||
{
|
||||
{ "en-001", "en" },
|
||||
{ "en-150", "en" },
|
||||
{ "en-us-posix", "en" },
|
||||
};
|
||||
|
||||
// Validates and formats a locale string
|
||||
// Return a lowercase locale string
|
||||
// Return empty string for empty or the special 'C' locale
|
||||
// Returns null for invalid/unsupported locales
|
||||
public static string? FormatLocale(string? locale)
|
||||
{
|
||||
if (string.IsNullOrEmpty(locale) || locale == "C")
|
||||
return "";
|
||||
|
||||
// Some systems may use '_' instead of '-'
|
||||
// Lowercase it to be consistent
|
||||
locale = locale.Replace("_", "-").ToLower();
|
||||
|
||||
if (SpecialCases.ContainsKey(locale))
|
||||
return SpecialCases[locale];
|
||||
|
||||
return locale.Length switch
|
||||
{
|
||||
2 => locale,
|
||||
(>= 5) and (<= 7) => locale[2] == '-' ? locale : null,
|
||||
10 => locale[2] == '-' && locale[7] == '-' ? locale : null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
88
src/Features/Ingestion/UserAgentParser.cs
Executable file
88
src/Features/Ingestion/UserAgentParser.cs
Executable file
|
@ -0,0 +1,88 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Aptabase.Features.Ingestion;
|
||||
|
||||
public static class UserAgentParser
|
||||
{
|
||||
private static Dictionary<string, string> osKeys = new Dictionary<string, string>
|
||||
{
|
||||
{ "ipad", "iPadOS" },
|
||||
{ "iphone", "iOS" },
|
||||
{ "ios", "iOS" },
|
||||
|
||||
{ "windows nt", "Windows" },
|
||||
|
||||
{ "mac os x", "macOS" },
|
||||
{ "macos", "macOS" },
|
||||
{ "macintosh", "macOS" },
|
||||
|
||||
{ "android", "Android" },
|
||||
|
||||
{ "cros", "Chrome OS" },
|
||||
{ "ubuntu", "Ubuntu" },
|
||||
{ "fedora", "Fedora" },
|
||||
{ "arch linux", "Arch Linux" },
|
||||
{ "archlinux", "Arch Linux" },
|
||||
{ "linux", "Linux" }
|
||||
};
|
||||
|
||||
public static (string, string) ParseOperatingSystem(string userAgent)
|
||||
{
|
||||
var lcUserAgent = userAgent.ToLowerInvariant();
|
||||
|
||||
foreach (var (key, value) in osKeys)
|
||||
{
|
||||
if (lcUserAgent.Contains(key))
|
||||
return (value, "");
|
||||
}
|
||||
|
||||
return ("", "");
|
||||
}
|
||||
|
||||
|
||||
private static Dictionary<string, string> browserKeys = new Dictionary<string, string>
|
||||
{
|
||||
{ "Edg", "Edge" },
|
||||
{ "Firefox", "Firefox" },
|
||||
{ "OPiOS", "Opera" },
|
||||
{ "OPR", "Opera" },
|
||||
{ "YaBrowser", "Yandex Browser" }
|
||||
};
|
||||
|
||||
private static Regex browserRegex = new Regex(@"\((?<info>.*?)\)(\s|$)|(?<name>.*?)\/(?<version>.*?)(\s|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
public static (string, string) ParseBrowser(string userAgent)
|
||||
{
|
||||
var lcUserAgent = userAgent.ToLowerInvariant();
|
||||
var matches = browserRegex.Matches(userAgent);
|
||||
|
||||
var chromeVersion = "";
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
var version = match.Groups["version"].Value;
|
||||
|
||||
foreach (var (key, prettyName) in browserKeys)
|
||||
{
|
||||
if (name == key)
|
||||
return (prettyName, version);
|
||||
}
|
||||
|
||||
if (name == "Chrome")
|
||||
chromeVersion = version;
|
||||
|
||||
if (name == "Safari")
|
||||
{
|
||||
// It's not Safari unless it has a "Version" group
|
||||
var safariVersion = matches.FirstOrDefault(x => x.Groups["name"].Value == "Version")?.Groups["version"].Value ?? "";
|
||||
if (!string.IsNullOrEmpty(safariVersion))
|
||||
return ("Safari", safariVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// if we didn't find a browser, but we found a chrome version, we assume it's chrome
|
||||
if (!string.IsNullOrEmpty(chromeVersion))
|
||||
return ("Chrome", chromeVersion);
|
||||
|
||||
return ("", "");
|
||||
}
|
||||
}
|
6
src/Features/Notification/IEmailClient.cs
Executable file
6
src/Features/Notification/IEmailClient.cs
Executable file
|
@ -0,0 +1,6 @@
|
|||
namespace Aptabase.Features.Notification;
|
||||
|
||||
public interface IEmailClient
|
||||
{
|
||||
Task SendEmailAsync(string to, string subject, string templateName, Dictionary<string, string>? properties, CancellationToken cancellationToken);
|
||||
}
|
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