Compare commits
No commits in common. "django" and "next" have entirely different histories.
255 changed files with 26062 additions and 933 deletions
47
.env.example
47
.env.example
|
@ -1,5 +1,44 @@
|
||||||
DATABASE_NAME=postgres
|
# Key used to sign tokens. Generate this with `go run . generate key`
|
||||||
DATABASE_USER=postgres
|
HMAC_KEY=
|
||||||
DATABASE_PASSWORD=postgres
|
|
||||||
DATABASE_HOST=localhost
|
|
||||||
|
|
||||||
|
# PostgreSQL connection URL (postgresql://user:pass@host:port/dbname)
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Redis connection URL (redis://user:pass@host:port)
|
||||||
|
REDIS=
|
||||||
|
|
||||||
|
# Port for the backend to listen on; frontend assumes this will be 8080 for dev
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Frontend base URL, used to construct URLs that point back to the frontend
|
||||||
|
BASE_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# S3/MinIO configuration, required for avatars, pride flags, and data exports
|
||||||
|
# Note: MINIO_ENDPOINT must be set and look like a minio endpoint, but doesn't
|
||||||
|
# have to actually point to anything real
|
||||||
|
MINIO_ENDPOINT=example.com
|
||||||
|
MINIO_BUCKET=
|
||||||
|
MINIO_ACCESS_KEY_ID=
|
||||||
|
MINIO_ACCESS_KEY_SECRET=
|
||||||
|
MINIO_SSL=
|
||||||
|
|
||||||
|
# IP address of the frontend; requests from here will never be ratelimited
|
||||||
|
FRONTEND_IP=
|
||||||
|
|
||||||
|
# Auth providers - fill in OAuth app info to enable OAuth login for each
|
||||||
|
|
||||||
|
# https://discord.com/developers/applications
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# https://developers.google.com/identity/protocols/oauth2#basicsteps
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# https://www.tumblr.com/oauth/apps
|
||||||
|
TUMBLR_CLIENT_ID=
|
||||||
|
TUMBLR_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Discord bot config - provide the app's public key in addition to client ID/
|
||||||
|
# secret above to let the bot respond to command interactions over HTTP
|
||||||
|
DISCORD_PUBLIC_KEY=
|
||||||
|
|
20
.gitignore
vendored
20
.gitignore
vendored
|
@ -1,9 +1,13 @@
|
||||||
__pycache__/
|
.vscode
|
||||||
*.py[cod]
|
node_modules
|
||||||
*$py.class
|
*.log*
|
||||||
local_settings.py
|
|
||||||
__pypackages__/
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
.env
|
.env
|
||||||
venv/
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
dist
|
||||||
|
dump.rdb
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
package
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
661
LICENSE
Normal file
661
LICENSE
Normal file
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
17
Makefile
Normal file
17
Makefile
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
all: generate backend frontend
|
||||||
|
|
||||||
|
.PHONY: backend
|
||||||
|
backend:
|
||||||
|
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" .
|
||||||
|
|
||||||
|
.PHONY: generate
|
||||||
|
generate:
|
||||||
|
go generate ./...
|
||||||
|
|
||||||
|
.PHONY: frontend
|
||||||
|
frontend:
|
||||||
|
cd frontend && pnpm install && pnpm build
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
cd frontend && pnpm dev
|
56
README.md
56
README.md
|
@ -1 +1,57 @@
|
||||||
# pronouns.cc
|
# pronouns.cc
|
||||||
|
|
||||||
|
A work-in-progress site to share your names, pronouns, and other preferred terms.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- API server is written in Go with the [chi](https://github.com/go-chi/chi) router
|
||||||
|
- Persistent data is stored in PostgreSQL
|
||||||
|
- Temporary data is stored in Redis
|
||||||
|
- The frontend is written in TypeScript with Svelte, using [SvelteKit](https://kit.svelte.dev/) for server-side rendering
|
||||||
|
- Avatars are stored in S3-compatible storage ([MinIO](https://github.com/minio/minio) for development)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
When working on the frontend, run the API and then use `pnpm dev` in `frontend/` for hot reloading.
|
||||||
|
|
||||||
|
Note that the Vite dev server assumes that the backend listens on `:8080` and MinIO listens on `:9000`.
|
||||||
|
If these ports differ on your development environment, you must edit `vite.config.ts`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Go 1.18 or later
|
||||||
|
- PostgreSQL (any currently supported version should work)
|
||||||
|
- Redis 6.0 or later
|
||||||
|
- Node.js (latest version)
|
||||||
|
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Create a PostgreSQL user and database (the user should own the database).
|
||||||
|
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
|
||||||
|
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
|
||||||
|
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
||||||
|
4. Run `go run -v . web` to run the backend.
|
||||||
|
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
|
||||||
|
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
|
||||||
|
|
||||||
|
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (C) 2022 Sam <u1f320>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
|
@ -1,8 +0,0 @@
|
||||||
from ninja import NinjaAPI
|
|
||||||
|
|
||||||
from .views.users import router as users_router
|
|
||||||
|
|
||||||
api = NinjaAPI()
|
|
||||||
|
|
||||||
api.add_router("/users/", users_router)
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ApiV2Config(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "api_v2"
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from .api import api
|
|
||||||
|
|
||||||
urlpatterns = [path("/", api.urls)]
|
|
|
@ -1,3 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
|
@ -1,38 +0,0 @@
|
||||||
from ninja import Router, Field, Schema
|
|
||||||
from ninja.errors import HttpError
|
|
||||||
|
|
||||||
from pronounscc.models import User
|
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
class UserOut(Schema):
|
|
||||||
id: str = Field(..., alias="uid")
|
|
||||||
username: str
|
|
||||||
display_name: str = Field(..., alias="userprofile.display_name")
|
|
||||||
bio: str = Field(..., alias="userprofile.bio")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/@me")
|
|
||||||
def get_me(request):
|
|
||||||
return {"data": "me endpoint, eventually"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_ref}", response=UserOut)
|
|
||||||
def get_user(request, user_ref: str):
|
|
||||||
try:
|
|
||||||
user_id = int(user_ref)
|
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=user_id)
|
|
||||||
return user
|
|
||||||
except User.DoesNotExist:
|
|
||||||
pass
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = User.objects.get(username=user_ref)
|
|
||||||
return user
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise HttpError(404, "User not found")
|
|
11
backend/common/common.go
Normal file
11
backend/common/common.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// Package common contains functions and types common to all (or most) packages.
|
||||||
|
package common
|
||||||
|
|
||||||
|
import "unicode/utf8"
|
||||||
|
|
||||||
|
func StringLength(s *string) int {
|
||||||
|
if s == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return utf8.RuneCountInString(*s)
|
||||||
|
}
|
176
backend/db/avatars.go
Normal file
176
backend/db/avatars.go
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
|
||||||
|
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
|
||||||
|
const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size")
|
||||||
|
|
||||||
|
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
|
||||||
|
func (db *DB) ConvertAvatar(data string) (
|
||||||
|
webpOut *bytes.Buffer,
|
||||||
|
jpgOut *bytes.Buffer,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
defer vips.ShutdownThread()
|
||||||
|
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
|
||||||
|
return nil, nil, ErrInvalidDataURI
|
||||||
|
}
|
||||||
|
split := strings.Split(data, ",")
|
||||||
|
|
||||||
|
rawData, err := base64.StdEncoding.DecodeString(split[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "invalid base64 data")
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "decoding image")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "resizing image")
|
||||||
|
}
|
||||||
|
|
||||||
|
webpExport := vips.NewWebpExportParams()
|
||||||
|
webpExport.Quality = 90
|
||||||
|
webpB, _, err := image.ExportWebp(webpExport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "exporting webp image")
|
||||||
|
}
|
||||||
|
webpOut = bytes.NewBuffer(webpB)
|
||||||
|
|
||||||
|
jpegExport := vips.NewJpegExportParams()
|
||||||
|
jpegExport.Quality = 80
|
||||||
|
jpegB, _, err := image.ExportJpeg(jpegExport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "exporting jpeg image")
|
||||||
|
}
|
||||||
|
jpgOut = bytes.NewBuffer(jpegB)
|
||||||
|
|
||||||
|
return webpOut, jpgOut, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) WriteUserAvatar(ctx context.Context,
|
||||||
|
userID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer,
|
||||||
|
) (
|
||||||
|
hash string, err error,
|
||||||
|
) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, err = hasher.Write(webp.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "hashing webp avatar")
|
||||||
|
}
|
||||||
|
hash = hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
|
||||||
|
ContentType: "image/webp",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "uploading webp avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "uploading jpeg avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) WriteMemberAvatar(ctx context.Context,
|
||||||
|
memberID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer,
|
||||||
|
) (
|
||||||
|
hash string, err error,
|
||||||
|
) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, err = hasher.Write(webp.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "hashing webp avatar")
|
||||||
|
}
|
||||||
|
hash = hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
|
||||||
|
ContentType: "image/webp",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "uploading webp avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
|
||||||
|
ContentType: "image/jpeg",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "uploading jpeg avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error {
|
||||||
|
err := db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting webp avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting jpeg avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash string) error {
|
||||||
|
err := db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting webp avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting jpeg avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.ReadCloser, error) {
|
||||||
|
obj, err := db.minio.GetObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "getting object")
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) MemberAvatar(ctx context.Context, memberID xid.ID, hash string) (io.ReadCloser, error) {
|
||||||
|
obj, err := db.minio.GetObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "getting object")
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
157
backend/db/db.go
Normal file
157
backend/db/db.go
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
|
||||||
|
|
||||||
|
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
|
||||||
|
|
||||||
|
const (
|
||||||
|
uniqueViolation = "23505"
|
||||||
|
foreignKeyViolation = "23503"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Execer interface {
|
||||||
|
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*pgxpool.Pool
|
||||||
|
|
||||||
|
Redis radix.Client
|
||||||
|
|
||||||
|
minio *minio.Client
|
||||||
|
minioBucket string
|
||||||
|
baseURL *url.URL
|
||||||
|
|
||||||
|
TotalRequests prometheus.Counter
|
||||||
|
|
||||||
|
activeUsersDay, activeUsersWeek, activeUsersMonth int64
|
||||||
|
usersTotal, membersTotal int64
|
||||||
|
countMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*DB, error) {
|
||||||
|
log.Debug("creating postgres client")
|
||||||
|
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "creating postgres client")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("creating redis client")
|
||||||
|
redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "creating redis client")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("creating minio client")
|
||||||
|
minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""),
|
||||||
|
Secure: os.Getenv("MINIO_SSL") == "true",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "creating minio client")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(os.Getenv("BASE_URL"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "parsing base URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := &DB{
|
||||||
|
Pool: pool,
|
||||||
|
Redis: redis,
|
||||||
|
|
||||||
|
minio: minioClient,
|
||||||
|
minioBucket: os.Getenv("MINIO_BUCKET"),
|
||||||
|
baseURL: baseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("initializing metrics")
|
||||||
|
err = db.initMetrics()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "initializing metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiCmd executes the given Redis commands in order.
|
||||||
|
// If any return an error, the function is aborted.
|
||||||
|
func (db *DB) MultiCmd(ctx context.Context, cmds ...radix.Action) error {
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
err := db.Redis.Do(ctx, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJSON sets the given key to v marshaled as JSON.
|
||||||
|
func (db *DB) SetJSON(ctx context.Context, key string, v any, args ...string) error {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "marshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdArgs := make([]string, 0, len(args)+2)
|
||||||
|
cmdArgs = append(cmdArgs, key, string(b))
|
||||||
|
cmdArgs = append(cmdArgs, args...)
|
||||||
|
|
||||||
|
err = db.Redis.Do(ctx, radix.Cmd(nil, "SET", cmdArgs...))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "writing to Redis")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJSON gets the given key as a JSON object.
|
||||||
|
func (db *DB) GetJSON(ctx context.Context, key string, v any) error {
|
||||||
|
var b []byte
|
||||||
|
|
||||||
|
err := db.Redis.Do(ctx, radix.Cmd(&b, "GET", key))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "reading from Redis")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if v == nil {
|
||||||
|
return fmt.Errorf("nil pointer passed into GetJSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(b, v)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling json")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotNull is a little helper that returns an *empty slice* when the slice's length is 0.
|
||||||
|
// This is to prevent nil slices from being marshaled as JSON null
|
||||||
|
func NotNull[T any](slice []T) []T {
|
||||||
|
if len(slice) == 0 {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
return slice
|
||||||
|
}
|
107
backend/db/entries.go
Normal file
107
backend/db/entries.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WordStatus string
|
||||||
|
|
||||||
|
func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
||||||
|
if string(src) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := strings.Trim(string(src), `"`)
|
||||||
|
switch s {
|
||||||
|
case "1":
|
||||||
|
*w = "favourite"
|
||||||
|
case "2":
|
||||||
|
*w = "okay"
|
||||||
|
case "3":
|
||||||
|
*w = "jokingly"
|
||||||
|
case "4":
|
||||||
|
*w = "friends_only"
|
||||||
|
case "5":
|
||||||
|
*w = "avoid"
|
||||||
|
default:
|
||||||
|
*w = WordStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w WordStatus) Valid(extra CustomPreferences) bool {
|
||||||
|
if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := range extra {
|
||||||
|
if string(w) == k {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldEntry struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Status WordStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fe FieldEntry) Validate(custom CustomPreferences) string {
|
||||||
|
if fe.Value == "" {
|
||||||
|
return "value cannot be empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len([]rune(fe.Value)) > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fe.Status.Valid(custom) {
|
||||||
|
return "status is invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type PronounEntry struct {
|
||||||
|
Pronouns string `json:"pronouns"`
|
||||||
|
DisplayText *string `json:"display_text"`
|
||||||
|
Status WordStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PronounEntry) Validate(custom CustomPreferences) string {
|
||||||
|
if p.Pronouns == "" {
|
||||||
|
return "pronouns cannot be empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.DisplayText != nil {
|
||||||
|
if len([]rune(*p.DisplayText)) > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("display_text must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(*p.DisplayText)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len([]rune(p.Pronouns)) > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.Status.Valid(custom) {
|
||||||
|
return "status is invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PronounEntry) String() string {
|
||||||
|
if p.DisplayText != nil {
|
||||||
|
return *p.DisplayText
|
||||||
|
}
|
||||||
|
|
||||||
|
split := strings.Split(p.Pronouns, "/")
|
||||||
|
if len(split) <= 2 {
|
||||||
|
return strings.Join(split, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(split[:1], "/")
|
||||||
|
}
|
105
backend/db/export.go
Normal file
105
backend/db/export.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataExport struct {
|
||||||
|
ID int64
|
||||||
|
UserID xid.ID
|
||||||
|
Filename string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (de DataExport) Path() string {
|
||||||
|
return "exports/" + de.UserID.String() + "/" + de.Filename + ".zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrNoExport = errors.Sentinel("no data export exists")
|
||||||
|
|
||||||
|
const KeepExportTime = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
func (db *DB) UserExport(ctx context.Context, userID xid.ID) (de DataExport, err error) {
|
||||||
|
sql, args, err := sq.Select("*").
|
||||||
|
From("data_exports").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
OrderBy("id DESC").
|
||||||
|
Limit(1).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return de, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &de, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return de, ErrNoExport
|
||||||
|
}
|
||||||
|
|
||||||
|
return de, errors.Wrap(err, "executing sql")
|
||||||
|
}
|
||||||
|
return de, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentExport = 24 * time.Hour
|
||||||
|
|
||||||
|
func (db *DB) HasRecentExport(ctx context.Context, userID xid.ID) (hasExport bool, err error) {
|
||||||
|
err = db.QueryRow(ctx,
|
||||||
|
"SELECT EXISTS(SELECT * FROM data_exports WHERE user_id = $1 AND created_at > $2)",
|
||||||
|
userID, time.Now().Add(-recentExport)).Scan(&hasExport)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return hasExport, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string, file *bytes.Buffer) (de DataExport, err error) {
|
||||||
|
de = DataExport{
|
||||||
|
UserID: userID,
|
||||||
|
Filename: filename,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{
|
||||||
|
ContentType: "application/zip",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return de, errors.Wrap(err, "writing export file")
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := sq.Insert("data_exports").Columns("user_id", "filename").Values(userID, filename).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return de, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
pgxscan.Get(ctx, db, &de, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return de, errors.Wrap(err, "executing sql")
|
||||||
|
}
|
||||||
|
return de, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteExport(ctx context.Context, de DataExport) (err error) {
|
||||||
|
sql, args, err := sq.Delete("data_exports").Where("id = ?", de.ID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.minio.RemoveObject(ctx, db.minioBucket, de.Path(), minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting export zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing sql")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
103
backend/db/fediverse.go
Normal file
103
backend/db/fediverse.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FediverseApp struct {
|
||||||
|
ID int64
|
||||||
|
// Instance is the instance's base API url, excluding schema
|
||||||
|
Instance string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
InstanceType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FediverseApp) ClientConfig() *oauth2.Config {
|
||||||
|
if f.MastodonCompatible() {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: f.ClientID,
|
||||||
|
ClientSecret: f.ClientSecret,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://" + f.Instance + "/oauth/authorize",
|
||||||
|
TokenURL: "https://" + f.Instance + "/oauth/token",
|
||||||
|
AuthStyle: oauth2.AuthStyleInParams,
|
||||||
|
},
|
||||||
|
Scopes: []string{"read:accounts"},
|
||||||
|
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: f.ClientID,
|
||||||
|
ClientSecret: f.ClientSecret,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://" + f.Instance + "/auth",
|
||||||
|
TokenURL: "https://" + f.Instance + "/api/auth/session/oauth",
|
||||||
|
AuthStyle: oauth2.AuthStyleInHeader,
|
||||||
|
},
|
||||||
|
Scopes: []string{"read:account"},
|
||||||
|
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/misskey/" + f.Instance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FediverseApp) MastodonCompatible() bool {
|
||||||
|
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FediverseApp) Misskey() bool {
|
||||||
|
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey"
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")
|
||||||
|
|
||||||
|
func (db *DB) FediverseApp(ctx context.Context, instance string) (fa FediverseApp, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("fediverse_apps").Where("instance = ?", instance).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return fa, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &fa, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return fa, ErrNoInstanceApp
|
||||||
|
}
|
||||||
|
return fa, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return fa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) FediverseAppByID(ctx context.Context, id int64) (fa FediverseApp, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("fediverse_apps").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return fa, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &fa, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fa, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return fa, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateFediverseApp(ctx context.Context, instance, instanceType, clientID, clientSecret string) (fa FediverseApp, err error) {
|
||||||
|
sql, args, err := sq.Insert("fediverse_apps").
|
||||||
|
Columns("instance", "instance_type", "client_id", "client_secret").
|
||||||
|
Values(instance, instanceType, clientID, clientSecret).
|
||||||
|
Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return fa, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &fa, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fa, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return fa, nil
|
||||||
|
}
|
123
backend/db/field.go
Normal file
123
backend/db/field.go
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxFields = 25
|
||||||
|
FieldNameMaxLength = 100
|
||||||
|
FieldEntriesLimit = 100
|
||||||
|
FieldEntryMaxLength = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type Field struct {
|
||||||
|
ID int64 `json:"-"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Entries []FieldEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
|
||||||
|
func (f Field) Validate(custom CustomPreferences) string {
|
||||||
|
if f.Name == "" {
|
||||||
|
return "name cannot be empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
if length := len([]rune(f.Name)); length > FieldNameMaxLength {
|
||||||
|
return fmt.Sprintf("name max length is %d characters, length is %d", FieldNameMaxLength, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if length := len(f.Entries); length > FieldEntriesLimit {
|
||||||
|
return fmt.Sprintf("max number of entries is %d, current number is %d", FieldEntriesLimit, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, entry := range f.Entries {
|
||||||
|
if length := len([]rune(entry.Value)); length > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !entry.Status.Valid(custom) {
|
||||||
|
return fmt.Sprintf("entries.%d: status is invalid", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFields returns the fields associated with the given user ID.
|
||||||
|
func (db *DB) UserFields(ctx context.Context, id xid.ID) (fs []Field, err error) {
|
||||||
|
sql, args, err := sq.Select("id", "name", "entries").From("user_fields").Where("user_id = ?", id).OrderBy("id").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return fs, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fs, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserFields updates the fields for the given user.
|
||||||
|
func (db *DB) SetUserFields(ctx context.Context, tx pgx.Tx, userID xid.ID, fields []Field) (err error) {
|
||||||
|
sql, args, err := sq.Delete("user_fields").Where("user_id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting existing fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
_, err := tx.Exec(ctx, "INSERT INTO user_fields (user_id, name, entries) VALUES ($1, $2, $3)", userID, field.Name, field.Entries)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "inserting new fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberFields returns the fields associated with the given member ID.
|
||||||
|
func (db *DB) MemberFields(ctx context.Context, id xid.ID) (fs []Field, err error) {
|
||||||
|
sql, args, err := sq.Select("id", "name", "entries").From("member_fields").Where("member_id = ?", id).OrderBy("id").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return fs, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fs, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberFields updates the fields for the given member.
|
||||||
|
func (db *DB) SetMemberFields(ctx context.Context, tx pgx.Tx, memberID xid.ID, fields []Field) (err error) {
|
||||||
|
sql, args, err := sq.Delete("member_fields").Where("member_id = ?", memberID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting existing fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
_, err := tx.Exec(ctx, "INSERT INTO member_fields (member_id, name, entries) VALUES ($1, $2, $3)", memberID, field.Name, field.Entries)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "inserting new fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
323
backend/db/flags.go
Normal file
323
backend/db/flags.go
Normal file
|
@ -0,0 +1,323 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrideFlag struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
UserID xid.ID `json:"-"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserFlag struct {
|
||||||
|
ID int64 `json:"-"`
|
||||||
|
UserID xid.ID `json:"-"`
|
||||||
|
FlagID xid.ID `json:"id"`
|
||||||
|
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberFlag struct {
|
||||||
|
ID int64 `json:"-"`
|
||||||
|
MemberID xid.ID `json:"-"`
|
||||||
|
FlagID xid.ID `json:"id"`
|
||||||
|
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxPrideFlags = 500
|
||||||
|
MaxPrideFlagTitleLength = 100
|
||||||
|
MaxPrideFlagDescLength = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrInvalidFlagID = errors.Sentinel("invalid flag ID")
|
||||||
|
ErrFlagNotFound = errors.Sentinel("flag not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)", "id").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return NotNull(fs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return f, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &f, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return f, ErrFlagNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) {
|
||||||
|
sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description").
|
||||||
|
From("user_flags AS u").
|
||||||
|
Where("u.user_id = $1", userID).
|
||||||
|
Join("pride_flags AS f ON u.flag_id = f.id").
|
||||||
|
OrderBy("u.id ASC").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return NotNull(fs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) {
|
||||||
|
sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description").
|
||||||
|
From("member_flags AS m").
|
||||||
|
Where("m.member_id = $1", memberID).
|
||||||
|
Join("pride_flags AS f ON m.flag_id = f.id").
|
||||||
|
OrderBy("m.id ASC").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &fs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return NotNull(fs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) {
|
||||||
|
sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting existing flags")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"},
|
||||||
|
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
|
||||||
|
return []any{userID, flags[i]}, nil
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
if pge.Code == foreignKeyViolation {
|
||||||
|
return ErrInvalidFlagID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "copying new flags")
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Debugf("set %v flags for user %v", n, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) {
|
||||||
|
sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting existing flags")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"},
|
||||||
|
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
|
||||||
|
return []any{memberID, flags[i]}, nil
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
if pge.Code == foreignKeyViolation {
|
||||||
|
return ErrInvalidFlagID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "copying new flags")
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Debugf("set %v flags for member %v", n, memberID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) {
|
||||||
|
description := &desc
|
||||||
|
if desc == "" {
|
||||||
|
description = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := sq.Insert("pride_flags").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"id": xid.New(),
|
||||||
|
"hash": "",
|
||||||
|
"user_id": userID.String(),
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
}).Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return f, errors.Wrap(err, "building query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &f, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return f, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) {
|
||||||
|
b := sq.Update("pride_flags").
|
||||||
|
Where("id = ?", flagID)
|
||||||
|
if name != nil {
|
||||||
|
b = b.Set("name", *name)
|
||||||
|
}
|
||||||
|
if desc != nil {
|
||||||
|
if *desc == "" {
|
||||||
|
b = b.Set("description", nil)
|
||||||
|
} else {
|
||||||
|
b = b.Set("description", *desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hash != nil {
|
||||||
|
b = b.Set("hash", *hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := b.Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return f, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &f, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return f, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, err = hasher.Write(flag.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "hashing flag")
|
||||||
|
}
|
||||||
|
hash = hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
_, err = db.minio.PutObject(ctx, db.minioBucket, "flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{
|
||||||
|
ContentType: "image/webp",
|
||||||
|
SendContentMd5: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "uploading flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error {
|
||||||
|
sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) {
|
||||||
|
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "getting object")
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaxFlagInputSize = 512_000
|
||||||
|
|
||||||
|
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
|
||||||
|
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
|
||||||
|
defer vips.ShutdownThread()
|
||||||
|
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
|
||||||
|
return nil, ErrInvalidDataURI
|
||||||
|
}
|
||||||
|
split := strings.Split(data, ",")
|
||||||
|
|
||||||
|
rawData, err := base64.StdEncoding.DecodeString(split[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "invalid base64 data")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawData) > MaxFlagInputSize {
|
||||||
|
return nil, ErrFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "decoding image")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "resizing image")
|
||||||
|
}
|
||||||
|
|
||||||
|
webpExport := vips.NewWebpExportParams()
|
||||||
|
webpExport.Lossless = true
|
||||||
|
webpB, _, err := image.ExportWebp(webpExport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "exporting webp image")
|
||||||
|
}
|
||||||
|
webpOut = bytes.NewBuffer(webpB)
|
||||||
|
|
||||||
|
return webpOut, nil
|
||||||
|
}
|
111
backend/db/invites.go
Normal file
111
backend/db/invites.go
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
UserID xid.ID
|
||||||
|
Code string
|
||||||
|
Created time.Time
|
||||||
|
Used bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UserInvites(ctx context.Context, userID xid.ID) (is []Invite, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("invites").Where("user_id = ?", userID).OrderBy("created").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &is, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "querying database")
|
||||||
|
}
|
||||||
|
if len(is) == 0 {
|
||||||
|
is = []Invite{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return is, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrTooManyInvites = errors.Sentinel("user invite limit reached")
|
||||||
|
|
||||||
|
func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err error) {
|
||||||
|
tx, err := db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var maxInvites, inviteCount int
|
||||||
|
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "querying invite limit")
|
||||||
|
}
|
||||||
|
err = tx.QueryRow(ctx, "SELECT count(*) FROM invites WHERE user_id = $1", userID).Scan(&inviteCount)
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "querying current invite count")
|
||||||
|
}
|
||||||
|
|
||||||
|
if inviteCount >= maxInvites {
|
||||||
|
return i, ErrTooManyInvites
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 32)
|
||||||
|
|
||||||
|
_, err = rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
|
||||||
|
sql, args, err := sq.Insert("invites").Columns("user_id", "code").Values(userID, code).Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "building insert invite sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &i, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "inserting invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return i, errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) InvalidateInvite(ctx context.Context, tx pgx.Tx, code string) (valid, alreadyUsed bool, err error) {
|
||||||
|
err = tx.QueryRow(ctx, "SELECT used FROM invites WHERE code = $1", code).Scan(&alreadyUsed)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, false, errors.Wrap(err, "checking if invite exists and is used")
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid: true, already used: true
|
||||||
|
if alreadyUsed {
|
||||||
|
return true, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invite is valid, not already used
|
||||||
|
_, err = tx.Exec(ctx, "UPDATE invites SET used = true WHERE code = $1", code)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, errors.Wrap(err, "updating invite usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid: true, already used: false
|
||||||
|
return true, false, nil
|
||||||
|
}
|
296
backend/db/member.go
Normal file
296
backend/db/member.go
Normal file
|
@ -0,0 +1,296 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxMemberCount = 500
|
||||||
|
MaxMemberNameLength = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
type Member struct {
|
||||||
|
ID xid.ID
|
||||||
|
UserID xid.ID
|
||||||
|
SID string `db:"sid"`
|
||||||
|
Name string
|
||||||
|
DisplayName *string
|
||||||
|
Bio *string
|
||||||
|
Avatar *string
|
||||||
|
Links []string
|
||||||
|
Names []FieldEntry
|
||||||
|
Pronouns []PronounEntry
|
||||||
|
Unlisted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrMemberNotFound = errors.Sentinel("member not found")
|
||||||
|
ErrMemberNameInUse = errors.Sentinel("member name already in use")
|
||||||
|
)
|
||||||
|
|
||||||
|
// member names must match this regex
|
||||||
|
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$")
|
||||||
|
|
||||||
|
func MemberNameValid(name string) bool {
|
||||||
|
// These two names will break routing, but periods should still be allowed in names otherwise.
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberNameRegex.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &m, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserMember returns a member scoped by user.
|
||||||
|
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &m, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberBySID gets a user by their short ID.
|
||||||
|
func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrMemberNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "getting members from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserMembers returns all of a user's members, sorted by name.
|
||||||
|
func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) {
|
||||||
|
builder := sq.Select("*").
|
||||||
|
From("members").Where("user_id = ?", userID).
|
||||||
|
OrderBy("name", "id")
|
||||||
|
if !showHidden {
|
||||||
|
builder = builder.Where("unlisted = ?", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &ms, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "retrieving members")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ms == nil {
|
||||||
|
ms = make([]Member, 0)
|
||||||
|
}
|
||||||
|
return ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMember creates a member.
|
||||||
|
func (db *DB) CreateMember(
|
||||||
|
ctx context.Context, tx pgx.Tx, userID xid.ID,
|
||||||
|
name string, displayName *string, bio string, links []string,
|
||||||
|
) (m Member, err error) {
|
||||||
|
sql, args, err := sq.Insert("members").
|
||||||
|
Columns("user_id", "id", "sid", "name", "display_name", "bio", "links").
|
||||||
|
Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
|
||||||
|
Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &m, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
// unique constraint violation
|
||||||
|
if pge.Code == uniqueViolation {
|
||||||
|
return m, ErrMemberNameInUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMember deletes a member by the given ID. This is irreversible.
|
||||||
|
func (db *DB) DeleteMember(ctx context.Context, id xid.ID) (err error) {
|
||||||
|
sql, args, err := sq.Delete("members").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting member")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberCount returns the number of members that the given user has.
|
||||||
|
func (db *DB) MemberCount(ctx context.Context, userID xid.ID) (n int64, err error) {
|
||||||
|
sql, args, err := sq.Select("count(id)").From("members").Where("user_id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.QueryRow(ctx, sql, args...).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateMember(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx, id xid.ID,
|
||||||
|
name, displayName, bio *string,
|
||||||
|
unlisted *bool,
|
||||||
|
links *[]string,
|
||||||
|
avatar *string,
|
||||||
|
) (m Member, err error) {
|
||||||
|
if name == nil && displayName == nil && bio == nil && links == nil && avatar == nil {
|
||||||
|
// get member
|
||||||
|
sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &m, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
builder := sq.Update("members").Where("id = ?", id).Suffix("RETURNING *")
|
||||||
|
if name != nil {
|
||||||
|
if *name == "" {
|
||||||
|
return m, errors.Wrap(err, "name was empty")
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("name", *name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if displayName != nil {
|
||||||
|
if *displayName == "" {
|
||||||
|
builder = builder.Set("display_name", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("display_name", *displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bio != nil {
|
||||||
|
if *bio == "" {
|
||||||
|
builder = builder.Set("bio", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("bio", *bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if links != nil {
|
||||||
|
builder = builder.Set("links", *links)
|
||||||
|
}
|
||||||
|
if unlisted != nil {
|
||||||
|
builder = builder.Set("unlisted", *unlisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if avatar != nil {
|
||||||
|
if *avatar == "" {
|
||||||
|
builder = builder.Set("avatar", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("avatar", avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &m, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
if pge.Code == uniqueViolation {
|
||||||
|
return m, ErrMemberNameInUse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, errors.Wrap(err, "executing sql")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) {
|
||||||
|
tx, err := db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
sql, args, err := sq.Update("members").
|
||||||
|
Set("sid", squirrel.Expr("find_free_member_sid()")).
|
||||||
|
Where("id = ?", memberID).
|
||||||
|
Suffix("RETURNING sid").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.QueryRow(ctx, sql, args...).Scan(&newID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err = sq.Update("users").
|
||||||
|
Set("last_sid_reroll", time.Now()).
|
||||||
|
Where("id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
return newID, nil
|
||||||
|
}
|
198
backend/db/metrics.go
Normal file
198
backend/db/metrics.go
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) initMetrics() (err error) {
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_users_total",
|
||||||
|
Help: "The total number of registered users",
|
||||||
|
}, func() float64 {
|
||||||
|
count, err := db.TotalUserCount(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user count for metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.countMu.Lock()
|
||||||
|
db.usersTotal = count
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
return float64(count)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering user count gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_members_total",
|
||||||
|
Help: "The total number of registered members",
|
||||||
|
}, func() float64 {
|
||||||
|
count, err := db.TotalMemberCount(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting member count for metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.countMu.Lock()
|
||||||
|
db.membersTotal = count
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
return float64(count)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering member count gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_users_active",
|
||||||
|
Help: "The number of users active in the past 30 days",
|
||||||
|
}, func() float64 {
|
||||||
|
count, err := db.ActiveUsers(context.Background(), ActiveMonth)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting active user count for metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.countMu.Lock()
|
||||||
|
db.activeUsersMonth = count
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
return float64(count)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering active user count gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_users_active_week",
|
||||||
|
Help: "The number of users active in the past 7 days",
|
||||||
|
}, func() float64 {
|
||||||
|
count, err := db.ActiveUsers(context.Background(), ActiveWeek)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting active user count for metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.countMu.Lock()
|
||||||
|
db.activeUsersWeek = count
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
return float64(count)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering active user count gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_users_active_day",
|
||||||
|
Help: "The number of users active in the past 1 day",
|
||||||
|
}, func() float64 {
|
||||||
|
count, err := db.ActiveUsers(context.Background(), ActiveDay)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting active user count for metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.countMu.Lock()
|
||||||
|
db.activeUsersDay = count
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
return float64(count)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering active user count gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "pronouns_database_latency",
|
||||||
|
Help: "The latency to the database in nanoseconds",
|
||||||
|
}, func() float64 {
|
||||||
|
start := time.Now()
|
||||||
|
_, err = db.Exec(context.Background(), "SELECT 1")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("pinging database: %v", err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return float64(time.Since(start))
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "registering database latency gauge")
|
||||||
|
}
|
||||||
|
|
||||||
|
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pronouns_api_requests_total",
|
||||||
|
Help: "The total number of API requests since the last restart",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Counts(ctx context.Context) (numUsers, numMembers, usersDay, usersWeek, usersMonth int64) {
|
||||||
|
db.countMu.Lock()
|
||||||
|
if db.usersTotal != 0 {
|
||||||
|
defer db.countMu.Unlock()
|
||||||
|
return db.usersTotal, db.membersTotal, db.activeUsersDay, db.activeUsersWeek, db.activeUsersMonth
|
||||||
|
}
|
||||||
|
db.countMu.Unlock()
|
||||||
|
|
||||||
|
numUsers, _ = db.TotalUserCount(ctx)
|
||||||
|
numMembers, _ = db.TotalMemberCount(ctx)
|
||||||
|
usersDay, _ = db.ActiveUsers(ctx, ActiveDay)
|
||||||
|
usersWeek, _ = db.ActiveUsers(ctx, ActiveWeek)
|
||||||
|
usersMonth, _ = db.ActiveUsers(ctx, ActiveMonth)
|
||||||
|
return numUsers, numMembers, usersDay, usersWeek, usersMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) TotalUserCount(ctx context.Context) (numUsers int64, err error) {
|
||||||
|
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "querying user count")
|
||||||
|
}
|
||||||
|
return numUsers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error) {
|
||||||
|
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "querying member count")
|
||||||
|
}
|
||||||
|
return numMembers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActiveMonth = 30 * 24 * time.Hour
|
||||||
|
ActiveWeek = 7 * 24 * time.Hour
|
||||||
|
ActiveDay = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) ActiveUsers(ctx context.Context, dur time.Duration) (numUsers int64, err error) {
|
||||||
|
t := time.Now().Add(-dur)
|
||||||
|
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "querying active user count")
|
||||||
|
}
|
||||||
|
return numUsers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type connOrTx interface {
|
||||||
|
Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members)
|
||||||
|
func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) {
|
||||||
|
sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
35
backend/db/names_pronouns.go
Normal file
35
backend/db/names_pronouns.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) SetUserNamesPronouns(ctx context.Context, tx pgx.Tx, userID xid.ID, names []FieldEntry, pronouns []PronounEntry) (err error) {
|
||||||
|
sql, args, err := sq.Update("users").Set("names", names).Set("pronouns", pronouns).Where("id = ?", userID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetMemberNamesPronouns(ctx context.Context, tx pgx.Tx, memberID xid.ID, names []FieldEntry, pronouns []PronounEntry) (err error) {
|
||||||
|
sql, args, err := sq.Update("members").Set("names", names).Set("pronouns", pronouns).Where("id = ?", memberID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
20
backend/db/redis.go
Normal file
20
backend/db/redis.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ radix.Client = (*dummyRedis)(nil)
|
||||||
|
|
||||||
|
type dummyRedis struct{}
|
||||||
|
|
||||||
|
func (*dummyRedis) Addr() net.Addr { return &net.IPAddr{} }
|
||||||
|
func (*dummyRedis) Close() error { return nil }
|
||||||
|
|
||||||
|
func (*dummyRedis) Do(context.Context, radix.Action) error {
|
||||||
|
return errors.Sentinel("this is a dummy client")
|
||||||
|
}
|
229
backend/db/report.go
Normal file
229
backend/db/report.go
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID xid.ID `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
MemberID xid.ID `json:"member_id"`
|
||||||
|
MemberName *string `json:"member_name"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ReporterID xid.ID `json:"reporter_id"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
AdminID xid.ID `json:"admin_id"`
|
||||||
|
AdminComment *string `json:"admin_comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReportPageSize = 100
|
||||||
|
const ErrReportNotFound = errors.Sentinel("report not found")
|
||||||
|
|
||||||
|
func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report, err error) {
|
||||||
|
builder := sq.Select("*",
|
||||||
|
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
|
||||||
|
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
|
||||||
|
From("reports").
|
||||||
|
Limit(ReportPageSize).
|
||||||
|
OrderBy("id DESC")
|
||||||
|
if before != 0 {
|
||||||
|
builder = builder.Where("id < ?", before)
|
||||||
|
}
|
||||||
|
if closed {
|
||||||
|
builder = builder.Where("resolved_at IS NOT NULL")
|
||||||
|
} else {
|
||||||
|
builder = builder.Where("resolved_at IS NULL")
|
||||||
|
}
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &rs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return []Report{}, nil
|
||||||
|
}
|
||||||
|
return rs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) {
|
||||||
|
builder := sq.Select("*",
|
||||||
|
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
|
||||||
|
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
|
||||||
|
From("reports").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Limit(ReportPageSize).
|
||||||
|
OrderBy("id DESC")
|
||||||
|
if before != 0 {
|
||||||
|
builder = builder.Where("id < ?", before)
|
||||||
|
}
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &rs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return []Report{}, nil
|
||||||
|
}
|
||||||
|
return rs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) {
|
||||||
|
builder := sq.Select("*",
|
||||||
|
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
|
||||||
|
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
|
||||||
|
From("reports").
|
||||||
|
Where("reporter_id = ?", reporterID).
|
||||||
|
Limit(ReportPageSize).
|
||||||
|
OrderBy("id DESC")
|
||||||
|
if before != 0 {
|
||||||
|
builder = builder.Where("id < ?", before)
|
||||||
|
}
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &rs, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return []Report{}, nil
|
||||||
|
}
|
||||||
|
return rs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Report(ctx context.Context, tx pgx.Tx, id int64) (r Report, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("reports").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return r, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &r, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return r, ErrReportNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateReport(ctx context.Context, reporterID, userID xid.ID, memberID *xid.ID, reason string) (r Report, err error) {
|
||||||
|
sql, args, err := sq.Insert("reports").SetMap(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"reporter_id": reporterID,
|
||||||
|
"member_id": memberID,
|
||||||
|
"reason": reason,
|
||||||
|
}).Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return r, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &r, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return r, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ResolveReport(ctx context.Context, ex Execer, id int64, adminID xid.ID, comment string) error {
|
||||||
|
sql, args, err := sq.Update("reports").
|
||||||
|
Set("admin_id", adminID).
|
||||||
|
Set("admin_comment", comment).
|
||||||
|
Set("resolved_at", time.Now().UTC()).
|
||||||
|
Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Warning struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID xid.ID `json:"-"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ReadAt *time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateWarning(ctx context.Context, tx pgx.Tx, userID xid.ID, reason string) (w Warning, err error) {
|
||||||
|
sql, args, err := sq.Insert("warnings").SetMap(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"reason": reason,
|
||||||
|
}).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return w, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &w, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return w, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Warnings(ctx context.Context, userID xid.ID, unread bool) (ws []Warning, err error) {
|
||||||
|
builder := sq.Select("*").From("warnings").Where("user_id = ?", userID).OrderBy("id DESC")
|
||||||
|
if unread {
|
||||||
|
builder = builder.Where("read_at IS NULL")
|
||||||
|
}
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ws, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &ws, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
if len(ws) == 0 {
|
||||||
|
return []Warning{}, nil
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AckWarning(ctx context.Context, userID xid.ID, id int64) (ok bool, err error) {
|
||||||
|
sql, args, err := sq.Update("warnings").
|
||||||
|
Set("read_at", time.Now().UTC()).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Where("read_at IS NULL").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
ct, err := db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
118
backend/db/tokens.go
Normal file
118
backend/db/tokens.go
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
UserID xid.ID
|
||||||
|
TokenID xid.ID
|
||||||
|
Invalidated bool
|
||||||
|
APIOnly bool `db:"api_only"`
|
||||||
|
ReadOnly bool
|
||||||
|
Created time.Time
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) TokenValid(ctx context.Context, userID, tokenID xid.ID) (valid bool, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("tokens").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("token_id = ?", tokenID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
var t Token
|
||||||
|
err = pgxscan.Get(ctx, db, &t, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, errors.Wrap(err, "getting from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return !t.Invalidated && t.Created.Before(now) && t.Expires.After(now), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("tokens").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("expires > ?", time.Now()).
|
||||||
|
OrderBy("created").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Select(ctx, db, &ts, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "getting from database")
|
||||||
|
}
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 months, might be customizable later
|
||||||
|
const TokenExpiryTime = 3 * 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// SaveToken saves a token to the database.
|
||||||
|
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) {
|
||||||
|
sql, args, err := sq.Insert("tokens").
|
||||||
|
SetMap(map[string]any{
|
||||||
|
"user_id": userID,
|
||||||
|
"token_id": tokenID,
|
||||||
|
"expires": time.Now().Add(TokenExpiryTime),
|
||||||
|
"api_only": apiOnly,
|
||||||
|
"read_only": readOnly,
|
||||||
|
}).
|
||||||
|
Suffix("RETURNING *").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return t, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &t, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return t, errors.Wrap(err, "inserting token")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) {
|
||||||
|
sql, args, err := sq.Update("tokens").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("token_id = ?", tokenID).
|
||||||
|
Set("invalidated", true).
|
||||||
|
Suffix("RETURNING *").
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return t, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &t, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return t, errors.Wrap(err, "invalidating token")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) InvalidateAllTokens(ctx context.Context, tx pgx.Tx, userID xid.ID) error {
|
||||||
|
sql, args, err := sq.Update("tokens").Where("user_id = ?", userID).Set("invalidated", true).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
762
backend/db/user.go
Normal file
762
backend/db/user.go
Normal file
|
@ -0,0 +1,762 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/icons"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID xid.ID
|
||||||
|
SID string `db:"sid"`
|
||||||
|
Username string
|
||||||
|
DisplayName *string
|
||||||
|
Bio *string
|
||||||
|
MemberTitle *string
|
||||||
|
LastActive time.Time
|
||||||
|
|
||||||
|
Avatar *string
|
||||||
|
Links []string
|
||||||
|
|
||||||
|
Names []FieldEntry
|
||||||
|
Pronouns []PronounEntry
|
||||||
|
|
||||||
|
Discord *string
|
||||||
|
DiscordUsername *string
|
||||||
|
|
||||||
|
Fediverse *string
|
||||||
|
FediverseUsername *string
|
||||||
|
FediverseAppID *int64
|
||||||
|
FediverseInstance *string
|
||||||
|
|
||||||
|
Tumblr *string
|
||||||
|
TumblrUsername *string
|
||||||
|
|
||||||
|
Google *string
|
||||||
|
GoogleUsername *string
|
||||||
|
|
||||||
|
MaxInvites int
|
||||||
|
IsAdmin bool
|
||||||
|
ListPrivate bool
|
||||||
|
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
||||||
|
|
||||||
|
DeletedAt *time.Time
|
||||||
|
SelfDelete *bool
|
||||||
|
DeleteReason *string
|
||||||
|
|
||||||
|
CustomPreferences CustomPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomPreferences = map[string]CustomPreference
|
||||||
|
|
||||||
|
type CustomPreference struct {
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
Size PreferenceSize `json:"size"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
|
Favourite bool `json:"favourite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomPreference) Validate() string {
|
||||||
|
if !icons.IsValid(c.Icon) {
|
||||||
|
return fmt.Sprintf("custom preference icon %q is invalid", c.Icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Tooltip == "" {
|
||||||
|
return "custom preference tooltip is empty"
|
||||||
|
}
|
||||||
|
if common.StringLength(&c.Tooltip) > FieldEntryMaxLength {
|
||||||
|
return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall {
|
||||||
|
return fmt.Sprintf("custom preference size %q is invalid", string(c.Size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreferenceSize string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PreferenceSizeLarge PreferenceSize = "large"
|
||||||
|
PreferenceSizeNormal PreferenceSize = "normal"
|
||||||
|
PreferenceSizeSmall PreferenceSize = "small"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u User) NumProviders() (numProviders int) {
|
||||||
|
if u.Discord != nil {
|
||||||
|
numProviders++
|
||||||
|
}
|
||||||
|
if u.Fediverse != nil {
|
||||||
|
numProviders++
|
||||||
|
}
|
||||||
|
if u.Tumblr != nil {
|
||||||
|
numProviders++
|
||||||
|
}
|
||||||
|
if u.Google != nil {
|
||||||
|
numProviders++
|
||||||
|
}
|
||||||
|
return numProviders
|
||||||
|
}
|
||||||
|
|
||||||
|
type Badge int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
BadgeAdmin Badge = 1 << 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// usernames must match this regex
|
||||||
|
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
|
||||||
|
|
||||||
|
func UsernameValid(username string) (err error) {
|
||||||
|
// This name would break routing, but periods should still be allowed in names otherwise.
|
||||||
|
if username == ".." {
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if !usernameRegex.MatchString(username) {
|
||||||
|
if len(username) < 2 {
|
||||||
|
return ErrUsernameTooShort
|
||||||
|
} else if len(username) > 40 {
|
||||||
|
return ErrUsernameTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrUserNotFound = errors.Sentinel("user not found")
|
||||||
|
|
||||||
|
ErrUsernameTaken = errors.Sentinel("username is already taken")
|
||||||
|
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
|
||||||
|
ErrUsernameTooShort = errors.Sentinel("username is too short")
|
||||||
|
ErrUsernameTooLong = errors.Sentinel("username is too long")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxUsernameLength = 40
|
||||||
|
MaxDisplayNameLength = 100
|
||||||
|
MaxUserBioLength = 1000
|
||||||
|
MaxUserLinksLength = 25
|
||||||
|
MaxLinkLength = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SelfDeleteAfter = 30 * 24 * time.Hour
|
||||||
|
ModDeleteAfter = 180 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateUser creates a user with the given username.
|
||||||
|
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
|
||||||
|
// check if the username is valid
|
||||||
|
// if not, return an error depending on what failed
|
||||||
|
if err := UsernameValid(username); err != nil {
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
// unique constraint violation
|
||||||
|
if pge.Code == uniqueViolation {
|
||||||
|
return u, ErrUsernameTaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Cause(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
From("users").
|
||||||
|
Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username string, appID int64) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("fediverse", userID).
|
||||||
|
Set("fediverse_username", username).
|
||||||
|
Set("fediverse_app_id", appID).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Fediverse = &userID
|
||||||
|
u.FediverseUsername = &username
|
||||||
|
u.FediverseAppID = &appID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UnlinkFedi(ctx context.Context, ex Execer) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("fediverse", nil).
|
||||||
|
Set("fediverse_username", nil).
|
||||||
|
Set("fediverse_app_id", nil).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Fediverse = nil
|
||||||
|
u.FediverseUsername = nil
|
||||||
|
u.FediverseAppID = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscordUser fetches a user by Discord user ID.
|
||||||
|
func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
From("users").Where("discord = ?", discordID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UpdateFromDiscord(ctx context.Context, ex Execer, du *discordgo.User) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("discord", du.ID).
|
||||||
|
Set("discord_username", du.String()).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Discord = &du.ID
|
||||||
|
username := du.String()
|
||||||
|
u.DiscordUsername = &username
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("discord", nil).
|
||||||
|
Set("discord_username", nil).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Discord = nil
|
||||||
|
u.DiscordUsername = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TumblrUser fetches a user by Tumblr user ID.
|
||||||
|
func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
From("users").Where("tumblr = ?", tumblrID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("tumblr", tumblrID).
|
||||||
|
Set("tumblr_username", tumblrUsername).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Tumblr = &tumblrID
|
||||||
|
u.TumblrUsername = &tumblrUsername
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("tumblr", nil).
|
||||||
|
Set("tumblr_username", nil).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Tumblr = nil
|
||||||
|
u.TumblrUsername = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoogleUser fetches a user by Google user ID.
|
||||||
|
func (db *DB) GoogleUser(ctx context.Context, googleID string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
From("users").Where("google = ?", googleID).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("google", googleID).
|
||||||
|
Set("google_username", googleUsername).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Google = &googleID
|
||||||
|
u.GoogleUsername = &googleUsername
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("google", nil).
|
||||||
|
Set("google_username", nil).
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ex.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Google = nil
|
||||||
|
u.GoogleUsername = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User gets a user by ID.
|
||||||
|
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||||
|
From("users").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "getting user from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username gets a user by username.
|
||||||
|
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "getting user from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBySID gets a user by their short ID.
|
||||||
|
func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "getting user from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsernameTaken checks if the given username is already taken.
|
||||||
|
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
|
||||||
|
if err := UsernameValid(username); err != nil {
|
||||||
|
return false, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.QueryRow(ctx, "select exists (select id from users where username = $1)", username).Scan(&taken)
|
||||||
|
return true, taken, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUsername validates the given username, then updates the given user's name to it if valid.
|
||||||
|
func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error {
|
||||||
|
if err := UsernameValid(newName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
pge := &pgconn.PgError{}
|
||||||
|
if errors.As(err, &pge) {
|
||||||
|
// unique constraint violation
|
||||||
|
if pge.Code == uniqueViolation {
|
||||||
|
return ErrUsernameTaken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateUser(
|
||||||
|
ctx context.Context,
|
||||||
|
tx pgx.Tx, id xid.ID,
|
||||||
|
displayName, bio *string,
|
||||||
|
memberTitle *string, listPrivate *bool,
|
||||||
|
links *[]string,
|
||||||
|
avatar *string,
|
||||||
|
customPreferences *CustomPreferences,
|
||||||
|
) (u User, err error) {
|
||||||
|
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
|
||||||
|
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "getting user from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
builder := sq.Update("users").Where("id = ?", id).Suffix("RETURNING *")
|
||||||
|
if displayName != nil {
|
||||||
|
if *displayName == "" {
|
||||||
|
builder = builder.Set("display_name", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("display_name", *displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bio != nil {
|
||||||
|
if *bio == "" {
|
||||||
|
builder = builder.Set("bio", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("bio", *bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memberTitle != nil {
|
||||||
|
if *memberTitle == "" {
|
||||||
|
builder = builder.Set("member_title", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("member_title", *memberTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if links != nil {
|
||||||
|
builder = builder.Set("links", *links)
|
||||||
|
}
|
||||||
|
if listPrivate != nil {
|
||||||
|
builder = builder.Set("list_private", *listPrivate)
|
||||||
|
}
|
||||||
|
if customPreferences != nil {
|
||||||
|
builder = builder.Set("custom_preferences", *customPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
if avatar != nil {
|
||||||
|
if *avatar == "" {
|
||||||
|
builder = builder.Set("avatar", nil)
|
||||||
|
} else {
|
||||||
|
builder = builder.Set("avatar", avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, tx, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "executing sql")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete bool, reason string) error {
|
||||||
|
builder := sq.Update("users").Set("deleted_at", time.Now().UTC()).Set("self_delete", selfDelete).Where("id = ?", id)
|
||||||
|
if !selfDelete {
|
||||||
|
builder = builder.Set("delete_reason", reason)
|
||||||
|
}
|
||||||
|
sql, args, err := builder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("sid", squirrel.Expr("find_free_user_sid()")).
|
||||||
|
Set("last_sid_reroll", time.Now()).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Suffix("RETURNING sid").ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.QueryRow(ctx, sql, args...).Scan(&newID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return newID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("deleted_at", nil).
|
||||||
|
Set("self_delete", nil).
|
||||||
|
Set("delete_reason", nil).
|
||||||
|
Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ForceDeleteUser(ctx context.Context, id xid.ID) error {
|
||||||
|
sql, args, err := sq.Delete("users").Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteUserMembers(ctx context.Context, tx pgx.Tx, id xid.ID) error {
|
||||||
|
sql, args, err := sq.Delete("members").Where("user_id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ResetUser(ctx context.Context, tx pgx.Tx, id xid.ID) error {
|
||||||
|
err := db.SetUserFields(ctx, tx, id, []Field{})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, err = hasher.Write(id.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "hashing user id")
|
||||||
|
}
|
||||||
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
sql, args, err := sq.Update("users").
|
||||||
|
Set("username", "deleted-"+hash).
|
||||||
|
Set("display_name", nil).
|
||||||
|
Set("bio", nil).
|
||||||
|
Set("links", nil).
|
||||||
|
Set("names", "[]").
|
||||||
|
Set("pronouns", "[]").
|
||||||
|
Set("avatar", nil).
|
||||||
|
Where("id = ?", id).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "executing query")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
|
||||||
|
u, err := db.User(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Avatar != nil {
|
||||||
|
err = db.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting user avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var exports []DataExport
|
||||||
|
err = pgxscan.Select(ctx, db, &exports, "SELECT * FROM data_exports WHERE user_id = $1", u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting export iles")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, de := range exports {
|
||||||
|
err = db.DeleteExport(ctx, de)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := db.UserMembers(ctx, u.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting members")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range members {
|
||||||
|
if m.Avatar == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
280
backend/exporter/exporter.go
Normal file
280
backend/exporter/exporter.go
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Command = &cli.Command{
|
||||||
|
Name: "exporter",
|
||||||
|
Usage: "Data exporter service",
|
||||||
|
Action: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
Router chi.Router
|
||||||
|
DB *db.DB
|
||||||
|
|
||||||
|
exporting map[xid.ID]struct{}
|
||||||
|
exportingMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(c *cli.Context) error {
|
||||||
|
port := ":" + os.Getenv("EXPORTER_PORT")
|
||||||
|
|
||||||
|
db, err := db.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("creating database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &server{
|
||||||
|
Router: chi.NewRouter(),
|
||||||
|
DB: db,
|
||||||
|
exporting: make(map[xid.ID]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// set up middleware + the single route
|
||||||
|
s.Router.Use(middleware.Recoverer)
|
||||||
|
s.Router.Get("/start/{id}", s.startExport)
|
||||||
|
|
||||||
|
e := make(chan error)
|
||||||
|
|
||||||
|
// run server in another goroutine (for gracefully shutting down, see below)
|
||||||
|
go func() {
|
||||||
|
e <- http.ListenAndServe(port, s.Router)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
log.Infof("API server running at %v!", port)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Info("Interrupt signal received, shutting down...")
|
||||||
|
s.DB.Close()
|
||||||
|
return nil
|
||||||
|
case err := <-e:
|
||||||
|
log.Fatalf("Error running server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) startExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user %v: %v", id, err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.doExport(u)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) doExport(u db.User) {
|
||||||
|
s.exportingMu.Lock()
|
||||||
|
if _, ok := s.exporting[u.ID]; ok {
|
||||||
|
s.exportingMu.Unlock()
|
||||||
|
log.Debugf("user %v is already being exported, aborting", u.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.exporting[u.ID] = struct{}{}
|
||||||
|
s.exportingMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
s.exportingMu.Lock()
|
||||||
|
delete(s.exporting, u.ID)
|
||||||
|
s.exportingMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
log.Debugf("[%v] starting export of user", u.ID)
|
||||||
|
|
||||||
|
jsonBuffer := new(bytes.Buffer)
|
||||||
|
encoder := json.NewEncoder(jsonBuffer)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
|
||||||
|
outBuffer := new(bytes.Buffer)
|
||||||
|
zw := zip.NewWriter(outBuffer)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
w, err := zw.Create("user.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] creating file in zip archive: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] getting user fields", u.ID)
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting user fields: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] getting user warnings", u.ID)
|
||||||
|
|
||||||
|
warnings, err := s.DB.Warnings(ctx, u.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting warnings: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] writing user json", u.ID)
|
||||||
|
|
||||||
|
err = encoder.Encode(dbUserToExport(u, fields, warnings))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] marshaling user: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(w, jsonBuffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] writing user: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonBuffer.Reset()
|
||||||
|
|
||||||
|
if u.Avatar != nil {
|
||||||
|
log.Debugf("[%v] getting user avatar", u.ID)
|
||||||
|
|
||||||
|
w, err := zw.Create("user_avatar.webp")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] creating file in zip archive: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := s.DB.UserAvatar(ctx, u.ID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting user avatar: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] writing user avatar: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] exported user avatar", u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.DB.UserMembers(ctx, u.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting user members: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range members {
|
||||||
|
log.Debugf("[%v] starting export for member %v", u.ID, m.ID)
|
||||||
|
|
||||||
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting fields for member %v: %v", u.ID, m.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := zw.Create("members/" + m.Name + "-" + m.ID.String() + ".json")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] creating file in zip archive: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = encoder.Encode(dbMemberToExport(m, fields))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] marshaling member %v: %v", u.ID, m.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(w, jsonBuffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] writing member %v json: %v", u.ID, m.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonBuffer.Reset()
|
||||||
|
|
||||||
|
if m.Avatar != nil {
|
||||||
|
log.Debugf("[%v] getting member %v avatar", u.ID, m.ID)
|
||||||
|
|
||||||
|
w, err := zw.Create("members/" + m.Name + "-" + m.ID.String() + "-avatar.webp")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] creating file in zip archive: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := s.DB.MemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] getting member %v avatar: %v", u.ID, m.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] writing member %v avatar: %v", u.ID, m.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] exported member %v avatar", u.ID, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] finished export for member %v", u.ID, m.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] finished export, uploading to object storage and saving in database", u.ID)
|
||||||
|
|
||||||
|
err = zw.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] closing zip file: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
de, err := s.DB.CreateExport(ctx, u.ID, randomFilename(), outBuffer)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("[%v] writing export: %v", u.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("[%v] finished writing export. path: %q", u.ID, de.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomFilename() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
86
backend/exporter/types.go
Normal file
86
backend/exporter/types.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userExport struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Username string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
|
||||||
|
Links []string `json:"links"`
|
||||||
|
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
|
||||||
|
Fediverse *string `json:"fediverse"`
|
||||||
|
FediverseUsername *string `json:"fediverse_username"`
|
||||||
|
FediverseInstance *string `json:"fediverse_instance"`
|
||||||
|
|
||||||
|
Discord *string `json:"discord"`
|
||||||
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
|
Tumblr *string `json:"tumblr"`
|
||||||
|
TumblrUsername *string `json:"tumblr_username"`
|
||||||
|
|
||||||
|
Google *string `json:"google"`
|
||||||
|
GoogleUsername *string `json:"google_username"`
|
||||||
|
|
||||||
|
MaxInvites int `json:"max_invites"`
|
||||||
|
|
||||||
|
Warnings []db.Warning `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExport {
|
||||||
|
return userExport{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Bio: u.Bio,
|
||||||
|
Links: db.NotNull(u.Links),
|
||||||
|
Names: db.NotNull(u.Names),
|
||||||
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Tumblr: u.Tumblr,
|
||||||
|
TumblrUsername: u.TumblrUsername,
|
||||||
|
Google: u.Google,
|
||||||
|
GoogleUsername: u.GoogleUsername,
|
||||||
|
MaxInvites: u.MaxInvites,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: u.FediverseInstance,
|
||||||
|
Warnings: db.NotNull(warnings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberExport struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
Unlisted bool `json:"unlisted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
|
||||||
|
return memberExport{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
DisplayName: m.DisplayName,
|
||||||
|
Bio: m.Bio,
|
||||||
|
Links: db.NotNull(m.Links),
|
||||||
|
Names: db.NotNull(m.Names),
|
||||||
|
Pronouns: db.NotNull(m.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
Unlisted: m.Unlisted,
|
||||||
|
}
|
||||||
|
}
|
1968
backend/icons/icons.go
Normal file
1968
backend/icons/icons.go
Normal file
File diff suppressed because it is too large
Load diff
69
backend/log/log.go
Normal file
69
backend/log/log.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// Package log contains a global Zap logger.
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Logger *zap.Logger
|
||||||
|
var SugaredLogger *zap.SugaredLogger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
zcfg := zap.NewProductionConfig()
|
||||||
|
|
||||||
|
zcfg.Level.SetLevel(zap.DebugLevel)
|
||||||
|
zcfg.Encoding = "console"
|
||||||
|
zcfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||||
|
zcfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
|
||||||
|
logger, err := zcfg.Build()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zap.RedirectStdLog(logger)
|
||||||
|
|
||||||
|
Logger = logger
|
||||||
|
SugaredLogger = Logger.WithOptions(zap.AddCallerSkip(1)).Sugar()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Debug(v ...any) {
|
||||||
|
SugaredLogger.Debug(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(v ...any) {
|
||||||
|
SugaredLogger.Info(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warn(v ...any) {
|
||||||
|
SugaredLogger.Warn(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(v ...any) {
|
||||||
|
SugaredLogger.Error(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fatal(v ...any) {
|
||||||
|
SugaredLogger.Fatal(v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Debugf(tmpl string, v ...any) {
|
||||||
|
SugaredLogger.Debugf(tmpl, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Infof(tmpl string, v ...any) {
|
||||||
|
SugaredLogger.Infof(tmpl, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warnf(tmpl string, v ...any) {
|
||||||
|
SugaredLogger.Warnf(tmpl, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Errorf(tmpl string, v ...any) {
|
||||||
|
SugaredLogger.Errorf(tmpl, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fatalf(tmpl string, v ...any) {
|
||||||
|
SugaredLogger.Fatalf(tmpl, v...)
|
||||||
|
}
|
94
backend/main.go
Normal file
94
backend/main.go
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Command = &cli.Command{
|
||||||
|
Name: "web",
|
||||||
|
Usage: "Run the API server",
|
||||||
|
Action: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(c *cli.Context) error {
|
||||||
|
// set vips log level to WARN, else it will spam logs on info level
|
||||||
|
vips.LoggingSettings(nil, vips.LogLevelWarning)
|
||||||
|
|
||||||
|
vips.Startup(nil)
|
||||||
|
defer vips.Shutdown()
|
||||||
|
|
||||||
|
port := ":" + os.Getenv("PORT")
|
||||||
|
|
||||||
|
s, err := server.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating server: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// set render.Decode to a custom one that checks content length
|
||||||
|
render.Decode = decode
|
||||||
|
|
||||||
|
// mount api routes
|
||||||
|
mountRoutes(s)
|
||||||
|
|
||||||
|
e := make(chan error)
|
||||||
|
|
||||||
|
// run server in another goroutine (for gracefully shutting down, see below)
|
||||||
|
go func() {
|
||||||
|
e <- http.ListenAndServe(port, s.Router)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
log.Infof("API server running at %v!", port)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Info("Interrupt signal received, shutting down...")
|
||||||
|
s.DB.Close()
|
||||||
|
return nil
|
||||||
|
case err := <-e:
|
||||||
|
log.Fatalf("Error running server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaxContentLength = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
// decode is a custom render.Decode function that makes sure the request doesn't exceed 2 megabytes.
|
||||||
|
func decode(r *http.Request, v any) error {
|
||||||
|
if r.ContentLength > MaxContentLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrRequestTooBig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is copied from render.Decode to replace r.Body with an io.LimitedReader
|
||||||
|
var err error
|
||||||
|
lr := io.LimitReader(r.Body, MaxContentLength)
|
||||||
|
|
||||||
|
switch render.GetRequestContentType(r) {
|
||||||
|
case render.ContentTypeJSON:
|
||||||
|
err = render.DecodeJSON(lr, v)
|
||||||
|
default:
|
||||||
|
err = server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
480
backend/openapi.html
Normal file
480
backend/openapi.html
Normal file
File diff suppressed because one or more lines are too long
99
backend/prns/main.go
Normal file
99
backend/prns/main.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package prns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Command = &cli.Command{
|
||||||
|
Name: "shortener",
|
||||||
|
Usage: "URL shortener service",
|
||||||
|
Action: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(c *cli.Context) error {
|
||||||
|
port := ":" + os.Getenv("PRNS_PORT")
|
||||||
|
baseURL := os.Getenv("BASE_URL")
|
||||||
|
|
||||||
|
db, err := dbpkg.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("creating database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Errorf("recovered from panic: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if len(id) == 5 {
|
||||||
|
u, err := db.UserBySID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err != dbpkg.ErrUserNotFound {
|
||||||
|
log.Errorf("getting user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, baseURL+"/@"+u.Username, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(id) == 6 {
|
||||||
|
m, err := db.MemberBySID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err != dbpkg.ErrMemberNotFound {
|
||||||
|
log.Errorf("getting member: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := db.User(r.Context(), m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user for member %v: %v", m.ID, err)
|
||||||
|
|
||||||
|
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, baseURL+"/@"+u.Username+"/"+m.Name, http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
|
||||||
|
})
|
||||||
|
|
||||||
|
e := make(chan error)
|
||||||
|
go func() {
|
||||||
|
e <- http.ListenAndServe(port, nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
log.Infof("API server running at %v!", port)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Info("Interrupt signal received, shutting down...")
|
||||||
|
db.Close()
|
||||||
|
return nil
|
||||||
|
case err := <-e:
|
||||||
|
log.Fatalf("Error running server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
39
backend/routes.go
Normal file
39
backend/routes.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/auth"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/bot"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/member"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/meta"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/mod"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/user"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed openapi.html
|
||||||
|
var openapi string
|
||||||
|
|
||||||
|
// mountRoutes mounts all API routes on the server's router.
|
||||||
|
// they are all mounted under /v1/
|
||||||
|
func mountRoutes(s *server.Server) {
|
||||||
|
// future-proofing for API versions
|
||||||
|
s.Router.Route("/v1", func(r chi.Router) {
|
||||||
|
auth.Mount(s, r)
|
||||||
|
user.Mount(s, r)
|
||||||
|
member.Mount(s, r)
|
||||||
|
bot.Mount(s, r)
|
||||||
|
meta.Mount(s, r)
|
||||||
|
mod.Mount(s, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// API docs
|
||||||
|
s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render.HTML(w, r, openapi)
|
||||||
|
})
|
||||||
|
}
|
57
backend/routes/auth/captcha.go
Normal file
57
backend/routes/auth/captcha.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hcaptchaURL = "https://hcaptcha.com/siteverify"
|
||||||
|
|
||||||
|
type hcaptchaResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyCaptcha verifies a captcha response.
|
||||||
|
func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) {
|
||||||
|
vals := url.Values{
|
||||||
|
"response": []string{response},
|
||||||
|
"secret": []string{s.hcaptchaSecret},
|
||||||
|
"sitekey": []string{s.hcaptchaSitekey},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "creating request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "sending request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return false, errors.Sentinel("error status code")
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "reading body")
|
||||||
|
}
|
||||||
|
|
||||||
|
var hr hcaptchaResponse
|
||||||
|
err = json.Unmarshal(b, &hr)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "unmarshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hr.Success, nil
|
||||||
|
}
|
389
backend/routes/auth/discord.go
Normal file
389
backend/routes/auth/discord.go
Normal file
|
@ -0,0 +1,389 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var discordOAuthConfig = oauth2.Config{
|
||||||
|
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://discord.com/api/oauth2/authorize",
|
||||||
|
TokenURL: "https://discord.com/api/oauth2/token",
|
||||||
|
AuthStyle: oauth2.AuthStyleInParams,
|
||||||
|
},
|
||||||
|
Scopes: []string{"identify"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthCallbackRequest struct {
|
||||||
|
CallbackDomain string `json:"callback_domain"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type discordCallbackResponse struct {
|
||||||
|
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Discord will be set
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
|
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
||||||
|
Ticket string `json:"ticket,omitempty"`
|
||||||
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
SelfDelete *bool `json:"self_delete,omitempty"`
|
||||||
|
DeleteReason *string `json:"delete_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
decoded, err := Decode[oauthCallbackRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the state can't be validated, return
|
||||||
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := discordOAuthConfig
|
||||||
|
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/discord"
|
||||||
|
token, err := cfg.Exchange(r.Context(), decoded.Code)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("exchanging oauth code: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
|
||||||
|
du, err := dg.User("@me")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.DiscordUser(ctx, du.ID)
|
||||||
|
if err == nil {
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
// store cancel delete token
|
||||||
|
token := undeleteToken()
|
||||||
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving undelete token: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, []db.Field{}),
|
||||||
|
IsDeleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
SelfDelete: u.SelfDelete,
|
||||||
|
DeleteReason: u.DeleteReason,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromDiscord(ctx, s.DB, du)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating user %v with Discord info: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no user found, so save a ticket + save their Discord info in Redis
|
||||||
|
ticket := RandBase64(32)
|
||||||
|
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
|
HasAccount: false,
|
||||||
|
Discord: du.String(),
|
||||||
|
Ticket: ticket,
|
||||||
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkRequest struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := Decode[linkRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Discord != nil {
|
||||||
|
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
du := new(discordgo.User)
|
||||||
|
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting discord user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
if du.ID == "" {
|
||||||
|
log.Errorf("linking user with id %v: discord user ID was empty", claims.UserID)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromDiscord(ctx, s.DB, du)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from discord")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Discord == nil {
|
||||||
|
return server.APIError{Code: server.ErrNotLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cannot unlink last auth provider
|
||||||
|
if u.NumProviders() <= 1 {
|
||||||
|
return server.APIError{Code: server.ErrLastProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UnlinkDiscord(ctx, s.DB)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type signupRequest struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
CaptchaResponse string `json:"captcha_response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signupResponse struct {
|
||||||
|
User userResponse `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
req, err := Decode[signupRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite && req.InviteCode == "" {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
du := new(discordgo.User)
|
||||||
|
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting discord user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if du.ID == "" {
|
||||||
|
log.Errorf("creating user with name %q: user ID was empty", req.Username)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromDiscord(ctx, tx, du)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from discord")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite {
|
||||||
|
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking and invalidating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete sign up ticket
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "discord:"+req.Ticket))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting signup ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit transaction
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user
|
||||||
|
render.JSON(w, r, signupResponse{
|
||||||
|
User: *dbUserToUserResponse(u, nil),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decode[T any](r *http.Request) (T, error) {
|
||||||
|
decoded := *new(T)
|
||||||
|
|
||||||
|
return decoded, render.Decode(r, &decoded)
|
||||||
|
}
|
411
backend/routes/auth/fedi_mastodon.go
Normal file
411
backend/routes/auth/fedi_mastodon.go
Normal file
|
@ -0,0 +1,411 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fediOauthCallbackRequest struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fediCallbackResponse struct {
|
||||||
|
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Fediverse will be set
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
|
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
|
||||||
|
Ticket string `json:"ticket,omitempty"`
|
||||||
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
SelfDelete *bool `json:"self_delete,omitempty"`
|
||||||
|
DeleteReason *string `json:"delete_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type partialMastodonAccount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
decoded, err := Decode[fediOauthCallbackRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the state can't be validated, return
|
||||||
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, decoded.Instance)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting app for instance %q: %v", decoded.Instance, err)
|
||||||
|
|
||||||
|
if err == db.ErrNoInstanceApp {
|
||||||
|
// can we get here?
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.ClientConfig().Exchange(ctx, decoded.Code)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("exchanging oauth code: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make me user request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+decoded.Instance+"/api/v1/accounts/verify_credentials", nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating verify_credentials request")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", token.Type()+" "+token.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "sending verify_credentials request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "reading verify_credentials response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu partialMastodonAccount
|
||||||
|
err = json.Unmarshal(jb, &mu)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling verify_credentials response")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID)
|
||||||
|
if err == nil {
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
// store cancel delete token
|
||||||
|
token := undeleteToken()
|
||||||
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving undelete token: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, []db.Field{}),
|
||||||
|
IsDeleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
SelfDelete: u.SelfDelete,
|
||||||
|
DeleteReason: u.DeleteReason,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating user %v with mastoAPI info: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no user found, so save a ticket + save their Mastodon info in Redis
|
||||||
|
ticket := RandBase64(32)
|
||||||
|
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: false,
|
||||||
|
Fediverse: mu.Username,
|
||||||
|
Ticket: ticket,
|
||||||
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fediLinkRequest struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := Decode[fediLinkRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting instance application")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Fediverse != nil {
|
||||||
|
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := new(partialMastodonAccount)
|
||||||
|
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting mastoAPI user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mu.ID == "" {
|
||||||
|
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from mastoAPI")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Fediverse == nil {
|
||||||
|
return server.APIError{Code: server.ErrNotLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cannot unlink last auth provider
|
||||||
|
if u.NumProviders() <= 1 {
|
||||||
|
return server.APIError{Code: server.ErrLastProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UnlinkFedi(ctx, s.DB)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fediSignupRequest struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
CaptchaResponse string `json:"captcha_response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
req, err := Decode[fediSignupRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite && req.InviteCode == "" {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting instance application")
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
mu := new(partialMastodonAccount)
|
||||||
|
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting mastoAPI user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if mu.ID == "" {
|
||||||
|
log.Errorf("creating user with name %q: user ID was empty", req.Username)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from mastoAPI")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite {
|
||||||
|
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking and invalidating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete sign up ticket
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "mastodon:"+req.Ticket))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting signup ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit transaction
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user
|
||||||
|
render.JSON(w, r, signupResponse{
|
||||||
|
User: *dbUserToUserResponse(u, nil),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
456
backend/routes/auth/fedi_misskey.go
Normal file
456
backend/routes/auth/fedi_misskey.go
Normal file
|
@ -0,0 +1,456 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type partialMisskeyAccount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
decoded, err := Decode[fediOauthCallbackRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, decoded.Instance)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting app for instance %q: %v", decoded.Instance, err)
|
||||||
|
|
||||||
|
if err == db.ErrNoInstanceApp {
|
||||||
|
// can we get here?
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userkeyReq := struct {
|
||||||
|
AppSecret string `json:"appSecret"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}{AppSecret: app.ClientSecret, Token: decoded.Code}
|
||||||
|
|
||||||
|
b, err := json.Marshal(userkeyReq)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "marshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// make me user request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+decoded.Instance+"/api/auth/session/userkey", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating userkey request")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "sending i request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "reading i response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
log.Errorf("POST userkey for instance %q (type %v): %v", app.Instance, app.InstanceType, string(jb))
|
||||||
|
return errors.Wrap(err, "error on misskey's end")
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu struct {
|
||||||
|
User partialMisskeyAccount `json:"user"`
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(jb, &mu)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling userkey response")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.FediverseUser(ctx, mu.User.ID, app.ID)
|
||||||
|
if err == nil {
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
// store cancel delete token
|
||||||
|
token := undeleteToken()
|
||||||
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving undelete token: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, []db.Field{}),
|
||||||
|
IsDeleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
SelfDelete: u.SelfDelete,
|
||||||
|
DeleteReason: u.DeleteReason,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, s.DB, mu.User.ID, mu.User.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating user %v with misskey info: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no user found, so save a ticket + save their Misskey info in Redis
|
||||||
|
ticket := RandBase64(32)
|
||||||
|
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
|
HasAccount: false,
|
||||||
|
Fediverse: mu.User.Username,
|
||||||
|
Ticket: ticket,
|
||||||
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := Decode[fediLinkRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting instance application")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Fediverse != nil {
|
||||||
|
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := new(partialMisskeyAccount)
|
||||||
|
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting misskey user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mu.ID == "" {
|
||||||
|
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from misskey")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
req, err := Decode[fediSignupRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite && req.InviteCode == "" {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting instance application")
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
mu := new(partialMisskeyAccount)
|
||||||
|
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting misskey user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if mu.ID == "" {
|
||||||
|
log.Errorf("creating user with name %q: user ID was empty", req.Username)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from misskey")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite {
|
||||||
|
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking and invalidating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete sign up ticket
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "misskey:"+req.Ticket))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting signup ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit transaction
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user
|
||||||
|
render.JSON(w, r, signupResponse{
|
||||||
|
User: *dbUserToUserResponse(u, nil),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) noAppMisskeyURL(ctx context.Context, w http.ResponseWriter, r *http.Request, softwareName, instance string) error {
|
||||||
|
log.Debugf("creating application on misskey-compatible instance %q", instance)
|
||||||
|
|
||||||
|
b, err := json.Marshal(misskeyAppRequest{
|
||||||
|
Name: "pronouns.cc (+" + s.BaseURL + ")",
|
||||||
|
Description: "pronouns.cc on " + s.BaseURL,
|
||||||
|
CallbackURL: s.BaseURL + "/auth/login/misskey/" + instance,
|
||||||
|
Permission: []string{"read:account"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("marshaling app json: %v", err)
|
||||||
|
return errors.Wrap(err, "marshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/app/create", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating POST apps request for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "creating POST apps request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("sending POST apps request for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "sending POST apps request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("reading response for request: %v", err)
|
||||||
|
return errors.Wrap(err, "reading response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ma misskeyApp
|
||||||
|
err = json.Unmarshal(jb, &ma)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling misskey app")
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ID, ma.Secret)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving app for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "creating app")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, url, err := s.misskeyURL(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("generating URL for misskey %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "generating URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, FediResponse{
|
||||||
|
URL: url,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type misskeyAppRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permission []string `json:"permission"`
|
||||||
|
CallbackURL string `json:"callbackUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type misskeyApp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) misskeyURL(ctx context.Context, app db.FediverseApp) (token, url string, err error) {
|
||||||
|
genSession := struct {
|
||||||
|
AppSecret string `json:"appSecret"`
|
||||||
|
}{AppSecret: app.ClientSecret}
|
||||||
|
|
||||||
|
b, err := json.Marshal(genSession)
|
||||||
|
if err != nil {
|
||||||
|
return token, url, errors.Wrap(err, "marshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+app.Instance+"/api/auth/session/generate", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating POST session request for %q: %v", app.Instance, err)
|
||||||
|
return token, url, errors.Wrap(err, "creating POST apps request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("sending POST session request for %q: %v", app.Instance, err)
|
||||||
|
return token, url, errors.Wrap(err, "sending POST apps request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("reading response for request: %v", err)
|
||||||
|
return token, url, errors.Wrap(err, "reading response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var genSessionResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(jb, &genSessionResp)
|
||||||
|
if err != nil {
|
||||||
|
return token, url, errors.Wrap(err, "unmarshaling misskey response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return genSessionResp.Token, genSessionResp.URL, nil
|
||||||
|
}
|
95
backend/routes/auth/fedi_nodeinfo.go
Normal file
95
backend/routes/auth/fedi_nodeinfo.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const errNoNodeinfoURL = errors.Sentinel("no valid nodeinfo rel found")
|
||||||
|
|
||||||
|
// nodeinfo queries an instance's nodeinfo and returns the software name.
|
||||||
|
func nodeinfo(ctx context.Context, instance string) (softwareName string, err error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+instance+"/.well-known/nodeinfo", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "creating .well-known/nodeinfo request")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "sending .well-known/nodeinfo request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "reading .well-known/nodeinfo response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wkr wellKnownResponse
|
||||||
|
err = json.Unmarshal(jb, &wkr)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "unmarshaling .well-known/nodeinfo response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeinfoURL string
|
||||||
|
for _, link := range wkr.Links {
|
||||||
|
if link.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" {
|
||||||
|
nodeinfoURL = link.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodeinfoURL == "" {
|
||||||
|
return "", errNoNodeinfoURL
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequestWithContext(ctx, "GET", nodeinfoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "creating nodeinfo request")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "sending nodeinfo request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err = io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "reading nodeinfo response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ni partialNodeinfo
|
||||||
|
err = json.Unmarshal(jb, &ni)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(err, "unmarshaling nodeinfo response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ni.Software.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type wellKnownResponse struct {
|
||||||
|
Links []wellKnownLink `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wellKnownLink struct {
|
||||||
|
Rel string `json:"rel"`
|
||||||
|
Href string `json:"href"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type partialNodeinfo struct {
|
||||||
|
Software nodeinfoSoftware `json:"software"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeinfoSoftware struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
136
backend/routes/auth/fediverse.go
Normal file
136
backend/routes/auth/fediverse.go
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FediResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
instance := r.FormValue("instance")
|
||||||
|
if instance == "" {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too many people tried using @username@fediverse.example despite the warning
|
||||||
|
if strings.Contains(instance, "@") {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL should only be the base URL, without username"}
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.FediverseApp(ctx, instance)
|
||||||
|
if err != nil {
|
||||||
|
return s.noAppFediverseURL(ctx, w, r, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Misskey() {
|
||||||
|
_, url, err := s.misskeyURL(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "generating misskey URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, FediResponse{
|
||||||
|
URL: url,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.setCSRFState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting CSRF state")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, FediResponse{
|
||||||
|
URL: app.ClientConfig().AuthCodeURL(state),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r *http.Request, instance string) error {
|
||||||
|
softwareName, err := nodeinfo(ctx, instance)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("querying instance %q nodeinfo: %v", instance, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch softwareName {
|
||||||
|
case "misskey", "foundkey", "calckey":
|
||||||
|
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
|
||||||
|
case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial":
|
||||||
|
case "glitchcafe":
|
||||||
|
// plural.cafe (potentially other instances too?) runs Mastodon but changes the software name
|
||||||
|
// changing it back to mastodon here for consistency
|
||||||
|
softwareName = "mastodon"
|
||||||
|
default:
|
||||||
|
return server.APIError{Code: server.ErrUnsupportedInstance}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("creating application on mastodon-compatible instance %q", instance)
|
||||||
|
|
||||||
|
formData := url.Values{
|
||||||
|
"client_name": {"pronouns.cc (+" + s.BaseURL + ")"},
|
||||||
|
"redirect_uris": {s.BaseURL + "/auth/login/mastodon/" + instance},
|
||||||
|
"scopes": {"read:accounts"},
|
||||||
|
"website": {s.BaseURL},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/v1/apps", strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating POST apps request for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "creating POST apps request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("sending POST apps request for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "sending POST apps request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("reading response for request: %v", err)
|
||||||
|
return errors.Wrap(err, "reading response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ma mastodonApplication
|
||||||
|
err = json.Unmarshal(jb, &ma)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling mastodon app")
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ClientID, ma.ClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving app for %q: %v", instance, err)
|
||||||
|
return errors.Wrap(err, "creating app")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := s.setCSRFState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting CSRF state")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, FediResponse{
|
||||||
|
URL: app.ClientConfig().AuthCodeURL(state),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mastodonApplication struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
}
|
386
backend/routes/auth/google.go
Normal file
386
backend/routes/auth/google.go
Normal file
|
@ -0,0 +1,386 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"google.golang.org/api/idtoken"
|
||||||
|
)
|
||||||
|
|
||||||
|
var googleOAuthConfig = oauth2.Config{
|
||||||
|
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
TokenURL: "https://oauth2.googleapis.com/token",
|
||||||
|
AuthStyle: oauth2.AuthStyleInParams,
|
||||||
|
},
|
||||||
|
Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type googleCallbackResponse struct {
|
||||||
|
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
|
Google string `json:"google,omitempty"` // username, for UI purposes
|
||||||
|
Ticket string `json:"ticket,omitempty"`
|
||||||
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
SelfDelete *bool `json:"self_delete,omitempty"`
|
||||||
|
DeleteReason *string `json:"delete_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type partialGoogleUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
decoded, err := Decode[oauthCallbackRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the state can't be validated, return
|
||||||
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := googleOAuthConfig
|
||||||
|
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google"
|
||||||
|
token, err := cfg.Exchange(r.Context(), decoded.Code)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("exchanging oauth code: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
||||||
|
}
|
||||||
|
rawToken := token.Extra("id_token")
|
||||||
|
if rawToken == nil {
|
||||||
|
log.Debug("id_token was nil")
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, ok := rawToken.(string)
|
||||||
|
if !ok {
|
||||||
|
log.Debug("id_token was not a string")
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
payload, err := idtoken.Validate(ctx, idToken, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting id token payload: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
googleID, ok := payload.Claims["sub"].(string)
|
||||||
|
if !ok {
|
||||||
|
log.Debug("id_token.claims.sub was not a string")
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
googleUsername, ok := payload.Claims["email"].(string)
|
||||||
|
if !ok {
|
||||||
|
log.Debug("id_token.claims.email was not a string")
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.GoogleUser(ctx, googleID)
|
||||||
|
if err == nil {
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
// store cancel delete token
|
||||||
|
token := undeleteToken()
|
||||||
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving undelete token: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, googleCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, []db.Field{}),
|
||||||
|
IsDeleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
SelfDelete: u.SelfDelete,
|
||||||
|
DeleteReason: u.DeleteReason,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating user %v with Google info: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, googleCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no user found, so save a ticket + save their Google info in Redis
|
||||||
|
ticket := RandBase64(32)
|
||||||
|
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, googleCallbackResponse{
|
||||||
|
HasAccount: false,
|
||||||
|
Google: googleUsername,
|
||||||
|
Ticket: ticket,
|
||||||
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := Decode[linkRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Google != nil {
|
||||||
|
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
gu := new(partialGoogleUser)
|
||||||
|
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting google user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gu.ID == "" {
|
||||||
|
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from google")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Google == nil {
|
||||||
|
return server.APIError{Code: server.ErrNotLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cannot unlink last auth provider
|
||||||
|
if u.NumProviders() <= 1 {
|
||||||
|
return server.APIError{Code: server.ErrLastProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UnlinkGoogle(ctx, s.DB)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
req, err := Decode[signupRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite && req.InviteCode == "" {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
gu := new(partialGoogleUser)
|
||||||
|
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting google user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if gu.ID == "" {
|
||||||
|
log.Errorf("creating user with name %q: user ID was empty", req.Username)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from google")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite {
|
||||||
|
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking and invalidating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete sign up ticket
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "google:"+req.Ticket))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting signup ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit transaction
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user
|
||||||
|
render.JSON(w, r, signupResponse{
|
||||||
|
User: *dbUserToUserResponse(u, nil),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
76
backend/routes/auth/invite.go
Normal file
76
backend/routes/auth/invite.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inviteResponse struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbInviteToResponse(i db.Invite) inviteResponse {
|
||||||
|
return inviteResponse{
|
||||||
|
Code: i.Code,
|
||||||
|
Created: i.Created,
|
||||||
|
Used: i.Used,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getInvites(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !s.RequireInvite {
|
||||||
|
return server.APIError{Code: server.ErrInvitesDisabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
is, err := s.DB.UserInvites(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user invites")
|
||||||
|
}
|
||||||
|
|
||||||
|
resps := make([]inviteResponse, len(is))
|
||||||
|
for i := range is {
|
||||||
|
resps[i] = dbInviteToResponse(is[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, resps)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !s.RequireInvite {
|
||||||
|
return server.APIError{Code: server.ErrInvitesDisabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, err := s.DB.CreateInvite(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrTooManyInvites {
|
||||||
|
return server.APIError{Code: server.ErrInviteLimitReached}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbInviteToResponse(inv))
|
||||||
|
return nil
|
||||||
|
}
|
45
backend/routes/auth/oauth.go
Normal file
45
backend/routes/auth/oauth.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// numStates is the number of CSRF states stored in Redis at any one time.
|
||||||
|
// This must be an integer.
|
||||||
|
const numStates = "1000"
|
||||||
|
|
||||||
|
// setCSRFState generates a random string to use as state, then stores that in Redis.
|
||||||
|
func (s *Server) setCSRFState(ctx context.Context) (string, error) {
|
||||||
|
state := RandBase64(32)
|
||||||
|
|
||||||
|
err := s.DB.MultiCmd(ctx,
|
||||||
|
radix.Cmd(nil, "LPUSH", "csrf", state),
|
||||||
|
radix.Cmd(nil, "LTRIM", "csrf", "0", numStates),
|
||||||
|
)
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCSRFState checks if the given state exists in Redis.
|
||||||
|
func (s *Server) validateCSRFState(ctx context.Context, state string) (matched bool, err error) {
|
||||||
|
var num int
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(&num, "LREM", "csrf", "1", state))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return num > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandBase64 returns a string of random bytes encoded in raw base 64.
|
||||||
|
func RandBase64(size int) string {
|
||||||
|
b := make([]byte, size)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
216
backend/routes/auth/routes.go
Normal file
216
backend/routes/auth/routes.go
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
|
||||||
|
RequireInvite bool
|
||||||
|
BaseURL string
|
||||||
|
|
||||||
|
hcaptchaSitekey string
|
||||||
|
hcaptchaSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Username string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
|
||||||
|
Discord *string `json:"discord"`
|
||||||
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
|
Tumblr *string `json:"tumblr"`
|
||||||
|
TumblrUsername *string `json:"tumblr_username"`
|
||||||
|
|
||||||
|
Google *string `json:"google"`
|
||||||
|
GoogleUsername *string `json:"google_username"`
|
||||||
|
|
||||||
|
Fediverse *string `json:"fediverse"`
|
||||||
|
FediverseUsername *string `json:"fediverse_username"`
|
||||||
|
FediverseInstance *string `json:"fediverse_instance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
||||||
|
return &userResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Bio: u.Bio,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Links: db.NotNull(u.Links),
|
||||||
|
Names: db.NotNull(u.Names),
|
||||||
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Tumblr: u.Tumblr,
|
||||||
|
TumblrUsername: u.TumblrUsername,
|
||||||
|
Google: u.Google,
|
||||||
|
GoogleUsername: u.GoogleUsername,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: u.FediverseInstance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{
|
||||||
|
Server: srv,
|
||||||
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
|
BaseURL: os.Getenv("BASE_URL"),
|
||||||
|
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
|
||||||
|
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
// check if username is taken
|
||||||
|
r.Get("/username", server.WrapHandler(s.usernameTaken))
|
||||||
|
|
||||||
|
// generate csrf token, returns all supported OAuth provider URLs
|
||||||
|
r.Post("/urls", server.WrapHandler(s.oauthURLs))
|
||||||
|
r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL))
|
||||||
|
|
||||||
|
r.Route("/discord", func(r chi.Router) {
|
||||||
|
// takes code + state, validates it, returns token OR discord signup ticket
|
||||||
|
r.Post("/callback", server.WrapHandler(s.discordCallback))
|
||||||
|
// takes discord signup ticket to register account
|
||||||
|
r.Post("/signup", server.WrapHandler(s.discordSignup))
|
||||||
|
// takes discord signup ticket to link to existing account
|
||||||
|
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.discordLink))
|
||||||
|
// removes discord link from existing account
|
||||||
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/tumblr", func(r chi.Router) {
|
||||||
|
r.Post("/callback", server.WrapHandler(s.tumblrCallback))
|
||||||
|
r.Post("/signup", server.WrapHandler(s.tumblrSignup))
|
||||||
|
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink))
|
||||||
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/google", func(r chi.Router) {
|
||||||
|
r.Post("/callback", server.WrapHandler(s.googleCallback))
|
||||||
|
r.Post("/signup", server.WrapHandler(s.googleSignup))
|
||||||
|
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink))
|
||||||
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/mastodon", func(r chi.Router) {
|
||||||
|
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
|
||||||
|
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
|
||||||
|
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.mastodonLink))
|
||||||
|
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/misskey", func(r chi.Router) {
|
||||||
|
r.Post("/callback", server.WrapHandler(s.misskeyCallback))
|
||||||
|
r.Post("/signup", server.WrapHandler(s.misskeySignup))
|
||||||
|
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.misskeyLink))
|
||||||
|
})
|
||||||
|
|
||||||
|
// invite routes
|
||||||
|
r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites))
|
||||||
|
r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite))
|
||||||
|
|
||||||
|
// tokens
|
||||||
|
r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens))
|
||||||
|
r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken))
|
||||||
|
r.With(server.MustAuth).Delete("/tokens", server.WrapHandler(s.deleteToken))
|
||||||
|
|
||||||
|
// cancel user delete
|
||||||
|
// uses a special token, so handled in the function itself
|
||||||
|
r.Get("/cancel-delete", server.WrapHandler(s.cancelDelete))
|
||||||
|
// force user delete
|
||||||
|
// uses a special token (same as above)
|
||||||
|
r.Get("/force-delete", server.WrapHandler(s.forceDelete))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthURLsRequest struct {
|
||||||
|
CallbackDomain string `json:"callback_domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthURLsResponse struct {
|
||||||
|
Discord string `json:"discord,omitempty"`
|
||||||
|
Tumblr string `json:"tumblr,omitempty"`
|
||||||
|
Google string `json:"google,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
req, err := Decode[oauthURLsRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate CSRF state
|
||||||
|
state, err := s.setCSRFState(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting CSRF state")
|
||||||
|
}
|
||||||
|
var resp oauthURLsResponse
|
||||||
|
|
||||||
|
if discordOAuthConfig.ClientID != "" {
|
||||||
|
discordCfg := discordOAuthConfig
|
||||||
|
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
|
||||||
|
resp.Discord = discordCfg.AuthCodeURL(state) + "&prompt=none"
|
||||||
|
}
|
||||||
|
if tumblrOAuthConfig.ClientID != "" {
|
||||||
|
tumblrCfg := tumblrOAuthConfig
|
||||||
|
tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr"
|
||||||
|
resp.Tumblr = tumblrCfg.AuthCodeURL(state)
|
||||||
|
}
|
||||||
|
if googleOAuthConfig.ClientID != "" {
|
||||||
|
googleCfg := googleOAuthConfig
|
||||||
|
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
|
||||||
|
resp.Google = googleCfg.AuthCodeURL(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, resp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) usernameTaken(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
type Response struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Taken bool `json:"taken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("username")
|
||||||
|
if name == "" {
|
||||||
|
render.JSON(w, r, Response{
|
||||||
|
Valid: false,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(r.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, Response{
|
||||||
|
Valid: valid,
|
||||||
|
Taken: taken,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
125
backend/routes/auth/tokens.go
Normal file
125
backend/routes/auth/tokens.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getTokenResponse struct {
|
||||||
|
TokenID xid.ID `json:"id"`
|
||||||
|
APIOnly bool `json:"api_only"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbTokenToGetResponse(t db.Token) getTokenResponse {
|
||||||
|
return getTokenResponse{
|
||||||
|
TokenID: t.TokenID,
|
||||||
|
APIOnly: t.APIOnly,
|
||||||
|
ReadOnly: t.ReadOnly,
|
||||||
|
Created: t.Created,
|
||||||
|
Expires: t.Expires,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := s.DB.Tokens(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
resps := make([]getTokenResponse, len(tokens))
|
||||||
|
for i := range tokens {
|
||||||
|
resps[i] = dbTokenToGetResponse(tokens[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, resps)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "invalidating tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
TokenID xid.ID `json:"id"`
|
||||||
|
APIOnly bool `json:"api_only"`
|
||||||
|
ReadOnly bool `json:"read_only"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting me user")
|
||||||
|
}
|
||||||
|
|
||||||
|
readOnly := r.FormValue("read_only") == "true"
|
||||||
|
tokenID := xid.New()
|
||||||
|
tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, createTokenResponse{
|
||||||
|
Token: tokenStr,
|
||||||
|
TokenID: t.TokenID,
|
||||||
|
APIOnly: t.APIOnly,
|
||||||
|
ReadOnly: t.ReadOnly,
|
||||||
|
Created: t.Created,
|
||||||
|
Expires: t.Expires,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
419
backend/routes/auth/tumblr.go
Normal file
419
backend/routes/auth/tumblr.go
Normal file
|
@ -0,0 +1,419 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tumblrOAuthConfig = oauth2.Config{
|
||||||
|
ClientID: os.Getenv("TUMBLR_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://www.tumblr.com/oauth2/authorize",
|
||||||
|
TokenURL: "https://api.tumblr.com/v2/oauth2/token",
|
||||||
|
AuthStyle: oauth2.AuthStyleInParams,
|
||||||
|
},
|
||||||
|
Scopes: []string{"basic"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type partialTumblrResponse struct {
|
||||||
|
Meta struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Message string `json:"msg"`
|
||||||
|
} `json:"meta"`
|
||||||
|
Response struct {
|
||||||
|
User struct {
|
||||||
|
Blogs []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"blogs"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tumblrUserInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tumblrCallbackResponse struct {
|
||||||
|
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set
|
||||||
|
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
|
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
|
||||||
|
Ticket string `json:"ticket,omitempty"`
|
||||||
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
SelfDelete *bool `json:"self_delete,omitempty"`
|
||||||
|
DeleteReason *string `json:"delete_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
decoded, err := Decode[oauthCallbackRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the state can't be validated, return
|
||||||
|
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidState}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := tumblrOAuthConfig
|
||||||
|
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr"
|
||||||
|
token, err := cfg.Exchange(r.Context(), decoded.Code)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("exchanging oauth code: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidOAuthCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating user/info request")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
token.SetAuthHeader(req)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "sending user/info request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return errors.New("response had status code < 200 or >= 400")
|
||||||
|
}
|
||||||
|
|
||||||
|
jb, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "reading user/info response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tr partialTumblrResponse
|
||||||
|
err = json.Unmarshal(jb, &tr)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshaling user/info response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tumblrName, tumblrID string
|
||||||
|
for _, blog := range tr.Response.User.Blogs {
|
||||||
|
if blog.Primary {
|
||||||
|
tumblrName = blog.Name
|
||||||
|
tumblrID = blog.UUID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tumblrID == "" {
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.TumblrUser(ctx, tumblrID)
|
||||||
|
if err == nil {
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
// store cancel delete token
|
||||||
|
token := undeleteToken()
|
||||||
|
err = s.saveUndeleteToken(ctx, u.ID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("saving undelete token: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, tumblrCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, []db.Field{}),
|
||||||
|
IsDeleted: true,
|
||||||
|
DeletedAt: u.DeletedAt,
|
||||||
|
SelfDelete: u.SelfDelete,
|
||||||
|
DeleteReason: u.DeleteReason,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromTumblr(ctx, s.DB, tumblrID, tumblrName)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating user %v with Tumblr info: %v", u.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "querying fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, tumblrCallbackResponse{
|
||||||
|
HasAccount: true,
|
||||||
|
Token: token,
|
||||||
|
User: dbUserToUserResponse(u, fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
} else if err != db.ErrUserNotFound { // internal error
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no user found, so save a ticket + save their Tumblr info in Redis
|
||||||
|
ticket := RandBase64(32)
|
||||||
|
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, tumblrCallbackResponse{
|
||||||
|
HasAccount: false,
|
||||||
|
Tumblr: tumblrName,
|
||||||
|
Ticket: ticket,
|
||||||
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := Decode[linkRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Tumblr != nil {
|
||||||
|
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
tui := new(tumblrUserInfo)
|
||||||
|
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting tumblr user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tui.ID == "" {
|
||||||
|
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from tumblr")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
// only site tokens can be used for this endpoint
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Tumblr == nil {
|
||||||
|
return server.APIError{Code: server.ErrNotLinked}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cannot unlink last auth provider
|
||||||
|
if u.NumProviders() <= 1 {
|
||||||
|
return server.APIError{Code: server.ErrLastProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UnlinkTumblr(ctx, s.DB)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
req, err := Decode[signupRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite && req.InviteCode == "" {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
tui := new(tumblrUserInfo)
|
||||||
|
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting tumblr user for ticket: %v", err)
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "creating user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tui.ID == "" {
|
||||||
|
log.Errorf("creating user with name %q: user ID was empty", req.Username)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user from tumblr")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.RequireInvite {
|
||||||
|
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "checking and invalidating invite")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return server.APIError{Code: server.ErrInviteRequired}
|
||||||
|
}
|
||||||
|
|
||||||
|
if used {
|
||||||
|
return server.APIError{Code: server.ErrInviteAlreadyUsed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete sign up ticket
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+req.Ticket))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting signup ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// commit transaction
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// create token
|
||||||
|
// TODO: implement user + token permissions
|
||||||
|
tokenID := xid.New()
|
||||||
|
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// save token to database
|
||||||
|
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "saving token to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// return user
|
||||||
|
render.JSON(w, r, signupResponse{
|
||||||
|
User: *dbUserToUserResponse(u, nil),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
114
backend/routes/auth/undelete.go
Normal file
114
backend/routes/auth/undelete.go
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
token := r.Header.Get("X-Delete-Token")
|
||||||
|
if token == "" {
|
||||||
|
return server.APIError{Code: server.ErrForbidden}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.getUndeleteToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting undelete token: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrNotFound} // assume invalid token
|
||||||
|
}
|
||||||
|
|
||||||
|
// only self deleted users can undelete themselves
|
||||||
|
u, err := s.DB.User(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user: %v", err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
if !*u.SelfDelete {
|
||||||
|
return server.APIError{Code: server.ErrForbidden}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.UndoDeleteUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("executing undelete query: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func undeleteToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token string) error {
|
||||||
|
err := s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", "undelete:"+token, userID.String(), "EX", "3600"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting undelete key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) {
|
||||||
|
var idString string
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token))
|
||||||
|
if err != nil {
|
||||||
|
return userID, errors.Wrap(err, "getting undelete key")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err = xid.FromString(idString)
|
||||||
|
if err != nil {
|
||||||
|
return userID, errors.Wrap(err, "parsing ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "undelete:"+token))
|
||||||
|
if err != nil {
|
||||||
|
return userID, errors.Wrap(err, "deleting undelete key")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) forceDelete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
token := r.Header.Get("X-Delete-Token")
|
||||||
|
if token == "" {
|
||||||
|
return server.APIError{Code: server.ErrForbidden}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.getUndeleteToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting delete token: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrNotFound} // assume invalid token
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.CleanUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("cleaning user data: %v", err)
|
||||||
|
return errors.Wrap(err, "cleaning user")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.ForceDeleteUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("force deleting user: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
183
backend/routes/bot/bot.go
Normal file
183
backend/routes/bot/bot.go
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
*server.Server
|
||||||
|
|
||||||
|
publicKey ed25519.PublicKey
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) UserAvatarURL(u db.User) string {
|
||||||
|
if u.Avatar == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY"))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := &Bot{
|
||||||
|
Server: srv,
|
||||||
|
publicKey: publicKey,
|
||||||
|
baseURL: os.Getenv("BASE_URL"),
|
||||||
|
}
|
||||||
|
|
||||||
|
r.HandleFunc("/interactions", b.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !discordgo.VerifyInteraction(r, bot.publicKey) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev *discordgo.InteractionCreate
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// we can always respond to ping with pong
|
||||||
|
if ev.Type == discordgo.InteractionPing {
|
||||||
|
log.Debug("received ping interaction")
|
||||||
|
render.JSON(w, r, discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponsePong,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.Type != discordgo.InteractionApplicationCommand {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := ev.ApplicationCommandData()
|
||||||
|
|
||||||
|
switch data.Name {
|
||||||
|
case "Show user's pronouns":
|
||||||
|
bot.userPronouns(w, r, ev)
|
||||||
|
case "Show author's pronouns":
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var du *discordgo.User
|
||||||
|
for _, user := range ev.ApplicationCommandData().Resolved.Users {
|
||||||
|
du = user
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if du == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := bot.DB.DiscordUser(ctx, du.ID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrUserNotFound {
|
||||||
|
respond(w, r, &discordgo.MessageEmbed{
|
||||||
|
Description: du.String() + " does not have any pronouns set.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("getting discord user: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarURL := du.AvatarURL("")
|
||||||
|
if url := bot.UserAvatarURL(u); url != "" {
|
||||||
|
avatarURL = url
|
||||||
|
}
|
||||||
|
name := u.Username
|
||||||
|
if u.DisplayName != nil {
|
||||||
|
name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username)
|
||||||
|
}
|
||||||
|
url := bot.baseURL
|
||||||
|
if url != "" {
|
||||||
|
url += "/@" + u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &discordgo.MessageEmbed{
|
||||||
|
Author: &discordgo.MessageEmbedAuthor{
|
||||||
|
Name: name,
|
||||||
|
IconURL: avatarURL,
|
||||||
|
URL: url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Bio != nil {
|
||||||
|
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: "Bio",
|
||||||
|
Value: *u.Bio,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := bot.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, r, e)
|
||||||
|
|
||||||
|
log.Errorf("getting user fields: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
var favs []db.FieldEntry
|
||||||
|
|
||||||
|
for _, e := range field.Entries {
|
||||||
|
if e.Status == "favourite" {
|
||||||
|
favs = append(favs, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(favs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var value string
|
||||||
|
for _, fav := range favs {
|
||||||
|
if len(fav.Value) > 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
value += fav.Value + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
|
||||||
|
Name: field.Name,
|
||||||
|
Value: value,
|
||||||
|
Inline: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(w, r, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) {
|
||||||
|
render.JSON(w, r, discordgo.InteractionResponse{
|
||||||
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
Data: &discordgo.InteractionResponseData{
|
||||||
|
Embeds: embeds,
|
||||||
|
Flags: discordgo.MessageFlagsEphemeral,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
230
backend/routes/member/create_member.go
Normal file
230
backend/routes/member/create_member.go
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateMemberRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
memberCount, err := s.DB.MemberCount(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting member count")
|
||||||
|
}
|
||||||
|
if memberCount > db.MaxMemberCount {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberLimitReached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmr CreateMemberRequest
|
||||||
|
err = render.Decode(r, &cmr)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(server.APIError); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove whitespace from all fields
|
||||||
|
cmr.Name = strings.TrimSpace(cmr.Name)
|
||||||
|
cmr.Bio = strings.TrimSpace(cmr.Bio)
|
||||||
|
if cmr.DisplayName != nil {
|
||||||
|
*cmr.DisplayName = strings.TrimSpace(*cmr.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate everything
|
||||||
|
if cmr.Name == "" {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name may not be empty",
|
||||||
|
}
|
||||||
|
} else if len(cmr.Name) > 100 {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name may not be longer than 100 characters",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !db.MemberNameValid(cmr.Name) {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.StringLength(&cmr.Name) > db.MaxMemberNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(&cmr.Name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(cmr.DisplayName) > db.MaxDisplayNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(cmr.DisplayName)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(&cmr.Bio) > db.MaxUserBioLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(&cmr.Bio)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "starting transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == db.ErrMemberNameInUse {
|
||||||
|
return server.APIError{Code: server.ErrMemberNameInUse}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// set names, pronouns, fields
|
||||||
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Names = cmr.Names
|
||||||
|
m.Pronouns = cmr.Pronouns
|
||||||
|
|
||||||
|
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting fields for member %v: %v", m.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmr.Avatar != "" {
|
||||||
|
webp, jpg, err := s.DB.ConvertAvatar(cmr.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar data URI",
|
||||||
|
}
|
||||||
|
} else if err == db.ErrInvalidContentType {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar content type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("converting member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting avatar urls in db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last active time
|
||||||
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type validator interface {
|
||||||
|
Validate(custom db.CustomPreferences) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSlicePtr validates a slice of validators.
|
||||||
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
|
if slice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
max := db.MaxFields
|
||||||
|
if typ != "field" {
|
||||||
|
max = db.FieldEntriesLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// max 25 fields
|
||||||
|
if len(*slice) > max {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate all fields
|
||||||
|
for i, pronouns := range *slice {
|
||||||
|
if s := pronouns.Validate(custom); s != "" {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
64
backend/routes/member/delete_member.go
Normal file
64
backend/routes/member/delete_member.go
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.UserID != claims.UserID {
|
||||||
|
return server.APIError{Code: server.ErrNotOwnMember}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.DeleteMember(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Avatar != nil {
|
||||||
|
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting member avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last active time
|
||||||
|
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
197
backend/routes/member/get_member.go
Normal file
197
backend/routes/member/get_member.go
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetMemberResponse struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
Flags []db.MemberFlag `json:"flags"`
|
||||||
|
|
||||||
|
User PartialUser `json:"user"`
|
||||||
|
|
||||||
|
Unlisted *bool `json:"unlisted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
|
||||||
|
r := GetMemberResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
SID: m.SID,
|
||||||
|
Name: m.Name,
|
||||||
|
DisplayName: m.DisplayName,
|
||||||
|
Bio: m.Bio,
|
||||||
|
Avatar: m.Avatar,
|
||||||
|
Links: db.NotNull(m.Links),
|
||||||
|
|
||||||
|
Names: db.NotNull(m.Names),
|
||||||
|
Pronouns: db.NotNull(m.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
Flags: flags,
|
||||||
|
|
||||||
|
User: PartialUser{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if isOwnMember {
|
||||||
|
r.Unlisted = &m.Unlisted
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartialUser struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
Username string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwnMember := false
|
||||||
|
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
||||||
|
isOwnMember = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrUserNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwnMember := false
|
||||||
|
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
||||||
|
isOwnMember = true
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting me user")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.UserMember(ctx, claims.UserID, chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrMemberNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.MemberFields(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
|
||||||
|
if id, err := xid.FromString(userRef); err != nil {
|
||||||
|
u, err := s.DB.User(ctx, id)
|
||||||
|
if err == nil {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.DB.Username(ctx, userRef)
|
||||||
|
}
|
92
backend/routes/member/get_members.go
Normal file
92
backend/routes/member/get_members.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memberListResponse struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Unlisted bool `json:"unlisted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
|
||||||
|
resps := make([]memberListResponse, len(ms))
|
||||||
|
for i := range ms {
|
||||||
|
resps[i] = memberListResponse{
|
||||||
|
ID: ms[i].ID,
|
||||||
|
SID: ms[i].SID,
|
||||||
|
Name: ms[i].Name,
|
||||||
|
DisplayName: ms[i].DisplayName,
|
||||||
|
Bio: ms[i].Bio,
|
||||||
|
Avatar: ms[i].Avatar,
|
||||||
|
Links: db.NotNull(ms[i].Links),
|
||||||
|
Names: db.NotNull(ms[i].Names),
|
||||||
|
Pronouns: db.NotNull(ms[i].Pronouns),
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSelf {
|
||||||
|
resps[i].Unlisted = ms[i].Unlisted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrUserNotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelf := false
|
||||||
|
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
||||||
|
isSelf = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.ListPrivate && !isSelf {
|
||||||
|
return server.APIError{Code: server.ErrMemberListPrivate}
|
||||||
|
}
|
||||||
|
|
||||||
|
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, membersToMemberList(ms, isSelf))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, membersToMemberList(ms, true))
|
||||||
|
return nil
|
||||||
|
}
|
368
backend/routes/member/patch_member.go
Normal file
368
backend/routes/member/patch_member.go
Normal file
|
@ -0,0 +1,368 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatchMemberRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Links *[]string `json:"links"`
|
||||||
|
Names *[]db.FieldEntry `json:"names"`
|
||||||
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields *[]db.Field `json:"fields"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Unlisted *bool `json:"unlisted"`
|
||||||
|
Flags *[]xid.ID `json:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.UserID != claims.UserID {
|
||||||
|
return server.APIError{Code: server.ErrNotOwnMember}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PatchMemberRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that *something* is set
|
||||||
|
if req.DisplayName == nil &&
|
||||||
|
req.Name == nil &&
|
||||||
|
req.Bio == nil &&
|
||||||
|
req.Unlisted == nil &&
|
||||||
|
req.Links == nil &&
|
||||||
|
req.Fields == nil &&
|
||||||
|
req.Names == nil &&
|
||||||
|
req.Pronouns == nil &&
|
||||||
|
req.Avatar == nil &&
|
||||||
|
req.Flags == nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Data must not be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim whitespace from strings
|
||||||
|
if req.Name != nil {
|
||||||
|
*req.Name = strings.TrimSpace(*req.Name)
|
||||||
|
}
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
*req.DisplayName = strings.TrimSpace(*req.DisplayName)
|
||||||
|
}
|
||||||
|
if req.Bio != nil {
|
||||||
|
*req.Bio = strings.TrimSpace(*req.Bio)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil && *req.Name == "" {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name must not be empty",
|
||||||
|
}
|
||||||
|
} else if req.Name != nil && len(*req.Name) > 100 {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Name may not be longer than 100 characters",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate member name
|
||||||
|
if req.Name != nil {
|
||||||
|
if !db.MemberNameValid(*req.Name) {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate display name/bio
|
||||||
|
if common.StringLength(req.Name) > db.MaxMemberNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(req.Name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(req.Bio) > db.MaxUserBioLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate links
|
||||||
|
if req.Links != nil {
|
||||||
|
if len(*req.Links) > db.MaxUserLinksLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, link := range *req.Links {
|
||||||
|
if len(link) > db.MaxLinkLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate flag length
|
||||||
|
if req.Flags != nil {
|
||||||
|
if len(*req.Flags) > db.MaxPrideFlags {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update avatar
|
||||||
|
var avatarHash *string = nil
|
||||||
|
if req.Avatar != nil {
|
||||||
|
if *req.Avatar == "" {
|
||||||
|
if m.Avatar != nil {
|
||||||
|
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting member avatar: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avatarHash = req.Avatar
|
||||||
|
} else {
|
||||||
|
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar data URI",
|
||||||
|
}
|
||||||
|
} else if err == db.ErrInvalidContentType {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar content type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("converting member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("uploading member avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarHash = &hash
|
||||||
|
|
||||||
|
// delete current avatar if member has one
|
||||||
|
if m.Avatar != nil {
|
||||||
|
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting existing avatar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start transaction
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
|
||||||
|
if err != nil {
|
||||||
|
switch errors.Cause(err) {
|
||||||
|
case db.ErrNothingToUpdate:
|
||||||
|
case db.ErrMemberNameInUse:
|
||||||
|
return server.APIError{Code: server.ErrMemberNameInUse}
|
||||||
|
default:
|
||||||
|
log.Errorf("updating member: %v", err)
|
||||||
|
return errors.Wrap(err, "updating member in db")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Names != nil || req.Pronouns != nil {
|
||||||
|
names := m.Names
|
||||||
|
pronouns := m.Pronouns
|
||||||
|
|
||||||
|
if req.Names != nil {
|
||||||
|
names = *req.Names
|
||||||
|
}
|
||||||
|
if req.Pronouns != nil {
|
||||||
|
pronouns = *req.Pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting names for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Names = names
|
||||||
|
m.Pronouns = pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []db.Field
|
||||||
|
if req.Fields != nil {
|
||||||
|
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting fields for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fields = *req.Fields
|
||||||
|
} else {
|
||||||
|
fields, err = s.DB.MemberFields(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting fields for member %v: %v", id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update flags
|
||||||
|
if req.Flags != nil {
|
||||||
|
err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidFlagID {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("updating flags for member %v: %v", m.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last active time
|
||||||
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||||
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// echo the updated member back on success
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.UserID != claims.UserID {
|
||||||
|
return server.APIError{Code: server.ErrNotOwnMember}
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
|
||||||
|
return server.APIError{Code: server.ErrRerollingTooQuickly}
|
||||||
|
}
|
||||||
|
|
||||||
|
newID, err := s.DB.RerollMemberSID(ctx, u.ID, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating member SID")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.SID = newID
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, nil, nil, true))
|
||||||
|
return nil
|
||||||
|
}
|
36
backend/routes/member/routes.go
Normal file
36
backend/routes/member/routes.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package member
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{Server: srv}
|
||||||
|
|
||||||
|
// member list
|
||||||
|
r.Get("/users/{userRef}/members", server.WrapHandler(s.getUserMembers))
|
||||||
|
r.With(server.MustAuth).Get("/users/@me/members", server.WrapHandler(s.getMeMembers))
|
||||||
|
|
||||||
|
// user-scoped member lookup (including custom urls)
|
||||||
|
r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember))
|
||||||
|
r.With(server.MustAuth).Get("/users/@me/members/{memberRef}", server.WrapHandler(s.getMeMember))
|
||||||
|
|
||||||
|
r.Route("/members", func(r chi.Router) {
|
||||||
|
// any member by ID
|
||||||
|
r.Get("/{memberRef}", server.WrapHandler(s.getMember))
|
||||||
|
|
||||||
|
// create, edit, and delete members
|
||||||
|
r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember))
|
||||||
|
r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
|
||||||
|
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
|
||||||
|
|
||||||
|
// reroll member SID
|
||||||
|
r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID))
|
||||||
|
})
|
||||||
|
}
|
55
backend/routes/meta/meta.go
Normal file
55
backend/routes/meta/meta.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package meta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{Server: srv}
|
||||||
|
|
||||||
|
r.Get("/meta", server.WrapHandler(s.meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaResponse struct {
|
||||||
|
GitRepository string `json:"git_repository"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
Users MetaUsers `json:"users"`
|
||||||
|
Members int64 `json:"members"`
|
||||||
|
RequireInvite bool `json:"require_invite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaUsers struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
ActiveMonth int64 `json:"active_month"`
|
||||||
|
ActiveWeek int64 `json:"active_week"`
|
||||||
|
ActiveDay int64 `json:"active_day"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
|
||||||
|
|
||||||
|
render.JSON(w, r, MetaResponse{
|
||||||
|
GitRepository: server.Repository,
|
||||||
|
GitCommit: server.Revision,
|
||||||
|
Users: MetaUsers{
|
||||||
|
Total: numUsers,
|
||||||
|
ActiveMonth: activeMonth,
|
||||||
|
ActiveWeek: activeWeek,
|
||||||
|
ActiveDay: activeDay,
|
||||||
|
},
|
||||||
|
Members: numMembers,
|
||||||
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
119
backend/routes/mod/create_report.go
Normal file
119
backend/routes/mod/create_report.go
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxReasonLength = 2000
|
||||||
|
|
||||||
|
type CreateReportRequest struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrUserNotFound {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("getting user %v: %v", userID, err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateReportRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Reason) > MaxReasonLength {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating report for %v: %v", u.ID, err)
|
||||||
|
return errors.Wrap(err, "creating report")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
memberID, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("getting member %v: %v", memberID, err)
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, m.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user %v: %v", m.UserID, err)
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateReportRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Reason) > MaxReasonLength {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating report for %v: %v", m.ID, err)
|
||||||
|
return errors.Wrap(err, "creating report")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
84
backend/routes/mod/get_reports.go
Normal file
84
backend/routes/mod/get_reports.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) getReports(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
showClosed := r.FormValue("closed") == "true"
|
||||||
|
var before int
|
||||||
|
if s := r.FormValue("before"); s != "" {
|
||||||
|
before, err = strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reports, err := s.DB.Reports(ctx, showClosed, before)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting reports: %v", err)
|
||||||
|
return errors.Wrap(err, "getting reports from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, reports)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getReportsByUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var before int
|
||||||
|
if s := r.FormValue("before"); s != "" {
|
||||||
|
before, err = strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reports, err := s.DB.ReportsByUser(ctx, userID, before)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting reports: %v", err)
|
||||||
|
return errors.Wrap(err, "getting reports from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, reports)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getReportsByReporter(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
var before int
|
||||||
|
if s := r.FormValue("before"); s != "" {
|
||||||
|
before, err = strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := xid.FromString(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reports, err := s.DB.ReportsByReporter(ctx, userID, before)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting reports: %v", err)
|
||||||
|
return errors.Wrap(err, "getting reports from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, reports)
|
||||||
|
return nil
|
||||||
|
}
|
113
backend/routes/mod/resolve_report.go
Normal file
113
backend/routes/mod/resolve_report.go
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resolveReportRequest struct {
|
||||||
|
Warn bool `json:"warn"`
|
||||||
|
Ban bool `json:"ban"`
|
||||||
|
Delete bool `json:"delete"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req resolveReportRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Reason == "" {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating transaction: %v", err)
|
||||||
|
return errors.Wrap(err, "creating transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
report, err := s.DB.Report(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrReportNotFound {
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
log.Errorf("getting report: %v", err)
|
||||||
|
return errors.Wrap(err, "getting report")
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.ResolvedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrReportAlreadyHandled}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.ResolveReport(ctx, tx, report.ID, claims.UserID, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("resolving report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Warn || req.Ban {
|
||||||
|
_, err = s.DB.CreateWarning(ctx, tx, report.UserID, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating warning: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Ban {
|
||||||
|
err = s.DB.DeleteUser(ctx, tx, report.UserID, false, req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("banning user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Delete {
|
||||||
|
err = s.DB.InvalidateAllTokens(ctx, tx, report.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "invalidating tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.CleanUser(ctx, report.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("cleaning user data: %v", err)
|
||||||
|
return errors.Wrap(err, "cleaning user")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.DeleteUserMembers(ctx, tx, report.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting members: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting members")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.ResetUser(ctx, tx, report.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("resetting user data: %v", err)
|
||||||
|
return errors.Wrap(err, "resetting user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
61
backend/routes/mod/routes.go
Normal file
61
backend/routes/mod/routes.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{Server: srv}
|
||||||
|
|
||||||
|
r.With(MustAdmin).Route("/admin", func(r chi.Router) {
|
||||||
|
r.Get("/reports", server.WrapHandler(s.getReports))
|
||||||
|
r.Get("/reports/by-user/{id}", server.WrapHandler(s.getReportsByUser))
|
||||||
|
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
|
||||||
|
|
||||||
|
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport))
|
||||||
|
r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport))
|
||||||
|
|
||||||
|
r.With(server.MustAuth).Get("/auth/warnings", server.WrapHandler(s.getWarnings))
|
||||||
|
r.With(server.MustAuth).Post("/auth/warnings/{id}/ack", server.WrapHandler(s.ackWarning))
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustAdmin(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := server.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusForbidden)
|
||||||
|
render.JSON(w, r, server.APIError{
|
||||||
|
Code: server.ErrForbidden,
|
||||||
|
Message: "Forbidden",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !claims.UserIsAdmin {
|
||||||
|
render.Status(r, http.StatusForbidden)
|
||||||
|
render.JSON(w, r, server.APIError{
|
||||||
|
Code: server.ErrForbidden,
|
||||||
|
Message: "Forbidden",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
67
backend/routes/mod/warnings.go
Normal file
67
backend/routes/mod/warnings.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package mod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type warning struct {
|
||||||
|
db.Warning
|
||||||
|
Read bool `json:"read"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbWarningsToResponse(ws []db.Warning) []warning {
|
||||||
|
out := make([]warning, len(ws))
|
||||||
|
for i := range ws {
|
||||||
|
out[i] = warning{ws[i], ws[i].ReadAt != nil}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getWarnings(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
showAll := r.FormValue("all") == "true"
|
||||||
|
|
||||||
|
warnings, err := s.DB.Warnings(ctx, claims.UserID, !showAll)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting warnings: %v", err)
|
||||||
|
return errors.Wrap(err, "getting warnings from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbWarningsToResponse(warnings))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.DB.AckWarning(ctx, claims.UserID, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("acknowledging warning: %v", err)
|
||||||
|
return errors.Wrap(err, "acknowledging warning")
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
42
backend/routes/user/delete_user.go
Normal file
42
backend/routes/user/delete_user.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "creating transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting user as deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "invalidating tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
82
backend/routes/user/export.go
Normal file
82
backend/routes/user/export.go
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasExport, err := s.DB.HasRecentExport(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("checking if user has recent export: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
if hasExport {
|
||||||
|
return server.APIError{Code: server.ErrRecentExport}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", s.ExporterPath+"/start/"+claims.UserID.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating start export request: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("executing start export request: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
|
log.Errorf("got non-%v code: %v", http.StatusAccepted, resp.StatusCode)
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrInternalServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dataExportResponse struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if claims.APIToken {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
|
}
|
||||||
|
|
||||||
|
de, err := s.DB.UserExport(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrNoExport {
|
||||||
|
return server.APIError{Code: server.ErrNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("getting export for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dataExportResponse{
|
||||||
|
Path: de.Path(),
|
||||||
|
CreatedAt: de.CreatedAt,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
239
backend/routes/user/flags.go
Normal file
239
backend/routes/user/flags.go
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "getting flags for account %v", claims.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, flags)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type postUserFlagRequest struct {
|
||||||
|
Flag string `json:"flag"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting current user flags")
|
||||||
|
}
|
||||||
|
if len(flags) >= db.MaxPrideFlags {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrFlagLimitReached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req postUserFlagRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove whitespace from all fields
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Description = strings.TrimSpace(req.Description)
|
||||||
|
|
||||||
|
if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "starting transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating flag: %v", err)
|
||||||
|
return errors.Wrap(err, "creating flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
webp, err := s.DB.ConvertFlag(req.Flag)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
|
||||||
|
} else if err == db.ErrFileTooLarge {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"}
|
||||||
|
}
|
||||||
|
return errors.Wrap(err, "converting flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteFlag(ctx, flag.ID, webp)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "writing flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setting hash for flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type patchUserFlagRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting current user flags")
|
||||||
|
}
|
||||||
|
if len(flags) >= db.MaxPrideFlags {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrFlagLimitReached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, flag := range flags {
|
||||||
|
if flag.ID == flagID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req patchUserFlagRequest
|
||||||
|
err = render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
*req.Name = strings.TrimSpace(*req.Name)
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
*req.Description = strings.TrimSpace(*req.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == nil && req.Description == nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "beginning transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "committing transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, flag)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flag, err := s.DB.UserFlag(ctx, flagID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrFlagNotFound {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting flag object")
|
||||||
|
}
|
||||||
|
if flag.UserID != claims.UserID {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "deleting flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
render.NoContent(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
213
backend/routes/user/get_user.go
Normal file
213
backend/routes/user/get_user.go
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetUserResponse struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Username string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
MemberTitle *string `json:"member_title"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
Members []PartialMember `json:"members"`
|
||||||
|
Fields []db.Field `json:"fields"`
|
||||||
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
|
Flags []db.UserFlag `json:"flags"`
|
||||||
|
Badges db.Badge `json:"badges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetMeResponse struct {
|
||||||
|
GetUserResponse
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
MaxInvites int `json:"max_invites"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
ListPrivate bool `json:"list_private"`
|
||||||
|
LastSIDReroll time.Time `json:"last_sid_reroll"`
|
||||||
|
|
||||||
|
Discord *string `json:"discord"`
|
||||||
|
DiscordUsername *string `json:"discord_username"`
|
||||||
|
|
||||||
|
Tumblr *string `json:"tumblr"`
|
||||||
|
TumblrUsername *string `json:"tumblr_username"`
|
||||||
|
|
||||||
|
Google *string `json:"google"`
|
||||||
|
GoogleUsername *string `json:"google_username"`
|
||||||
|
|
||||||
|
Fediverse *string `json:"fediverse"`
|
||||||
|
FediverseUsername *string `json:"fediverse_username"`
|
||||||
|
FediverseInstance *string `json:"fediverse_instance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartialMember struct {
|
||||||
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Names []db.FieldEntry `json:"names"`
|
||||||
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
||||||
|
resp := GetUserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
SID: u.SID,
|
||||||
|
Username: u.Username,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
Bio: u.Bio,
|
||||||
|
MemberTitle: u.MemberTitle,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Links: db.NotNull(u.Links),
|
||||||
|
Names: db.NotNull(u.Names),
|
||||||
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
|
Flags: flags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.IsAdmin {
|
||||||
|
resp.Badges |= db.BadgeAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Members = make([]PartialMember, len(members))
|
||||||
|
for i := range members {
|
||||||
|
resp.Members[i] = PartialMember{
|
||||||
|
ID: members[i].ID,
|
||||||
|
SID: members[i].SID,
|
||||||
|
Name: members[i].Name,
|
||||||
|
DisplayName: members[i].DisplayName,
|
||||||
|
Bio: members[i].Bio,
|
||||||
|
Avatar: members[i].Avatar,
|
||||||
|
Links: db.NotNull(members[i].Links),
|
||||||
|
Names: db.NotNull(members[i].Names),
|
||||||
|
Pronouns: db.NotNull(members[i].Pronouns),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
userRef := chi.URLParamFromCtx(ctx, "userRef")
|
||||||
|
|
||||||
|
var u db.User
|
||||||
|
if id, err := xid.FromString(userRef); err == nil {
|
||||||
|
u, err = s.DB.User(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user by ID: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.ID.IsNil() {
|
||||||
|
u, err = s.DB.Username(ctx, userRef)
|
||||||
|
if err == db.ErrUserNotFound {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrUserNotFound,
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
log.Errorf("Error getting user by username: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.DeletedAt != nil {
|
||||||
|
return server.APIError{Code: server.ErrUserNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelf := false
|
||||||
|
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
|
||||||
|
isSelf = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var members []db.Member
|
||||||
|
if !u.ListPrivate || isSelf {
|
||||||
|
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user members: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user fields: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := s.DB.UserMembers(ctx, u.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error getting user members: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, GetMeResponse{
|
||||||
|
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
||||||
|
CreatedAt: u.ID.Time(),
|
||||||
|
MaxInvites: u.MaxInvites,
|
||||||
|
IsAdmin: u.IsAdmin,
|
||||||
|
ListPrivate: u.ListPrivate,
|
||||||
|
LastSIDReroll: u.LastSIDReroll,
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Tumblr: u.Tumblr,
|
||||||
|
TumblrUsername: u.TumblrUsername,
|
||||||
|
Google: u.Google,
|
||||||
|
GoogleUsername: u.GoogleUsername,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: u.FediverseInstance,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
394
backend/routes/user/patch_user.go
Normal file
394
backend/routes/user/patch_user.go
Normal file
|
@ -0,0 +1,394 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PatchUserRequest struct {
|
||||||
|
Username *string `json:"name"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
MemberTitle *string `json:"member_title"`
|
||||||
|
Links *[]string `json:"links"`
|
||||||
|
Names *[]db.FieldEntry `json:"names"`
|
||||||
|
Pronouns *[]db.PronounEntry `json:"pronouns"`
|
||||||
|
Fields *[]db.Field `json:"fields"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
ListPrivate *bool `json:"list_private"`
|
||||||
|
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
|
||||||
|
Flags *[]xid.ID `json:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
||||||
|
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PatchUserRequest
|
||||||
|
err := render.Decode(r, &req)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get existing user, for comparison later
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting existing user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate that *something* is set
|
||||||
|
if req.Username == nil &&
|
||||||
|
req.DisplayName == nil &&
|
||||||
|
req.Bio == nil &&
|
||||||
|
req.MemberTitle == nil &&
|
||||||
|
req.ListPrivate == nil &&
|
||||||
|
req.Links == nil &&
|
||||||
|
req.Fields == nil &&
|
||||||
|
req.Names == nil &&
|
||||||
|
req.Pronouns == nil &&
|
||||||
|
req.Avatar == nil &&
|
||||||
|
req.CustomPreferences == nil &&
|
||||||
|
req.Flags == nil {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "Data must not be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate display name/bio
|
||||||
|
if common.StringLength(req.Username) > db.MaxUsernameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxUsernameLength, common.StringLength(req.Username)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if common.StringLength(req.Bio) > db.MaxUserBioLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Bio)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate links
|
||||||
|
if req.Links != nil {
|
||||||
|
if len(*req.Links) > db.MaxUserLinksLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, link := range *req.Links {
|
||||||
|
if len(link) > db.MaxLinkLength {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate flag length
|
||||||
|
if req.Flags != nil {
|
||||||
|
if len(*req.Flags) > db.MaxPrideFlags {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate custom preferences
|
||||||
|
if req.CustomPreferences != nil {
|
||||||
|
if count := len(*req.CustomPreferences); count > db.MaxFields {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range *req.CustomPreferences {
|
||||||
|
_, err := uuid.Parse(k)
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
|
||||||
|
}
|
||||||
|
if s := v.Validate(); s != "" {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: s}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customPreferences := u.CustomPreferences
|
||||||
|
if req.CustomPreferences != nil {
|
||||||
|
customPreferences = *req.CustomPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("name", req.Names, customPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil {
|
||||||
|
return *err
|
||||||
|
}
|
||||||
|
|
||||||
|
// update avatar
|
||||||
|
var avatarHash *string = nil
|
||||||
|
if req.Avatar != nil {
|
||||||
|
if *req.Avatar == "" {
|
||||||
|
if u.Avatar != nil {
|
||||||
|
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting user avatar: %v", err)
|
||||||
|
return errors.Wrap(err, "deleting avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avatarHash = req.Avatar
|
||||||
|
} else {
|
||||||
|
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidDataURI {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar data URI",
|
||||||
|
}
|
||||||
|
} else if err == db.ErrInvalidContentType {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: "invalid avatar content type",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("converting user avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("uploading user avatar: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
avatarHash = &hash
|
||||||
|
|
||||||
|
// delete current avatar if user has one
|
||||||
|
if u.Avatar != nil {
|
||||||
|
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("deleting existing avatar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start transaction
|
||||||
|
tx, err := s.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("creating transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// update username
|
||||||
|
if req.Username != nil && *req.Username != u.Username {
|
||||||
|
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case db.ErrUsernameTaken:
|
||||||
|
return server.APIError{Code: server.ErrUsernameTaken}
|
||||||
|
case db.ErrInvalidUsername:
|
||||||
|
return server.APIError{Code: server.ErrInvalidUsername}
|
||||||
|
default:
|
||||||
|
return errors.Wrap(err, "updating username")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
|
||||||
|
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
||||||
|
log.Errorf("updating user: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Names != nil || req.Pronouns != nil {
|
||||||
|
names := u.Names
|
||||||
|
pronouns := u.Pronouns
|
||||||
|
|
||||||
|
if req.Names != nil {
|
||||||
|
names = *req.Names
|
||||||
|
}
|
||||||
|
if req.Pronouns != nil {
|
||||||
|
pronouns = *req.Pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting names for member %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Names = names
|
||||||
|
u.Pronouns = pronouns
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []db.Field
|
||||||
|
if req.Fields != nil {
|
||||||
|
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fields = *req.Fields
|
||||||
|
} else {
|
||||||
|
fields, err = s.DB.UserFields(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update flags
|
||||||
|
if req.Flags != nil {
|
||||||
|
err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrInvalidFlagID {
|
||||||
|
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last active time
|
||||||
|
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fedi instance name if the user has a linked fedi account
|
||||||
|
var fediInstance *string
|
||||||
|
if u.FediverseAppID != nil {
|
||||||
|
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
|
||||||
|
if err == nil {
|
||||||
|
fediInstance = &app.Instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// echo the updated user back on success
|
||||||
|
render.JSON(w, r, GetMeResponse{
|
||||||
|
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
|
||||||
|
MaxInvites: u.MaxInvites,
|
||||||
|
IsAdmin: u.IsAdmin,
|
||||||
|
ListPrivate: u.ListPrivate,
|
||||||
|
LastSIDReroll: u.LastSIDReroll,
|
||||||
|
Discord: u.Discord,
|
||||||
|
DiscordUsername: u.DiscordUsername,
|
||||||
|
Tumblr: u.Tumblr,
|
||||||
|
TumblrUsername: u.TumblrUsername,
|
||||||
|
Google: u.Google,
|
||||||
|
GoogleUsername: u.GoogleUsername,
|
||||||
|
Fediverse: u.Fediverse,
|
||||||
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
FediverseInstance: fediInstance,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type validator interface {
|
||||||
|
Validate(custom db.CustomPreferences) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSlicePtr validates a slice of validators.
|
||||||
|
// If the slice is nil, a nil error is returned (assuming that the field is not required)
|
||||||
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
|
if slice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
max := db.MaxFields
|
||||||
|
if typ != "field" {
|
||||||
|
max = db.FieldEntriesLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// max 25 fields
|
||||||
|
if len(*slice) > max {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate all fields
|
||||||
|
for i, pronouns := range *slice {
|
||||||
|
if s := pronouns.Validate(custom); s != "" {
|
||||||
|
return &server.APIError{
|
||||||
|
Code: server.ErrBadRequest,
|
||||||
|
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting existing user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
|
||||||
|
return server.APIError{Code: server.ErrRerollingTooQuickly}
|
||||||
|
}
|
||||||
|
|
||||||
|
newID, err := s.DB.RerollUserSID(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "updating user SID")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.SID = newID
|
||||||
|
render.JSON(w, r, dbUserToResponse(u, nil, nil, nil))
|
||||||
|
return nil
|
||||||
|
}
|
41
backend/routes/user/routes.go
Normal file
41
backend/routes/user/routes.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
*server.Server
|
||||||
|
|
||||||
|
ExporterPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
|
s := &Server{
|
||||||
|
Server: srv,
|
||||||
|
ExporterPath: "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"),
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Route("/users", func(r chi.Router) {
|
||||||
|
r.Get("/{userRef}", server.WrapHandler(s.getUser))
|
||||||
|
|
||||||
|
r.With(server.MustAuth).Group(func(r chi.Router) {
|
||||||
|
r.Get("/@me", server.WrapHandler(s.getMeUser))
|
||||||
|
r.Patch("/@me", server.WrapHandler(s.patchUser))
|
||||||
|
r.Delete("/@me", server.WrapHandler(s.deleteUser))
|
||||||
|
|
||||||
|
r.Get("/@me/export/start", server.WrapHandler(s.startExport))
|
||||||
|
r.Get("/@me/export", server.WrapHandler(s.getExport))
|
||||||
|
|
||||||
|
r.Get("/@me/flags", server.WrapHandler(s.getUserFlags))
|
||||||
|
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
|
||||||
|
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
|
||||||
|
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
|
||||||
|
|
||||||
|
r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
89
backend/server/auth.go
Normal file
89
backend/server/auth.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maybeAuth is a globally-used middleware.
|
||||||
|
func (s *Server) maybeAuth(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if token == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := s.Auth.Claims(token)
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, errCodeStatuses[ErrInvalidToken])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrInvalidToken,
|
||||||
|
Message: errCodeMessages[ErrInvalidToken],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "valid" here refers to existence and expiry date, not whether the token is known
|
||||||
|
valid, err := s.DB.TokenValid(r.Context(), claims.UserID, claims.TokenID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("validating token for user %v: %v", claims.UserID, err)
|
||||||
|
render.Status(r, errCodeStatuses[ErrInternalServerError])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrInternalServerError,
|
||||||
|
Message: errCodeMessages[ErrInternalServerError],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
render.Status(r, errCodeStatuses[ErrInvalidToken])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrInvalidToken,
|
||||||
|
Message: errCodeMessages[ErrInvalidToken],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), ctxKeyClaims, claims)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustAuth makes a valid token required
|
||||||
|
func MustAuth(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, errCodeStatuses[ErrForbidden])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrForbidden,
|
||||||
|
Message: errCodeMessages[ErrForbidden],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsFromContext returns the auth.Claims in the context, if any.
|
||||||
|
func ClaimsFromContext(ctx context.Context) (auth.Claims, bool) {
|
||||||
|
v := ctx.Value(ctxKeyClaims)
|
||||||
|
if v == nil {
|
||||||
|
return auth.Claims{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := v.(auth.Claims)
|
||||||
|
return claims, ok
|
||||||
|
}
|
93
backend/server/auth/auth.go
Normal file
93
backend/server/auth/auth.go
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims are the claims used in a token.
|
||||||
|
type Claims struct {
|
||||||
|
UserID xid.ID `json:"sub"`
|
||||||
|
TokenID xid.ID `json:"jti"`
|
||||||
|
UserIsAdmin bool `json:"adm"`
|
||||||
|
|
||||||
|
// APIToken specifies whether this token was generated for the API or for the website.
|
||||||
|
// API tokens cannot perform some destructive actions, such as DELETE /users/@me.
|
||||||
|
APIToken bool `json:"atn"`
|
||||||
|
// TokenWrite specifies whether this token can be used for write actions.
|
||||||
|
// If set to false, this token can only be used for read actions.
|
||||||
|
TokenWrite bool `json:"twr"`
|
||||||
|
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verifier struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Verifier {
|
||||||
|
raw := os.Getenv("HMAC_KEY")
|
||||||
|
if raw == "" {
|
||||||
|
log.Fatal("$HMAC_KEY is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := base64.URLEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("$HMAC_KEY is not a valid base 64 string")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Verifier{key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken creates a token for the given user ID.
|
||||||
|
// It expires after three months.
|
||||||
|
func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) {
|
||||||
|
now := time.Now()
|
||||||
|
expires := now.Add(db.TokenExpiryTime)
|
||||||
|
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
|
UserID: userID,
|
||||||
|
TokenID: tokenID,
|
||||||
|
UserIsAdmin: isAdmin,
|
||||||
|
APIToken: isAPIToken,
|
||||||
|
TokenWrite: isWriteToken,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "pronouns",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expires),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return t.SignedString(v.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims parses the given token and returns its Claims.
|
||||||
|
// If the token is invalid, returns an error.
|
||||||
|
func (v *Verifier) Claims(token string) (c Claims, err error) {
|
||||||
|
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf(`unexpected signing method "%v"`, t.Header["alg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.key, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return c, errors.Wrap(err, "parsing token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c, ok := parsed.Claims.(*Claims); ok && parsed.Valid {
|
||||||
|
return *c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, fmt.Errorf("unknown claims type %T", parsed.Claims)
|
||||||
|
}
|
207
backend/server/errors.go
Normal file
207
backend/server/errors.go
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WrapHandler wraps a modified http.HandlerFunc into a stdlib-compatible one.
|
||||||
|
// The inner HandlerFunc additionally returns an error.
|
||||||
|
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := hn(w, r)
|
||||||
|
if err != nil {
|
||||||
|
// if the function returned an API error, just render that verbatim
|
||||||
|
// we can assume that it also logged the error (if that was needed)
|
||||||
|
if apiErr, ok := err.(APIError); ok {
|
||||||
|
apiErr.prepare()
|
||||||
|
|
||||||
|
render.Status(r, apiErr.Status)
|
||||||
|
render.JSON(w, r, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, we log the error and return an internal server error message
|
||||||
|
log.Errorf("error in http handler: %v", err)
|
||||||
|
|
||||||
|
apiErr := APIError{Code: ErrInternalServerError}
|
||||||
|
apiErr.prepare()
|
||||||
|
|
||||||
|
render.Status(r, apiErr.Status)
|
||||||
|
render.JSON(w, r, apiErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError is an object returned by the API when an error occurs.
|
||||||
|
// It implements the error interface and can be returned by handlers.
|
||||||
|
type APIError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
|
||||||
|
RatelimitReset *int `json:"ratelimit_reset,omitempty"`
|
||||||
|
|
||||||
|
// Status is set as the HTTP status code.
|
||||||
|
Status int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e APIError) Error() string {
|
||||||
|
if e.Message == "" {
|
||||||
|
e.Message = errCodeMessages[e.Code]
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Details != "" {
|
||||||
|
return fmt.Sprintf("%s (code: %d) (%s)", e.Message, e.Code, e.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s (code: %d)", e.Message, e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) prepare() {
|
||||||
|
if e.Status == 0 {
|
||||||
|
e.Status = errCodeStatuses[e.Code]
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Message == "" {
|
||||||
|
e.Message = errCodeMessages[e.Code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error code constants
|
||||||
|
const (
|
||||||
|
ErrBadRequest = 400
|
||||||
|
ErrForbidden = 403
|
||||||
|
ErrNotFound = 404
|
||||||
|
ErrMethodNotAllowed = 405
|
||||||
|
ErrTooManyRequests = 429
|
||||||
|
ErrInternalServerError = 500 // catch-all code for unknown errors
|
||||||
|
|
||||||
|
// Login/authorize error codes
|
||||||
|
ErrInvalidState = 1001
|
||||||
|
ErrInvalidOAuthCode = 1002
|
||||||
|
ErrInvalidToken = 1003 // a token was supplied, but it is invalid
|
||||||
|
ErrInviteRequired = 1004
|
||||||
|
ErrInvalidTicket = 1005 // invalid signup ticket
|
||||||
|
ErrInvalidUsername = 1006 // invalid username (when signing up)
|
||||||
|
ErrUsernameTaken = 1007 // username taken (when signing up)
|
||||||
|
ErrInvitesDisabled = 1008 // invites are disabled (unneeded)
|
||||||
|
ErrInviteLimitReached = 1009 // invite limit reached (when creating invites)
|
||||||
|
ErrInviteAlreadyUsed = 1010 // invite already used (when signing up)
|
||||||
|
ErrDeletionPending = 1011 // own user deletion pending, returned with undo code
|
||||||
|
ErrRecentExport = 1012 // latest export is too recent
|
||||||
|
ErrUnsupportedInstance = 1013 // unsupported fediverse software
|
||||||
|
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
||||||
|
ErrNotLinked = 1015 // user already doesn't have a linked account
|
||||||
|
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
|
||||||
|
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
|
||||||
|
|
||||||
|
// User-related error codes
|
||||||
|
ErrUserNotFound = 2001
|
||||||
|
ErrMemberListPrivate = 2002
|
||||||
|
ErrFlagLimitReached = 2003
|
||||||
|
ErrRerollingTooQuickly = 2004
|
||||||
|
|
||||||
|
// Member-related error codes
|
||||||
|
ErrMemberNotFound = 3001
|
||||||
|
ErrMemberLimitReached = 3002
|
||||||
|
ErrMemberNameInUse = 3003
|
||||||
|
ErrNotOwnMember = 3004
|
||||||
|
|
||||||
|
// General request error codes
|
||||||
|
ErrRequestTooBig = 4001
|
||||||
|
ErrMissingPermissions = 4002
|
||||||
|
|
||||||
|
// Moderation related error codes
|
||||||
|
ErrReportAlreadyHandled = 5001
|
||||||
|
ErrNotSelfDelete = 5002
|
||||||
|
)
|
||||||
|
|
||||||
|
var errCodeMessages = map[int]string{
|
||||||
|
ErrBadRequest: "Bad request",
|
||||||
|
ErrForbidden: "Forbidden",
|
||||||
|
ErrInternalServerError: "Internal server error",
|
||||||
|
ErrNotFound: "Not found",
|
||||||
|
ErrTooManyRequests: "Rate limit reached",
|
||||||
|
ErrMethodNotAllowed: "Method not allowed",
|
||||||
|
|
||||||
|
ErrInvalidState: "Invalid OAuth state",
|
||||||
|
ErrInvalidOAuthCode: "Invalid OAuth code",
|
||||||
|
ErrInvalidToken: "Supplied token was invalid",
|
||||||
|
ErrInviteRequired: "A valid invite code is required",
|
||||||
|
ErrInvalidTicket: "Invalid signup ticket",
|
||||||
|
ErrInvalidUsername: "Invalid username",
|
||||||
|
ErrUsernameTaken: "Username is already taken",
|
||||||
|
ErrInvitesDisabled: "Invites are disabled",
|
||||||
|
ErrInviteLimitReached: "Your account has reached the invite limit",
|
||||||
|
ErrInviteAlreadyUsed: "That invite code has already been used",
|
||||||
|
ErrDeletionPending: "Your account is pending deletion",
|
||||||
|
ErrRecentExport: "Your latest data export is less than 1 day old",
|
||||||
|
ErrUnsupportedInstance: "Unsupported instance software",
|
||||||
|
ErrAlreadyLinked: "Your account is already linked to an account of this type",
|
||||||
|
ErrNotLinked: "Your account is already not linked to an account of this type",
|
||||||
|
ErrLastProvider: "This is your account's only authentication provider",
|
||||||
|
ErrInvalidCaptcha: "Invalid or missing captcha response",
|
||||||
|
|
||||||
|
ErrUserNotFound: "User not found",
|
||||||
|
ErrMemberListPrivate: "This user's member list is private",
|
||||||
|
ErrFlagLimitReached: "Maximum number of pride flags reached",
|
||||||
|
ErrRerollingTooQuickly: "You can only reroll one short ID per hour.",
|
||||||
|
|
||||||
|
ErrMemberNotFound: "Member not found",
|
||||||
|
ErrMemberLimitReached: "Member limit reached",
|
||||||
|
ErrMemberNameInUse: "Member name already in use",
|
||||||
|
ErrNotOwnMember: "Not your member",
|
||||||
|
|
||||||
|
ErrRequestTooBig: "Request too big (max 2 MB)",
|
||||||
|
ErrMissingPermissions: "Your account or current token is missing required permissions for this action",
|
||||||
|
|
||||||
|
ErrReportAlreadyHandled: "Report has already been resolved",
|
||||||
|
ErrNotSelfDelete: "Cannot cancel deletion for an account deleted by a moderator",
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCodeStatuses = map[int]int{
|
||||||
|
ErrBadRequest: http.StatusBadRequest,
|
||||||
|
ErrForbidden: http.StatusForbidden,
|
||||||
|
ErrInternalServerError: http.StatusInternalServerError,
|
||||||
|
ErrNotFound: http.StatusNotFound,
|
||||||
|
ErrTooManyRequests: http.StatusTooManyRequests,
|
||||||
|
ErrMethodNotAllowed: http.StatusMethodNotAllowed,
|
||||||
|
|
||||||
|
ErrInvalidState: http.StatusBadRequest,
|
||||||
|
ErrInvalidOAuthCode: http.StatusForbidden,
|
||||||
|
ErrInvalidToken: http.StatusUnauthorized,
|
||||||
|
ErrInviteRequired: http.StatusBadRequest,
|
||||||
|
ErrInvalidTicket: http.StatusBadRequest,
|
||||||
|
ErrInvalidUsername: http.StatusBadRequest,
|
||||||
|
ErrUsernameTaken: http.StatusBadRequest,
|
||||||
|
ErrInvitesDisabled: http.StatusForbidden,
|
||||||
|
ErrInviteLimitReached: http.StatusForbidden,
|
||||||
|
ErrInviteAlreadyUsed: http.StatusBadRequest,
|
||||||
|
ErrDeletionPending: http.StatusBadRequest,
|
||||||
|
ErrRecentExport: http.StatusBadRequest,
|
||||||
|
ErrUnsupportedInstance: http.StatusBadRequest,
|
||||||
|
ErrAlreadyLinked: http.StatusBadRequest,
|
||||||
|
ErrNotLinked: http.StatusBadRequest,
|
||||||
|
ErrLastProvider: http.StatusBadRequest,
|
||||||
|
ErrInvalidCaptcha: http.StatusBadRequest,
|
||||||
|
|
||||||
|
ErrUserNotFound: http.StatusNotFound,
|
||||||
|
ErrMemberListPrivate: http.StatusForbidden,
|
||||||
|
ErrFlagLimitReached: http.StatusBadRequest,
|
||||||
|
ErrRerollingTooQuickly: http.StatusForbidden,
|
||||||
|
|
||||||
|
ErrMemberNotFound: http.StatusNotFound,
|
||||||
|
ErrMemberLimitReached: http.StatusBadRequest,
|
||||||
|
ErrMemberNameInUse: http.StatusBadRequest,
|
||||||
|
ErrNotOwnMember: http.StatusForbidden,
|
||||||
|
|
||||||
|
ErrRequestTooBig: http.StatusBadRequest,
|
||||||
|
ErrMissingPermissions: http.StatusForbidden,
|
||||||
|
|
||||||
|
ErrReportAlreadyHandled: http.StatusBadRequest,
|
||||||
|
ErrNotSelfDelete: http.StatusForbidden,
|
||||||
|
}
|
96
backend/server/rate/rate.go
Normal file
96
backend/server/rate/rate.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
package rate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
"github.com/gobwas/glob"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Limiter struct {
|
||||||
|
scopes []*scopedLimiter
|
||||||
|
defaultLimiter func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
windowLength time.Duration
|
||||||
|
options []httprate.Option
|
||||||
|
|
||||||
|
wildcardScopes []*scopedLimiter
|
||||||
|
frontendIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
type scopedLimiter struct {
|
||||||
|
Method, Pattern string
|
||||||
|
|
||||||
|
glob glob.Glob
|
||||||
|
handler func(http.Handler) http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLimiter(defaultLimit int, windowLength time.Duration, options ...httprate.Option) *Limiter {
|
||||||
|
return &Limiter{
|
||||||
|
windowLength: windowLength,
|
||||||
|
options: options,
|
||||||
|
defaultLimiter: httprate.Limit(defaultLimit, windowLength, options...),
|
||||||
|
frontendIP: os.Getenv("FRONTEND_IP"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Scope(method, pattern string, requestLimit int) error {
|
||||||
|
handler := httprate.Limit(requestLimit, l.windowLength, l.options...)
|
||||||
|
|
||||||
|
g, err := glob.Compile("/v*"+pattern, '/')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == "*" {
|
||||||
|
l.wildcardScopes = append(l.wildcardScopes, &scopedLimiter{method, pattern, g, handler})
|
||||||
|
} else {
|
||||||
|
l.scopes = append(l.scopes, &scopedLimiter{method, pattern, g, handler})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) Handler() func(http.Handler) http.Handler {
|
||||||
|
sort.Slice(l.scopes, func(i, j int) bool {
|
||||||
|
len1 := len(strings.Split(l.scopes[i].Pattern, "/"))
|
||||||
|
len2 := len(strings.Split(l.scopes[j].Pattern, "/"))
|
||||||
|
|
||||||
|
return len1 > len2
|
||||||
|
})
|
||||||
|
l.scopes = append(l.scopes, l.wildcardScopes...)
|
||||||
|
|
||||||
|
return l.handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Limiter) handle(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if l.frontendIP != "" {
|
||||||
|
ip, err := httprate.KeyByIP(r)
|
||||||
|
if err == nil && ip == l.frontendIP {
|
||||||
|
// frontend gets to bypass ratelimit
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range l.scopes {
|
||||||
|
if (r.Method == s.Method || s.Method == "*") && s.glob.Match(r.URL.Path) {
|
||||||
|
bucket := s.Pattern
|
||||||
|
if s.Method != "*" {
|
||||||
|
bucket = s.Method + " " + s.Pattern
|
||||||
|
}
|
||||||
|
w.Header().Set("X-RateLimit-Bucket", bucket)
|
||||||
|
|
||||||
|
s.handler(next).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("X-RateLimit-Bucket", "/")
|
||||||
|
l.defaultLimiter(next).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
153
backend/server/server.go
Normal file
153
backend/server/server.go
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server/rate"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
chiprometheus "github.com/toshi0607/chi-prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Revision is the git commit, filled at build time
|
||||||
|
var (
|
||||||
|
Revision = "[unknown]"
|
||||||
|
Tag = "[unknown]"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository is the URL of the git repository
|
||||||
|
const Repository = "https://codeberg.org/pronounscc/pronouns.cc"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Router *chi.Mux
|
||||||
|
|
||||||
|
DB *db.DB
|
||||||
|
Auth *auth.Verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*Server, error) {
|
||||||
|
db, err := db.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
Router: chi.NewMux(),
|
||||||
|
|
||||||
|
DB: db,
|
||||||
|
Auth: auth.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("DEBUG") == "true" {
|
||||||
|
s.Router.Use(middleware.Logger)
|
||||||
|
}
|
||||||
|
s.Router.Use(middleware.Recoverer)
|
||||||
|
// add CORS
|
||||||
|
s.Router.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"https://*", "http://*"},
|
||||||
|
// Allow all methods normally used by the API
|
||||||
|
AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
|
AllowCredentials: false,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// enable request latency tracking
|
||||||
|
os.Setenv(chiprometheus.EnvChiPrometheusLatencyBuckets, "10,25,50,100,300,500,1000,5000")
|
||||||
|
prom := chiprometheus.New("pronouns.cc")
|
||||||
|
s.Router.Use(prom.Handler)
|
||||||
|
prom.MustRegisterDefault()
|
||||||
|
|
||||||
|
// enable authentication for all routes (but don't require it)
|
||||||
|
s.Router.Use(s.maybeAuth)
|
||||||
|
|
||||||
|
// rate limit handling
|
||||||
|
// - base is 120 req/minute (2/s)
|
||||||
|
// - keyed by Authorization header if valid token is provided, otherwise by IP
|
||||||
|
// - returns rate limit reset info in error
|
||||||
|
rateLimiter := rate.NewLimiter(120, time.Minute,
|
||||||
|
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
|
||||||
|
_, ok := ClaimsFromContext(r.Context())
|
||||||
|
if token := r.Header.Get("Authorization"); ok && token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := httprate.KeyByIP(r)
|
||||||
|
return ip, err
|
||||||
|
}),
|
||||||
|
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reset, _ := strconv.Atoi(w.Header().Get("X-RateLimit-Reset"))
|
||||||
|
|
||||||
|
render.Status(r, http.StatusTooManyRequests)
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrTooManyRequests,
|
||||||
|
Message: errCodeMessages[ErrTooManyRequests],
|
||||||
|
RatelimitReset: &reset,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// set scopes
|
||||||
|
// users
|
||||||
|
rateLimiter.Scope("GET", "/users/*", 60)
|
||||||
|
rateLimiter.Scope("PATCH", "/users/@me", 10)
|
||||||
|
|
||||||
|
// members
|
||||||
|
rateLimiter.Scope("GET", "/users/*/members", 60)
|
||||||
|
rateLimiter.Scope("GET", "/users/*/members/*", 60)
|
||||||
|
|
||||||
|
rateLimiter.Scope("POST", "/members", 10)
|
||||||
|
rateLimiter.Scope("GET", "/members/*", 60)
|
||||||
|
rateLimiter.Scope("PATCH", "/members/*", 20)
|
||||||
|
rateLimiter.Scope("DELETE", "/members/*", 5)
|
||||||
|
|
||||||
|
// auth
|
||||||
|
rateLimiter.Scope("*", "/auth/*", 20)
|
||||||
|
rateLimiter.Scope("*", "/auth/tokens", 10)
|
||||||
|
rateLimiter.Scope("*", "/auth/invites", 10)
|
||||||
|
rateLimiter.Scope("POST", "/auth/discord/*", 10)
|
||||||
|
|
||||||
|
s.Router.Use(rateLimiter.Handler())
|
||||||
|
|
||||||
|
// increment the total requests counter whenever a request is made
|
||||||
|
s.Router.Use(func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.DB.TotalRequests.Inc()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
})
|
||||||
|
|
||||||
|
// return an API error for not found + method not allowed
|
||||||
|
s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render.Status(r, errCodeStatuses[ErrNotFound])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrNotFound,
|
||||||
|
Message: errCodeMessages[ErrNotFound],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render.Status(r, errCodeStatuses[ErrMethodNotAllowed])
|
||||||
|
render.JSON(w, r, APIError{
|
||||||
|
Code: ErrMethodNotAllowed,
|
||||||
|
Message: errCodeMessages[ErrMethodNotAllowed],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxKeyClaims ctxKey = 1
|
||||||
|
)
|
12
docs/Caddyfile
Normal file
12
docs/Caddyfile
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
http://pronouns.local {
|
||||||
|
handle /media* {
|
||||||
|
uri path_regexp ^/media /pronouns.cc
|
||||||
|
reverse_proxy localhost:9000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /api* {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy localhost:5173
|
||||||
|
}
|
68
docs/production.md
Normal file
68
docs/production.md
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# Running pronouns.cc in production
|
||||||
|
|
||||||
|
The configuration files in this directory are the same files used to run pronouns.cc in production.
|
||||||
|
You might have to change paths and ports, but they should work fine as-is.
|
||||||
|
|
||||||
|
## Building pronouns.cc
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns
|
||||||
|
cd pronouns
|
||||||
|
git checkout stable
|
||||||
|
make all
|
||||||
|
|
||||||
|
# if running for the first time
|
||||||
|
./pronouns database migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
pronouns.cc is configured using `.env` files. Note that there are two separate `.env` files,
|
||||||
|
one in the repository root (for the backend) and one in the frontend directory.
|
||||||
|
|
||||||
|
### Backend keys
|
||||||
|
|
||||||
|
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building).
|
||||||
|
- `DATABASE_URL`: the URL for the PostgreSQL database.
|
||||||
|
- `REDIS`: the URL for the Redis database.
|
||||||
|
- `PORT` (int): the port the backend will listen on.
|
||||||
|
- `EXPORTER_PORT` (int): the port that the exporter service will listen on.
|
||||||
|
- `DEBUG` (true/false): whether to enable request logging.
|
||||||
|
- `BASE_URL`: the base URL for the frontend, used to construct some links.
|
||||||
|
- `MINIO_ENDPOINT`: the S3 endpoint for object storage.
|
||||||
|
- `MINIO_BUCKET`: the S3 bucket name.
|
||||||
|
- `MINIO_ACCESS_KEY_ID`: the S3 access key ID.
|
||||||
|
- `MINIO_ACCESS_KEY_SECRET`: the S3 access key secret.
|
||||||
|
- `MINIO_SSL`: whether to use SSL for S3.
|
||||||
|
- `FRONTEND_IP`: the IP for the frontend, which the rate limiter will ignore.
|
||||||
|
- `REQUIRE_INVITE`: whether to require invites to sign up.
|
||||||
|
- `DISCORD_CLIENT_ID`: for Discord auth, the client ID.
|
||||||
|
- `DISCORD_CLIENT_SECRET`: for Discord auth, the client secret.
|
||||||
|
- `DISCORD_PUBLIC_KEY`: public key for the Discord bot endpoint.
|
||||||
|
|
||||||
|
### Frontend keys
|
||||||
|
|
||||||
|
- `PUBLIC_BASE_URL`: the base URL for the frontend.
|
||||||
|
- `PRIVATE_SENTRY_DSN`: your Sentry DSN.
|
||||||
|
- `PUBLIC_MEDIA_URL`: the base URL for media.
|
||||||
|
If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make all
|
||||||
|
systemctl stop pronouns-api pronouns-fe
|
||||||
|
systemctl stop pronouns-exporter # only if the User, Member, Field, Export tables changed
|
||||||
|
./pronouns database migrate # only if a new migration was added
|
||||||
|
systemctl start pronouns-api pronouns-fe
|
||||||
|
systemctl start pronouns-exporter # if the exporter was stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxy
|
||||||
|
|
||||||
|
Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx.
|
||||||
|
This directory contains a sample configuration file for nginx.
|
||||||
|
|
||||||
|
Every path should be proxied to the frontend, except for `/api/`:
|
||||||
|
this should be proxied to the backend, with the URL being rewritten to remove `/api`
|
||||||
|
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
|
19
docs/pronouns-api.service
Normal file
19
docs/pronouns-api.service
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=pronouns.cc API
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Requires=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=2s
|
||||||
|
Type=simple
|
||||||
|
User=pronouns
|
||||||
|
Group=pronouns
|
||||||
|
AmbientCapabilities=
|
||||||
|
WorkingDirectory=/home/pronouns/src
|
||||||
|
ExecStart=/home/pronouns/src/pronouns web
|
||||||
|
Restart=always
|
||||||
|
Environment=USER=pronouns HOME=/home/pronouns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
19
docs/pronouns-clean.service
Normal file
19
docs/pronouns-clean.service
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Clean pronouns.cc database
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Requires=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=2s
|
||||||
|
Type=oneshot
|
||||||
|
User=pronouns
|
||||||
|
Group=pronouns
|
||||||
|
AmbientCapabilities=
|
||||||
|
WorkingDirectory=/home/pronouns/src
|
||||||
|
ExecStart=/home/pronouns/src/pronouns database clean
|
||||||
|
Restart=no
|
||||||
|
Environment=USER=pronouns HOME=/home/pronouns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
9
docs/pronouns-clean.timer
Normal file
9
docs/pronouns-clean.timer
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Clean pronouns.cc database daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=daily
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
19
docs/pronouns-exporter.service
Normal file
19
docs/pronouns-exporter.service
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=pronouns.cc data exporter service
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Requires=postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=2s
|
||||||
|
Type=simple
|
||||||
|
User=pronouns
|
||||||
|
Group=pronouns
|
||||||
|
AmbientCapabilities=
|
||||||
|
WorkingDirectory=/home/pronouns/src
|
||||||
|
ExecStart=/home/pronouns/src/pronouns exporter
|
||||||
|
Restart=always
|
||||||
|
Environment=USER=pronouns HOME=/home/pronouns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
19
docs/pronouns-fe.service
Normal file
19
docs/pronouns-fe.service
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=pronouns.cc frontend
|
||||||
|
After=syslog.target
|
||||||
|
After=network.target
|
||||||
|
Requires=pronouns-api.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RestartSec=2s
|
||||||
|
Type=simple
|
||||||
|
User=pronouns
|
||||||
|
Group=pronouns
|
||||||
|
AmbientCapabilities=
|
||||||
|
WorkingDirectory=/home/pronouns/src/frontend
|
||||||
|
ExecStart=node build/index.js
|
||||||
|
Restart=always
|
||||||
|
Environment=USER=pronouns HOME=/home/pronouns
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
82
docs/pronounscc.nginx
Normal file
82
docs/pronounscc.nginx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
server {
|
||||||
|
server_name example.tld;
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
# For SSL domain validation
|
||||||
|
root /var/www/html;
|
||||||
|
location /.well-known/acme-challenge/ { allow all; }
|
||||||
|
location /.well-known/pki-validation/ { allow all; }
|
||||||
|
location / { return 301 https://$server_name$request_uri; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# For media proxy
|
||||||
|
proxy_cache_path /tmp/pronouns-media-cache levels=1:2 keys_zone=pronouns_media_cache:10m max_size=1g
|
||||||
|
inactive=720m use_temp_path=off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name example.tld;
|
||||||
|
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:ssl_session_cache:10m;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
|
||||||
|
# To use a Let's Encrypt certificate
|
||||||
|
ssl_trusted_certificate /etc/letsencrypt/live/example.tld/chain.pem;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem;
|
||||||
|
|
||||||
|
# To use Debian/Ubuntu's self-signed certificate (For testing or before issuing a certificate)
|
||||||
|
#ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
|
||||||
|
#ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
|
||||||
|
|
||||||
|
client_max_body_size 8m;
|
||||||
|
|
||||||
|
location ~ ^/api {
|
||||||
|
rewrite ^/api(.*) $1 break;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/media {
|
||||||
|
proxy_cache pronouns_media_cache;
|
||||||
|
slice 1m;
|
||||||
|
proxy_cache_key $host$uri$is_args$args$slice_range;
|
||||||
|
proxy_set_header Range $slice_range;
|
||||||
|
proxy_cache_valid 200 206 301 304 1h;
|
||||||
|
proxy_cache_lock on;
|
||||||
|
proxy_ignore_client_abort on;
|
||||||
|
proxy_buffering on;
|
||||||
|
chunked_transfer_encoding on;
|
||||||
|
|
||||||
|
# Rewrite URL to remove /media/ and add bucket
|
||||||
|
rewrite ^/media/(.*) /pronouns/$1 break;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:9000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/fonts {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
}
|
||||||
|
}
|
15
frontend/.env.example
Normal file
15
frontend/.env.example
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# Base of frontend URLs
|
||||||
|
PUBLIC_BASE_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Base of media URLs, required for avatars, pride flags, and data exports
|
||||||
|
# If using the provided nginx reverse proxy config, use `$PUBLIC_BASE_URL/media`
|
||||||
|
PUBLIC_MEDIA_URL=
|
||||||
|
|
||||||
|
# Base of shortened profile URLs (leave empty to disable)
|
||||||
|
PUBLIC_SHORT_BASE=
|
||||||
|
|
||||||
|
# hCaptcha configuration (leave empty to disable)
|
||||||
|
PUBLIC_HCAPTCHA_SITEKEY=
|
||||||
|
|
||||||
|
# Sentry configuration (unused in dev, required in production)
|
||||||
|
PRIVATE_SENTRY_DSN=
|
13
frontend/.eslintignore
Normal file
13
frontend/.eslintignore
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||||
|
plugins: ['svelte3', '@typescript-eslint'],
|
||||||
|
ignorePatterns: ['*.cjs'],
|
||||||
|
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||||
|
settings: {
|
||||||
|
'svelte3/typescript': () => require('typescript')
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaVersion: 2020
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2017: true,
|
||||||
|
node: true
|
||||||
|
}
|
||||||
|
};
|
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
13
frontend/.prettierignore
Normal file
13
frontend/.prettierignore
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Ignore files for PNPM, NPM and YARN
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"pluginSearchDirs": ["."],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class FrontendConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "frontend"
|
|
44
frontend/icons.js
Normal file
44
frontend/icons.js
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// This script regenerates the list of icons for the frontend (frontend/src/icons.json)
|
||||||
|
// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons.
|
||||||
|
// Run with `pnpm node icons.js` in the frontend directory.
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" };
|
||||||
|
|
||||||
|
const keys = Object.keys(icons);
|
||||||
|
|
||||||
|
console.log(`Found ${keys.length} icons`);
|
||||||
|
const output = JSON.stringify(keys);
|
||||||
|
console.log(`Saving file as src/icons.ts`);
|
||||||
|
|
||||||
|
writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`);
|
||||||
|
|
||||||
|
const goCode1 = `// Generated code. DO NOT EDIT
|
||||||
|
package icons
|
||||||
|
|
||||||
|
var icons = [...]string{
|
||||||
|
`;
|
||||||
|
|
||||||
|
const goCode2 = `}
|
||||||
|
|
||||||
|
// IsValid returns true if the input is the name of a Bootstrap icon.
|
||||||
|
func IsValid(name string) bool {
|
||||||
|
for i := range icons {
|
||||||
|
if icons[i] == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let goOutput = goCode1;
|
||||||
|
|
||||||
|
keys.forEach((element) => {
|
||||||
|
goOutput += ` "${element}",\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
goOutput += goCode2;
|
||||||
|
|
||||||
|
console.log("Writing Go code");
|
||||||
|
writeFileSync("../backend/icons/icons.go", goOutput);
|
|
@ -1,3 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
52
frontend/package.json
Normal file
52
frontend/package.json
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"name": "pronouns-fe",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||||
|
"format": "prettier --plugin-search-dir . --write ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^2.0.0",
|
||||||
|
"@sveltejs/adapter-node": "^1.2.3",
|
||||||
|
"@sveltejs/kit": "^1.15.0",
|
||||||
|
"@types/luxon": "^3.2.2",
|
||||||
|
"@types/markdown-it": "^12.2.3",
|
||||||
|
"@types/node": "^18.15.11",
|
||||||
|
"@types/sanitize-html": "^2.9.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||||
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
|
"eslint": "^8.37.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-svelte3": "^4.0.0",
|
||||||
|
"prettier": "^2.8.7",
|
||||||
|
"prettier-plugin-svelte": "^2.10.0",
|
||||||
|
"svelte": "^3.58.0",
|
||||||
|
"svelte-check": "^3.1.4",
|
||||||
|
"svelte-hcaptcha": "^0.1.1",
|
||||||
|
"sveltestrap": "^5.10.0",
|
||||||
|
"tslib": "^2.5.0",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"vite": "^4.2.1",
|
||||||
|
"vite-plugin-markdown": "^2.1.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/firago": "^4.5.3",
|
||||||
|
"@popperjs/core": "^2.11.7",
|
||||||
|
"@sentry/node": "^7.46.0",
|
||||||
|
"base64-arraybuffer": "^1.0.2",
|
||||||
|
"bootstrap": "5.3.0-alpha1",
|
||||||
|
"bootstrap-icons": "^1.10.4",
|
||||||
|
"jose": "^4.13.1",
|
||||||
|
"luxon": "^3.3.0",
|
||||||
|
"markdown-it": "^13.0.1",
|
||||||
|
"pretty-bytes": "^6.1.0",
|
||||||
|
"sanitize-html": "^2.10.0"
|
||||||
|
}
|
||||||
|
}
|
2288
frontend/pnpm-lock.yaml
Normal file
2288
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
31
frontend/src/app.d.ts
vendored
Normal file
31
frontend/src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "svelte-hcaptcha" {
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
export interface HCaptchaProps {
|
||||||
|
sitekey?: string;
|
||||||
|
apihost?: string;
|
||||||
|
hl?: string;
|
||||||
|
reCaptchaCompat?: boolean;
|
||||||
|
theme?: CaptchaTheme;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare class HCaptcha extends SvelteComponent {
|
||||||
|
$$prop_def: HCaptchaProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HCaptcha;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue