initial commit
This commit is contained in:
commit
6f5a39c212
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
|
@ -0,0 +1,175 @@
|
|||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
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/>.
|
13
README.md
Normal file
13
README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Trillium
|
||||
sophie's chat app. Spiritual successor to [Awesomesauce chatrooms](https://github.com/awsomesauce-chatrooms).
|
||||
|
||||
### Features
|
||||
1. Written in Bun (both the backend and frontend)
|
||||
2. Using Elysia & Eden Treaty for max type safety
|
||||
3. Nearly no circular dependencies
|
||||
4. Staticly analyzable code (verified by CI)
|
||||
5. Easy to run (bun run dev or bun run build)
|
||||
|
||||
> This software is feature complete. Features might be added if I have enough time, which is not likely. Do not expect this to be maintained.
|
||||
|
||||
### Contact me at sad.ovh, if you have questions about this software, or anything else I've written.
|
18
package.json
Normal file
18
package.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "chatapp",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"workspaces": ["server", "web"],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter=* dev",
|
||||
"web:build": "bun run --filter=web build",
|
||||
"server:run": "bun run --filter=server dev",
|
||||
"server:prisma-generate": "bunx prisma generate --schema=./server/prisma/schema.prisma"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
46
server/.gitignore
vendored
Normal file
46
server/.gitignore
vendored
Normal file
|
@ -0,0 +1,46 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
**/*.trace
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
|
||||
static/
|
||||
|
||||
node_modules
|
||||
.env
|
BIN
server/bun.lockb
Executable file
BIN
server/bun.lockb
Executable file
Binary file not shown.
25
server/package.json
Normal file
25
server/package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.50",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.0.2",
|
||||
"@elysiajs/eden": "^1.0.13",
|
||||
"@elysiajs/static": "^1.0.3",
|
||||
"@elysiajs/swagger": "^1.0.5",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@prisma/client": "5.13.0",
|
||||
"elysia": "^1.0.16",
|
||||
"elysia-rate-limit": "^4.0.0",
|
||||
"logestic": "^1.1.1",
|
||||
"lucia": "^3.2.0",
|
||||
"prisma": "^5.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"module": "src/index.js"
|
||||
}
|
43
server/prisma/schema.prisma
Normal file
43
server/prisma/schema.prisma
Normal file
|
@ -0,0 +1,43 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Upload {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
type String
|
||||
uploadedBy String
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
username String
|
||||
password String
|
||||
staff Boolean @default(false)
|
||||
|
||||
status String @default("")
|
||||
passkeys Json[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
model Punishment {
|
||||
id String @id
|
||||
type String // "mute", "ban"
|
||||
reason String
|
||||
punishedUserId String
|
||||
staffId String
|
||||
time Int
|
||||
at DateTime
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
38
server/src/db.ts
Normal file
38
server/src/db.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Lucia, TimeSpan } from "lucia";
|
||||
|
||||
export const client = new PrismaClient();
|
||||
|
||||
const adapter = new PrismaAdapter(client.session, client.user);
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionExpiresIn: new TimeSpan(2, "w"), // 2 weeks
|
||||
|
||||
sessionCookie: {
|
||||
attributes: {
|
||||
secure: process.env.ENV === "PRODUCTION", // set `Secure` flag in HTTPS
|
||||
},
|
||||
},
|
||||
getUserAttributes: (attributes) => {
|
||||
return {
|
||||
username: attributes.username,
|
||||
password: attributes.password,
|
||||
staff: attributes.staff,
|
||||
status: attributes.status
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module "lucia" {
|
||||
interface Register {
|
||||
Lucia: typeof lucia;
|
||||
DatabaseUserAttributes: DatabaseUserAttributes;
|
||||
}
|
||||
}
|
||||
|
||||
interface DatabaseUserAttributes {
|
||||
username: string;
|
||||
password: string;
|
||||
status: string;
|
||||
staff: boolean;
|
||||
}
|
85
server/src/index.ts
Normal file
85
server/src/index.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
What if I never make it back from this damn panic attack?
|
||||
GTB sliding in a Hellcat bumping From First to Last
|
||||
When I die they're gonna make a park bench saying "This where he sat"
|
||||
Me and Yung Sherman going rehab, this shit is very sad
|
||||
Me and Yung Sherm in Venice Beach, man, this shit is very rad
|
||||
Me and Yung Sherman at the gym working out and getting tanned
|
||||
I never will see you again and I hope you understand
|
||||
I'm crashing down some like a wave over castles made of sand
|
||||
*/
|
||||
|
||||
// sad.ovh development
|
||||
|
||||
import cors from "@elysiajs/cors";
|
||||
import Elysia from "elysia";
|
||||
import auth from "./routes/auth";
|
||||
import ws from "./routes/ws";
|
||||
import profile from "./routes/profile";
|
||||
import { Logestic } from "logestic";
|
||||
import swagger from "@elysiajs/swagger";
|
||||
import staticPlugin from "@elysiajs/static";
|
||||
import { client } from "./db";
|
||||
import staff from "./routes/staff";
|
||||
import { mkdir } from "fs/promises";
|
||||
|
||||
try {
|
||||
await mkdir("static/pfps", { recursive: true });
|
||||
} catch {}
|
||||
try {
|
||||
await mkdir("static/images", { recursive: true });
|
||||
} catch {}
|
||||
|
||||
|
||||
const app = new Elysia()
|
||||
.use(Logestic.preset("common"))
|
||||
.use(
|
||||
swagger({
|
||||
documentation: {
|
||||
tags: [
|
||||
{
|
||||
name: "Auth",
|
||||
description: "All authenication routes have this tag.",
|
||||
},
|
||||
{ name: "Profile", description: "All profile routes have this tag." },
|
||||
{
|
||||
name: "Websocket",
|
||||
description: "All websocket routes have this tag.",
|
||||
},
|
||||
{ name: "Staff", description: "All staff routes have this tag." },
|
||||
{
|
||||
name: "Authenication not required",
|
||||
description: "Authenication isn't required",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.use(
|
||||
cors({
|
||||
origin: (context) => {
|
||||
return /(http(|s):\/\/|)(sad\.ovh|127\.0\.0\.1|localhost)(:\d{1,4}|)(\/|)/gm.test(
|
||||
context.url
|
||||
); // TODO fix this
|
||||
},
|
||||
allowedHeaders: "Origin, X-Requested-With, Content-Type, Accept",
|
||||
})
|
||||
)
|
||||
.use(
|
||||
staticPlugin({
|
||||
prefix: "",
|
||||
assets: "static",
|
||||
noCache: !(process.env.ENV === "PRODUCTION"),
|
||||
})
|
||||
)
|
||||
.use(auth)
|
||||
.use(ws)
|
||||
.use(profile)
|
||||
.use(staff)
|
||||
.listen(+process.env.PORT!);
|
||||
|
||||
export type App = typeof app;
|
||||
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
|
||||
);
|
74
server/src/lib.ts
Normal file
74
server/src/lib.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { client } from "./db";
|
||||
|
||||
import { unlink, mkdir, exists } from "fs/promises";
|
||||
|
||||
export async function setDeleteTimeout(
|
||||
entry: string | { id: string; expiresAt: Date; type: string }
|
||||
) {
|
||||
let upload: { id: string; expiresAt: Date; type: string };
|
||||
if (typeof entry == "string") {
|
||||
const x = await client.upload.findFirst({
|
||||
where: {
|
||||
id: entry,
|
||||
},
|
||||
});
|
||||
if (!x) {
|
||||
throw new Error(
|
||||
"A delete timeout was set for the " + entry + ", which doesn't exist!"
|
||||
);
|
||||
}
|
||||
upload = x;
|
||||
} else {
|
||||
upload = entry;
|
||||
}
|
||||
if (!(await exists("static/images/" + upload.id))) {
|
||||
console.log(
|
||||
"File with upload ID",
|
||||
upload.id,
|
||||
"was already deleted from FS, deleting it from DB."
|
||||
);
|
||||
await client.upload.delete({
|
||||
where: {
|
||||
id: upload.id,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = upload.expiresAt.getTime() - new Date().getTime();
|
||||
|
||||
if (diff < 0) {
|
||||
console.log(
|
||||
"File with upload ID",
|
||||
upload.id,
|
||||
"expired, which is being deleted."
|
||||
);
|
||||
|
||||
await unlink("static/images/" + upload.id);
|
||||
await client.upload.delete({
|
||||
where: {
|
||||
id: upload.id,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log("File ID", upload.id, "expiring in", diff + "ms.");
|
||||
setTimeout(async () => {
|
||||
console.log(
|
||||
"File with upload ID",
|
||||
upload.id,
|
||||
"reached expiration time, and it is being deleted"
|
||||
);
|
||||
|
||||
await unlink("static/images/" + upload.id);
|
||||
await client.upload.delete({
|
||||
where: {
|
||||
id: upload.id,
|
||||
},
|
||||
});
|
||||
}, diff);
|
||||
}
|
||||
|
||||
(await client.upload.findMany()).forEach(async (z) => {
|
||||
await setDeleteTimeout(z);
|
||||
});
|
253
server/src/routes/auth.ts
Normal file
253
server/src/routes/auth.ts
Normal file
|
@ -0,0 +1,253 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import { generateIdFromEntropySize, User } from "lucia";
|
||||
import { client, lucia } from "../db";
|
||||
import session, { ipGenerator } from "../session";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
|
||||
async function checkRecaptcha(
|
||||
recaptchaToken: string,
|
||||
action: "login" | "register"
|
||||
) {
|
||||
const recaptchaTest = await fetch(
|
||||
"https://www.google.com/recaptcha/api/siteverify?secret=" +
|
||||
process.env.RECAPTCHA_SECRET +
|
||||
"&response=" +
|
||||
encodeURIComponent(recaptchaToken),
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const recaptchaJson = await recaptchaTest.json();
|
||||
|
||||
if (!recaptchaJson.success) {
|
||||
return new Response("Recaptcha failed.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (recaptchaJson.action != action) {
|
||||
return new Response("Wrong action.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
recaptchaJson.hostname == "localhost" &&
|
||||
process.env.ENV == "production"
|
||||
) {
|
||||
return new Response(
|
||||
"Incorrect captcha, this is a production environment!",
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (recaptchaJson.score < 0.7) {
|
||||
return new Response("Suspicious request!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
export default new Elysia({
|
||||
prefix: "/api/auth",
|
||||
})
|
||||
.use(session)
|
||||
.get(
|
||||
"/userInfo",
|
||||
(context) => {
|
||||
if (!context.user) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
username: context.user.username,
|
||||
};
|
||||
},
|
||||
{
|
||||
response: t.MaybeEmpty(
|
||||
t.Object({
|
||||
username: t.String(),
|
||||
})
|
||||
),
|
||||
detail: {
|
||||
tags: ["Auth"],
|
||||
description: "Returns the username of the currently signed in user.",
|
||||
},
|
||||
}
|
||||
)
|
||||
.group("", (a) =>
|
||||
a
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "local",
|
||||
duration: 1 * 24 * 60 * 1000, // 1 day
|
||||
max: 1,
|
||||
generator: ipGenerator(),
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/register",
|
||||
async (context) => {
|
||||
context;
|
||||
if (context.user as User) {
|
||||
return new Response("You're already signed in!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
username: context.body.username,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
return new Response("User already exists!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.body.username.length > 30) {
|
||||
return new Response("Username greater than 30!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (context.body.username.length < 3) {
|
||||
return new Response("Username smaller than 3!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_!.]*$/gm.test(context.body.username)) {
|
||||
return new Response("Username is incorrectly formatted!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (context.body.password.length < 5) {
|
||||
return new Response("Password smaller than 5!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const recaptcha = await checkRecaptcha(
|
||||
context.body.recaptcha,
|
||||
"register"
|
||||
);
|
||||
if (recaptcha) return recaptcha;
|
||||
|
||||
const hashedPassword = await Bun.password.hash(context.body.password);
|
||||
const id = generateIdFromEntropySize(10);
|
||||
|
||||
await client.user.create({
|
||||
data: {
|
||||
id,
|
||||
username: context.body.username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
password: t.String(),
|
||||
username: t.String(),
|
||||
recaptcha: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Auth", "Authenication not required"],
|
||||
description:
|
||||
"Register endpoint. Requires a re-captcha token. This re-captcha token has to from our website.",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
.get(
|
||||
"/logout",
|
||||
async (context) => {
|
||||
if (!context.session) {
|
||||
return new Response("You're not signed in.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await lucia.invalidateSession(context.session?.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
context.cookie[sessionCookie.name].set(sessionCookie.attributes);
|
||||
context.cookie[sessionCookie.name].value = sessionCookie.value;
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
tags: ["Auth"],
|
||||
description: "Logs out of the currently authenicated request.",
|
||||
},
|
||||
}
|
||||
)
|
||||
.group("", (a) =>
|
||||
a
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "local",
|
||||
duration: 60 * 1000, // 1 minute
|
||||
max: 5, // 5 attempts
|
||||
generator: ipGenerator(),
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/login",
|
||||
|
||||
async (context) => {
|
||||
if (context.user) {
|
||||
return new Response("You're already signed in!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
username: context.body.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("There is no user with this username.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(await Bun.password.verify(context.body.password, user.password))
|
||||
) {
|
||||
return new Response("Incorrect password.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const recaptcha = await checkRecaptcha(
|
||||
context.body.recaptcha,
|
||||
"login"
|
||||
);
|
||||
if (recaptcha) return recaptcha;
|
||||
|
||||
const session = await lucia.createSession(user.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
context.cookie[sessionCookie.name].set(sessionCookie.attributes);
|
||||
context.cookie[sessionCookie.name].value = sessionCookie.value;
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
password: t.String(),
|
||||
username: t.String(),
|
||||
recaptcha: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Auth", "Authenication not required"],
|
||||
description:
|
||||
"Authenicates with password and username. Requires captcha, properly created.",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
145
server/src/routes/profile.ts
Normal file
145
server/src/routes/profile.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import session from "../session";
|
||||
import { client } from "../db";
|
||||
import { generateIdFromEntropySize } from "lucia";
|
||||
import { setDeleteTimeout } from "../lib";
|
||||
import { updateRooms } from "./ws";
|
||||
|
||||
export default new Elysia({
|
||||
prefix: "/api/profile",
|
||||
})
|
||||
.use(session)
|
||||
|
||||
.post(
|
||||
"/setStatus",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (context.body.status.length > 40) {
|
||||
return new Response("Status greater than 40!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (context.body.status.length < 1) {
|
||||
return new Response("Status smaller than 1!", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await client.user.update({
|
||||
where: {
|
||||
id: context.user.id,
|
||||
},
|
||||
data: {
|
||||
status: context.body.status,
|
||||
},
|
||||
});
|
||||
updateRooms(context.user.id, "status", context.body.status);
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
status: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Profile"],
|
||||
description: "Sets the status of the user. Can be seen in the client.",
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/setProfilePicture",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.body.file.size > 2e7) {
|
||||
return new Response("PFP too big", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!context.body.file.type.startsWith("image/")) {
|
||||
return new Response("PFP not image", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const response = new Response(context.body.file.stream());
|
||||
await Bun.write("static/pfps/" + context.user.id, response);
|
||||
|
||||
updateRooms(context.user.id, "pfp");
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
file: t.File(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Profile"],
|
||||
description:
|
||||
"Sets a profile picture. Max size 20MB, only images (mimetype image/).",
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/uploadImage",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.body.file.size > 1e7) {
|
||||
return new Response("Image too big", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!context.body.file.type.startsWith("image/")) {
|
||||
return new Response("File not image", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const uploadId = generateIdFromEntropySize(10);
|
||||
const expiresAt = new Date(Date.now() + 8.64e7);
|
||||
|
||||
await client.upload.create({
|
||||
data: {
|
||||
id: uploadId,
|
||||
expiresAt,
|
||||
type: context.body.file.type,
|
||||
uploadedBy: context.user.id,
|
||||
},
|
||||
});
|
||||
const response = new Response(context.body.file.stream());
|
||||
await Bun.write("static/images/" + uploadId, response);
|
||||
|
||||
await setDeleteTimeout({
|
||||
id: uploadId,
|
||||
expiresAt,
|
||||
type: context.body.file.type,
|
||||
});
|
||||
return {
|
||||
uploadId: uploadId,
|
||||
};
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
file: t.File(),
|
||||
}),
|
||||
response: t.MaybeEmpty(
|
||||
t.Object({
|
||||
uploadId: t.String(),
|
||||
})
|
||||
),
|
||||
detail: {
|
||||
tags: ["Profile"],
|
||||
description:
|
||||
"Uploads a file. Most commonly used for chat messages. These will only stay for 24 hours.",
|
||||
},
|
||||
}
|
||||
);
|
277
server/src/routes/staff.ts
Normal file
277
server/src/routes/staff.ts
Normal file
|
@ -0,0 +1,277 @@
|
|||
import Elysia, { t } from "elysia";
|
||||
import session from "../session";
|
||||
import { generateIdFromEntropySize } from "lucia";
|
||||
import { client } from "../db";
|
||||
import { disconnectID, findID } from "./ws";
|
||||
|
||||
export default new Elysia({
|
||||
prefix: "/api/staff",
|
||||
})
|
||||
.use(session)
|
||||
.post(
|
||||
"/invalidatePunishment",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (!context.user.staff) {
|
||||
return new Response("You are not staff.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const punishment = await client.punishment.findFirst({
|
||||
where: {
|
||||
id: context.body.punishmentId,
|
||||
},
|
||||
});
|
||||
if (!punishment) {
|
||||
return new Response("No punishment found!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
await client.punishment.delete({
|
||||
where: {
|
||||
id: context.body.punishmentId,
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
punishmentId: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Staff"],
|
||||
description:
|
||||
"Invalidates a request. Invalidate currently means delete, but it might in the future mean that the punishment is invalid.",
|
||||
},
|
||||
}
|
||||
)
|
||||
.post(
|
||||
"/punishments",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (!context.user.staff) {
|
||||
return new Response("You are not staff.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
id: context.body.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User does not exist!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const punishments = await client.punishment.findMany({
|
||||
where: {
|
||||
punishedUserId: user.id,
|
||||
},
|
||||
});
|
||||
return punishments.map((z) => {
|
||||
return {
|
||||
id: z.id,
|
||||
type: z.type,
|
||||
reason: z.reason,
|
||||
staffId: z.staffId,
|
||||
time: z.time,
|
||||
at: z.at,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
userId: t.String(),
|
||||
}),
|
||||
response: t.MaybeEmpty(
|
||||
t.Array(
|
||||
t.Object({
|
||||
id: t.String(),
|
||||
type: t.String(),
|
||||
reason: t.String(),
|
||||
staffId: t.String(),
|
||||
time: t.Number(),
|
||||
at: t.Date(),
|
||||
})
|
||||
)
|
||||
),
|
||||
detail: {
|
||||
tags: ["Staff"],
|
||||
description:
|
||||
"View an user's punishments. Will show all punishments by user, and who banned them and for what reason. Can be invalidated.",
|
||||
},
|
||||
}
|
||||
)
|
||||
.guard(
|
||||
{
|
||||
body: t.Object({
|
||||
id: t.String(),
|
||||
reason: t.String(),
|
||||
ms: t.Number(),
|
||||
isPermanent: t.MaybeEmpty(t.Boolean()),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Staff"],
|
||||
description:
|
||||
"Ban and Mute. These either ban, or mute a person. Mutes prohibit the user from chatting, and bans prohibit the user from joining.",
|
||||
},
|
||||
},
|
||||
(a) =>
|
||||
a
|
||||
.post("/ban", async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (!context.user.staff) {
|
||||
return new Response("You are not staff.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
id: context.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User does not exist!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (user.staff) {
|
||||
return new Response("Cannot ban staff!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
disconnectID(user.id);
|
||||
|
||||
await client.punishment.create({
|
||||
data: {
|
||||
type: "ban",
|
||||
id: generateIdFromEntropySize(10),
|
||||
reason: context.body.reason,
|
||||
punishedUserId: user.id,
|
||||
staffId: context.user.id,
|
||||
at: new Date(),
|
||||
time: context.body.isPermanent ? -1 : context.body.ms,
|
||||
},
|
||||
});
|
||||
})
|
||||
.post("/mute", async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (!context.user.staff) {
|
||||
return new Response("You are not staff.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
id: context.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User does not exist!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (user.staff) {
|
||||
return new Response("Cannot mute staff!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
await client.punishment.create({
|
||||
data: {
|
||||
type: "mute",
|
||||
id: generateIdFromEntropySize(10),
|
||||
reason: context.body.reason,
|
||||
punishedUserId: user.id,
|
||||
staffId: context.user.id,
|
||||
at: new Date(),
|
||||
time: context.body.isPermanent ? -1 : context.body.ms,
|
||||
},
|
||||
});
|
||||
})
|
||||
)
|
||||
.post(
|
||||
"/isUserOnline",
|
||||
async (context) => {
|
||||
if (!context.user) {
|
||||
return new Response("Not authenicated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
if (!context.user.staff) {
|
||||
return new Response("You are not staff.", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await client.user.findFirst({
|
||||
where: {
|
||||
id: context.body.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User does not exist!", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const participiants = findID(user.id);
|
||||
|
||||
if (participiants.length == 0) {
|
||||
return {
|
||||
isOnline: false,
|
||||
name: user.username,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOnline: true,
|
||||
rooms: participiants.map((z) => z.room),
|
||||
name: user.username,
|
||||
};
|
||||
},
|
||||
{
|
||||
response: t.MaybeEmpty(
|
||||
t.Object({
|
||||
isOnline: t.Boolean(),
|
||||
rooms: t.MaybeEmpty(t.Array(t.String())),
|
||||
name: t.String(),
|
||||
})
|
||||
),
|
||||
body: t.Object({
|
||||
userId: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
tags: ["Staff"],
|
||||
description:
|
||||
"Returns username from ID, and checks if user is online. If is online, then also returns all rooms they're in.",
|
||||
},
|
||||
}
|
||||
);
|
87
server/src/routes/ws.ts
Normal file
87
server/src/routes/ws.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
The Pulitzer Prize winner is definitely spiralin'
|
||||
I got your fucking lines tapped, I swear that I'm dialed in
|
||||
First, I was a rat, so where's the proof of the trial then?
|
||||
Where's the paperwork or the cabinet it's filed in?
|
||||
1090 Jake woulda took all the walls down
|
||||
The streets woulda had me hidin' out in a small town
|
||||
My Montreal connects stand up, now fall down
|
||||
The ones that you're getting your stories from, they all clowns
|
||||
I am a war general, sеasoned in preparation
|
||||
My jacket is covеred in medals, honor and decoration
|
||||
*/
|
||||
|
||||
// This file was developed independently from the rest of the project,
|
||||
// as it's way more complicated than.. let's say the index.ts of the frontend.
|
||||
|
||||
// sad.ovh developmeant
|
||||
|
||||
import Elysia, {
|
||||
InputSchema,
|
||||
MergeSchema,
|
||||
TSchema,
|
||||
UnwrapRoute,
|
||||
t,
|
||||
} from "elysia";
|
||||
import session, { ipGenerator } from "../session";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import { ElysiaWSType, Server } from "./ws/Server";
|
||||
import { Participiant } from "./ws/Participiant";
|
||||
|
||||
const server = new Server();
|
||||
|
||||
export function updateRooms(
|
||||
userId: string,
|
||||
type: "pfp" | "username" | "status",
|
||||
data: string = ""
|
||||
) {
|
||||
server.rooms.forEach((z) => {
|
||||
for (const [_, participiant] of z.participiants) {
|
||||
if (participiant.user.id == userId) {
|
||||
participiant.broadcast({
|
||||
type: "updateUser",
|
||||
updateType: type,
|
||||
id: participiant.user.id,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
export function disconnectID(id: string) {
|
||||
for (const room of server.rooms) {
|
||||
for (const [_, part] of room.participiants) {
|
||||
if (part.user.id == id) {
|
||||
part.closeClients();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export function findID(id: string) {
|
||||
const parts: Participiant[] = [];
|
||||
|
||||
for (const room of server.rooms) {
|
||||
for (const [_, part] of room.participiants) {
|
||||
if (part.user.id == id) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
export default new Elysia()
|
||||
.use(session)
|
||||
.use(
|
||||
rateLimit({
|
||||
scoping: "local",
|
||||
duration: 1000, // 1 second
|
||||
max: 2,
|
||||
generator: ipGenerator(),
|
||||
})
|
||||
)
|
||||
.ws("/api/ws", {
|
||||
open: (a) => server.open(a as unknown as ElysiaWSType),
|
||||
message: (a, b) => server.message(a as unknown as ElysiaWSType, b),
|
||||
close: (a) => server.close(a as unknown as ElysiaWSType),
|
||||
});
|
26
server/src/routes/ws/Client.ts
Normal file
26
server/src/routes/ws/Client.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { Participiant } from "./Participiant";
|
||||
import type { ElysiaWSType } from "./Server";
|
||||
|
||||
export class Client {
|
||||
private ws: ElysiaWSType;
|
||||
userId: string;
|
||||
room?: string;
|
||||
id: `${string}-${string}-${string}-${string}-${string}`;
|
||||
|
||||
constructor(ws: ElysiaWSType) {
|
||||
this.ws = ws;
|
||||
this.id = crypto.randomUUID();
|
||||
this.userId = this.ws.data.user!.id;
|
||||
}
|
||||
|
||||
makeParticipiant(): Participiant {
|
||||
return new Participiant(this.ws.data.user!);
|
||||
}
|
||||
send(obj: Record<any, any>) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
27
server/src/routes/ws/Participiant.ts
Normal file
27
server/src/routes/ws/Participiant.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { User } from "lucia";
|
||||
import { Client } from "./Client";
|
||||
|
||||
export class Participiant {
|
||||
clients: Client[] = [];
|
||||
room?: string;
|
||||
user: User;
|
||||
|
||||
constructor(user: User) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
closeClients() {
|
||||
this.clients.forEach((z) => {
|
||||
z.close();
|
||||
});
|
||||
}
|
||||
addClient(client: Client) {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
broadcast(obj: Record<any, any>) {
|
||||
this.clients.forEach((z) => {
|
||||
z.send(obj);
|
||||
});
|
||||
}
|
||||
}
|
155
server/src/routes/ws/Room.ts
Normal file
155
server/src/routes/ws/Room.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { client } from "../../db";
|
||||
import { Client } from "./Client";
|
||||
import { Participiant } from "./Participiant";
|
||||
|
||||
import {exists} from "fs/promises";
|
||||
|
||||
export class Room {
|
||||
participiants: Map<string, Participiant> = new Map();
|
||||
name: string;
|
||||
hidden: boolean;
|
||||
messageHistory: {
|
||||
userID: string;
|
||||
content: string;
|
||||
images?: string[] | undefined;
|
||||
username: string;
|
||||
time: number;
|
||||
}[] = [];
|
||||
|
||||
timeout?: number;
|
||||
|
||||
constructor(name: string, hidden: boolean) {
|
||||
this.name = name;
|
||||
this.hidden = hidden;
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
message: { message: string; images: string[] },
|
||||
part: Participiant
|
||||
) {
|
||||
if (message.message.length > 200) return;
|
||||
if (message.message.length < 1) return;
|
||||
let validImages: string[] = [];
|
||||
|
||||
for (const image of message.images) {
|
||||
if (!/^[a-z0-9]{1,16}$/gm.test(image)) continue;
|
||||
if (await exists("static/images/" + image)) {
|
||||
validImages.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
const punishments = await client.punishment.findMany({
|
||||
where: {
|
||||
punishedUserId: part.user.id,
|
||||
type: "mute",
|
||||
},
|
||||
});
|
||||
|
||||
for (const punishment of punishments) {
|
||||
if (punishment.time == -1) punishment.time = Infinity;
|
||||
const diff =
|
||||
punishment.at.getTime() + punishment.time - new Date().getTime();
|
||||
|
||||
if (diff > 0) {
|
||||
// punishment is valid
|
||||
const punishedBy = await client.user.findFirst({
|
||||
where: {
|
||||
id: punishment.staffId,
|
||||
},
|
||||
});
|
||||
|
||||
part.broadcast({
|
||||
type: "notification",
|
||||
message: `You've been muted by ${punishedBy?.username}. There are ${diff}ms left in your mute. You were muted at ${punishment.at}. The reason you were muted is "${punishment.reason}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const msg = {
|
||||
content: message.message,
|
||||
images: validImages,
|
||||
time: Date.now(),
|
||||
userID: part.user.id,
|
||||
username: part.user.username,
|
||||
};
|
||||
|
||||
for (const part of this.participiants) {
|
||||
part[1].broadcast({
|
||||
type: "message",
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
|
||||
this.messageHistory.push(msg);
|
||||
}
|
||||
addClient(client: Client) {
|
||||
let part = this.participiants.get(client.userId);
|
||||
if (part) {
|
||||
part.room = this.name;
|
||||
part.addClient(client);
|
||||
} else {
|
||||
part = client.makeParticipiant();
|
||||
part.room = this.name;
|
||||
part.addClient(client);
|
||||
this.participiants.set(part.user.id, part);
|
||||
}
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
client.send({
|
||||
type: "notification",
|
||||
message: `You saved ${this.name}! Thank you!`,
|
||||
});
|
||||
}
|
||||
client.send({
|
||||
type: "history",
|
||||
messages: this.messageHistory,
|
||||
});
|
||||
client.send({
|
||||
type: "room",
|
||||
room: this.name,
|
||||
});
|
||||
|
||||
this.updatePeople();
|
||||
}
|
||||
|
||||
removeClient(client: Client, timeoutFunction: () => void) {
|
||||
let part = this.participiants.get(client.userId);
|
||||
if (!part) return;
|
||||
part.clients = part.clients.filter((z) => z.id !== client.id);
|
||||
if (part.clients.length == 0) {
|
||||
this.participiants.delete(client.userId);
|
||||
}
|
||||
if (this.participiants.size == 0) {
|
||||
client.send({
|
||||
type: "notification",
|
||||
message:
|
||||
this.name +
|
||||
" has 0 users left! If nobody joins in 15 seconds, the room's history will be deleted.",
|
||||
});
|
||||
|
||||
this.timeout = setTimeout(timeoutFunction, 15000) as unknown as number;
|
||||
}
|
||||
this.updatePeople();
|
||||
return part.clients.length == 0;
|
||||
}
|
||||
sendPeople(z: Client) {
|
||||
z.send({
|
||||
type: "people",
|
||||
people: [...this.participiants.values()].map((z) => {
|
||||
return { username: z.user?.username, id: z.user?.id, status: z.user?.status };
|
||||
}),
|
||||
});
|
||||
}
|
||||
updatePeople() {
|
||||
this.participiants.forEach((z) => {
|
||||
z.broadcast({
|
||||
type: "people",
|
||||
people: [...this.participiants.values()].map((z) => {
|
||||
return { username: z.user?.username, id: z.user?.id, status: z.user?.status };
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
160
server/src/routes/ws/Server.ts
Normal file
160
server/src/routes/ws/Server.ts
Normal file
|
@ -0,0 +1,160 @@
|
|||
import { ServerWebSocket } from "bun";
|
||||
import type { TSchema, MergeSchema, UnwrapRoute, InputSchema } from "elysia";
|
||||
import type { TypeCheck } from "elysia/dist/type-system";
|
||||
import type { ElysiaWS } from "elysia/dist/ws";
|
||||
import type { User, Session } from "lucia";
|
||||
import { client } from "../../db";
|
||||
import { Room } from "./Room";
|
||||
import { Client } from "./Client";
|
||||
|
||||
export type ElysiaWSType = ElysiaWS<
|
||||
ServerWebSocket<{
|
||||
validator?: TypeCheck<TSchema> | undefined;
|
||||
}>,
|
||||
MergeSchema<UnwrapRoute<InputSchema<never>, {}>, {}> & {
|
||||
params: Record<never, string>;
|
||||
},
|
||||
{
|
||||
decorator: {};
|
||||
store: {};
|
||||
derive: {};
|
||||
resolve: {
|
||||
client: Client | null;
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
};
|
||||
} & {
|
||||
derive: {};
|
||||
resolve: {};
|
||||
}
|
||||
>;
|
||||
export class Server {
|
||||
clients: Client[] = [];
|
||||
rooms: Room[] = [];
|
||||
|
||||
async open(ws: ElysiaWSType) {
|
||||
if (!ws.data.user) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const punishments = await client.punishment.findMany({
|
||||
where: {
|
||||
punishedUserId: ws.data.user.id,
|
||||
type: "ban",
|
||||
},
|
||||
});
|
||||
|
||||
for (const punishment of punishments) {
|
||||
if (punishment.time == -1) punishment.time = Infinity;
|
||||
const diff =
|
||||
punishment.at.getTime() + punishment.time - new Date().getTime();
|
||||
|
||||
if (diff > 0) {
|
||||
// punishment is valid
|
||||
const punishedBy = await client.user.findFirst({
|
||||
where: {
|
||||
id: punishment.staffId,
|
||||
},
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "notification",
|
||||
message: `You've been banned by ${punishedBy?.username}. There are ${diff}ms left in your ban. You were banned at ${punishment.at}. The reason you were banned is "${punishment.reason}"`,
|
||||
})
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "notification",
|
||||
message: `Your ID: ${punishment.punishedUserId}, punishment ID: ${punishment.id}`,
|
||||
})
|
||||
);
|
||||
|
||||
ws.close();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ccl = new Client(ws);
|
||||
this.clients.push(ccl);
|
||||
ws.data.client = ccl;
|
||||
ccl.send({
|
||||
type: "hello",
|
||||
staff: ws.data.user.staff,
|
||||
});
|
||||
}
|
||||
|
||||
message(ws: ElysiaWSType, message: any) {
|
||||
if (!ws.data.client) return;
|
||||
const client = ws.data.client;
|
||||
|
||||
if (message.type == "room") {
|
||||
if (
|
||||
!message.room ||
|
||||
typeof message.room != "string" ||
|
||||
message.room.length == 0 ||
|
||||
message.room.length > 20
|
||||
)
|
||||
message.room = "lobby";
|
||||
|
||||
if (client.room) {
|
||||
if (client.room == message.room) return;
|
||||
const prevRoom = this.rooms.find((z) => z.name == client.room);
|
||||
if (prevRoom)
|
||||
prevRoom.removeClient(client, () => {
|
||||
this.rooms = this.rooms.filter((z) => z.name !== prevRoom.name);
|
||||
this.updateRooms();
|
||||
});
|
||||
}
|
||||
let room = this.rooms.find((z) => z.name == message.room);
|
||||
if (!room) {
|
||||
room = new Room(message.room, !!message.hidden);
|
||||
this.rooms.push(room);
|
||||
}
|
||||
|
||||
room.addClient(client);
|
||||
|
||||
client.room = message.room;
|
||||
|
||||
this.updateRooms();
|
||||
}
|
||||
if (message.type == "message") {
|
||||
const room = this.rooms.find((z) => z.name == client.room);
|
||||
if (!room) return;
|
||||
|
||||
room.addMessage(message, room.participiants.get(client.userId)!);
|
||||
}
|
||||
}
|
||||
|
||||
close(ws: ElysiaWSType) {
|
||||
if (!ws.data.client) return;
|
||||
this.clients = this.clients.filter((z) => z.id !== ws.data.client?.id);
|
||||
const room = this.rooms.find((z) => z.name == ws.data.client?.room);
|
||||
if (room) {
|
||||
if (room.removeClient(ws.data.client!, () => {
|
||||
this.rooms = this.rooms.filter((z) => z.name !== room.name);
|
||||
this.updateRooms();
|
||||
})) {
|
||||
this.updateRooms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendRooms(client: Client) {
|
||||
client.send({
|
||||
type: "rooms",
|
||||
rooms: this.rooms
|
||||
.filter((z) => !z.hidden)
|
||||
.map((g) => {
|
||||
return { name: g.name, count: g.participiants.size };
|
||||
}),
|
||||
});
|
||||
}
|
||||
updateRooms() {
|
||||
this.clients.forEach((z) => this.sendRooms(z));
|
||||
}
|
||||
}
|
||||
|
88
server/src/session.ts
Normal file
88
server/src/session.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import Elysia from "elysia";
|
||||
import { User, Session, verifyRequestOrigin } from "lucia";
|
||||
import { lucia } from "./db";
|
||||
import type { Generator } from "elysia-rate-limit";
|
||||
|
||||
export const ipGenerator = () => {
|
||||
let gen: Generator<any>;
|
||||
|
||||
if (process.env.DETECT_IP == "fwf") {
|
||||
gen = (req, server) => {
|
||||
let fwf = req.headers.get("x-forwarded-for")!;
|
||||
if (!fwf) {
|
||||
console.log(
|
||||
"!!! x-forwarded-for missing on request, while DETECT_IP is set to fwf! falling back to server IP !!!"
|
||||
);
|
||||
fwf = server?.requestIP(req)?.address!;
|
||||
}
|
||||
|
||||
return fwf;
|
||||
};
|
||||
} else {
|
||||
gen = (req, server) => {
|
||||
return server?.requestIP(req)?.address!;
|
||||
};
|
||||
}
|
||||
return gen;
|
||||
};
|
||||
|
||||
export default new Elysia({
|
||||
name: "session",
|
||||
}).derive(
|
||||
{ as: "global" },
|
||||
async (
|
||||
context
|
||||
): Promise<{
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
}> => {
|
||||
// CSRF check
|
||||
if (context.request.method !== "GET") {
|
||||
const originHeader = context.request.headers.get("Origin");
|
||||
// NOTE: You may need to use `X-Forwarded-Host` instead
|
||||
const hostHeader = context.request.headers.get("Host");
|
||||
if (
|
||||
!originHeader ||
|
||||
!hostHeader ||
|
||||
!verifyRequestOrigin(originHeader, [hostHeader, "localhost:5173"])
|
||||
) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// use headers instead of Cookie API to prevent type coercion
|
||||
const cookieHeader = context.request.headers.get("Cookie") ?? "";
|
||||
const sessionId = lucia.readSessionCookie(cookieHeader);
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { session, user } = await lucia.validateSession(sessionId);
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
context.cookie[sessionCookie.name].set({
|
||||
value: sessionCookie.value,
|
||||
...sessionCookie.attributes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
context.cookie[sessionCookie.name].set({
|
||||
value: sessionCookie.value,
|
||||
...sessionCookie.attributes,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
session,
|
||||
};
|
||||
}
|
||||
);
|
103
server/tsconfig.json
Normal file
103
server/tsconfig.json
Normal file
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
"noErrorTruncation": true,
|
||||
/* Language and Environment */
|
||||
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "ES2022", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
2
web/.gitignore
vendored
Normal file
2
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
BIN
web/bun.lockb
Executable file
BIN
web/bun.lockb
Executable file
Binary file not shown.
24
web/index.html
Normal file
24
web/index.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Trillium</title>
|
||||
<script src="https://kit.fontawesome.com/f8dad62a29.js" crossorigin="anonymous"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
||||
|
||||
<style>
|
||||
.grecaptcha-badge {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="dark"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
33
web/package.json
Normal file
33
web/package.json
Normal file
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.0.13",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"gif-picker-react": "^1.3.2",
|
||||
"preact": "^10.20.0",
|
||||
"react-google-recaptcha-v3": "^1.10.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-use-websocket": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^5.2.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@fortawesome/fontawesome-free"
|
||||
]
|
||||
}
|
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
20
web/public/logo.svg
Normal file
20
web/public/logo.svg
Normal file
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 136 132" version="1.1">
|
||||
<path
|
||||
d="M 11.238283,77.871644 C 9.6359691,77.442305 7.2871274,85.365019 6.930159,89.268215 l 0.09375,12.531255 c 0,8.24363 -5.7560452,8.26497 -6.06249996,10.09375 -1.04518504,4.19882 7.06232836,3.71699 14.78969296,3.62907 15.092131,-0.17172 26.147807,-6.10331 26.147807,-7.94157 0,-4.69512 -2.384992,-4.84425 -3.625,-9.968755 -1.48773,-6.148245 -0.809437,-12.718987 -4.384679,-12.125 -3.029969,0.503396 -7.255084,-0.229024 -10.676538,-1.067985 -5.854236,-1.435495 -9.868194,-5.982978 -11.974409,-6.547336 z m 12.523856,20.133443 c -3.173125,3.366313 -3.280867,5.186763 -6.498471,4.006973 -2.574573,-0.94401 -0.80779,-3.617506 1.517064,-7.317657 1.953596,-3.529986 5.146861,-3.669661 6.792852,-2.880987 2.549064,1.221381 1.36168,2.825359 -1.811445,6.191671 z"
|
||||
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="M 71.371805,24.042853 C 72.38763,22.731426 65.990638,17.500276 62.529508,15.660996 59.019722,14.053831 55.668618,12.393772 50.643472,11.291727 43.268106,8.3286412 45.55116,2.4014777 43.975669,1.4036601 39.925542,-2.2851948 39.159793,4.383057 37.164116,14.324443 c -7.721778,14.391503 -7.065692,25.850088 -5.050114,26.4972 4.470374,1.435237 5.389738,-0.325068 10.594919,0.51411 6.245042,1.006824 11.256659,6.529478 12.092155,3.002848 0.708073,-2.988771 3.766673,-9.038409 6.194855,-11.590688 4.44462,-4.671769 9.45993,-6.897341 10.375874,-8.70506 z m -28.184982,2.686114 c -1.336242,-4.928584 -1.366585,-8.453652 0.317312,-8.904851 2.125118,-0.569423 2.863597,2.796957 4.552804,7.229228 2.206003,5.78828 5.035336,8.060584 3.604424,9.162751 -1.883975,1.451141 -7.138298,-2.558544 -8.47454,-7.487128 z"
|
||||
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 109.33641,55.830715 c -0.11034,0.01299 -0.21667,0.04389 -0.28125,0.125 -1.36236,1.710966 -3.1317,7.452006 -8.03125,11.03125 -2.844616,2.078062 -7.308728,5.293317 -10.266888,6.120006 -3.689801,1.031154 0.709128,5.964975 2.985019,11.960616 1.871085,4.929213 -1.929553,8.043403 1.753759,10.955004 2.347541,1.855695 16.29852,0.378895 25.38245,-6.113197 4.36016,-2.808938 12.49947,-1.182006 14.08497,-5.585759 1.5855,-4.403753 -8.57491,-1.080782 -11.87681,-8.52417 l -4.46499,-11.276874 c -1.86595,-3.165473 -7.62997,-8.886706 -9.28501,-8.691876 z m 8.52008,23.380046 c -0.36078,2.770582 -6.16422,-2.317005 -10.01431,-3.044842 -4.01357,-0.758742 -10.45728,2.584598 -8.586742,-2.132375 1.870542,-4.716974 19.733412,-2.512878 18.601052,5.177217 z m -11.69646,-0.396999 c 1.28677,-0.03378 3.47878,0.704378 4.61388,1.329449 4.5404,2.500282 1.87736,4.765754 0.65625,5.209998 -1.22111,0.444243 -1.65698,-0.80362 -5.15625,-1.741248 -6.24596,-1.6736 -3.97419,-4.696847 -0.11388,-4.798199 z"
|
||||
style= "fill:rgb(229, 231, 235);fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="M 7.7923024,42.45318 C -2.0077829,65.105972 26.71296,89.696127 39.898909,78.861965 c 6.589995,-12.277406 13.55474,-12.860372 16.40625,-14.125 0.740344,-0.328338 1.139952,-4.761046 4.1875,-5.6875 C 57.283368,53.317038 47.93139,45.388652 36.511142,45.388652 c -5.46819,0 -7.887478,3.97375 -17.00789,4.223313 C 7.8224381,49.931588 9.3031945,41.562351 7.7923024,42.45318 z m 2.9503566,11.283785 c 1.336629,-2.315109 7.039156,3.971663 18.34375,1.78125 12.046196,-2.334108 19.104142,12.252633 14.34375,11.75 C 36.80316,65.420819 37.398401,60.17302 28.711409,60.705715 18.25717,62.45766 9.2181591,58.865339 10.742659,53.736965 z"
|
||||
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 92.02952,26.186975 c -9.125945,0 -15.511413,1.307991 -22.050533,5.416762 -8.050582,5.05848 -14.380046,13.510553 -10.330078,18.445728 6.01863,8.952383 12.21091,11.683583 13.15625,14.65625 1.024544,3.22173 -0.287197,5.399564 -0.915784,6.4616 -13.200216,8.806877 -11.472751,-2.915979 -16.802966,-1.36785 -12.231867,4.082598 -13.837875,15.230592 -11.0625,29.4375 1.175052,6.014995 6.848222,11.962175 11.40625,14.593755 4.558027,2.63158 12.26692,12.14989 8.53125,13.21875 -3.73567,1.06885 -3.913221,5.30111 0.34375,4.46875 4.256971,-0.83235 13.389958,-6.79471 18.84375,-14.0625 12.120315,-16.17878 6.988356,-45.114941 -3.21875,-44.218755 -1.946273,0.170884 -2.393987,-0.266654 -3.965837,-1.191472 -1.066665,-1.066666 0.17251,-3.962407 1.034428,-3.464778 8.075512,4.290847 17.606739,2.020003 23.77516,-7.125 3.27327,-4.852793 6.71183,-11.988102 4.84375,-20.34375 1.60329,-9.459235 9.33591,-5.755361 9.96875,-9.03125 -6.32119,-4.829512 -13.69944,-5.89374 -23.55689,-5.89374 z m -3.818111,6.67499 c 0.643748,0.03294 1.226158,0.288658 1.71875,0.78125 2.627157,2.627156 -5.109856,6.556107 -10.071252,11.517504 -4.961397,4.961396 -7.111819,13.097306 -10.5,11.03125 -3.282729,-2.001753 1.586049,-10.141425 7.375,-15.375 4.582,-4.142415 8.687926,-8.097743 11.477502,-7.955004 z m 6.40625,7.9375 c 3.813127,2.551966 -1.104534,4.705905 -3.026874,6.370624 -1.922341,1.664718 -4.058892,5.929572 -6.548754,3.481878 -4.041067,-7.397924 10.321718,-11.60946 9.575628,-9.852502 z m -1.625,10.65625 c 2.763568,-0.238557 1.756747,4.054721 0.28125,4.4375 -1.815996,0.471113 -5.426439,3.57716 -6.0625,2.5625 -0.636061,-1.01466 -1.540412,-3.676616 3.21875,-6 1.079909,-0.639475 1.924754,-0.944948 2.5625,-1 z m -23.65625,24.25 c 3.662476,0.02877 7.698919,5.806581 8.410935,18.673337 0.702975,12.703388 -1.093993,19.949208 -5.727143,22.322948 -5.408753,-0.577 1.232818,-5.83521 0.552172,-22.47298 -0.512636,-12.530912 -8.234635,-17.625405 -4.481727,-18.17982 0.116865,-0.01726 1.127619,-0.344413 1.245763,-0.343485 z m 11.5,4.78125 c 1.909349,-0.0278 3.33756,3.625376 3.78125,5.28125 0.764187,2.851984 1.679071,6.812796 1,10.4375 -0.795494,4.961315 -4.950395,1.748252 -4.804416,-4.203283 0.14198,-5.788552 -3.95109,-9.436322 -0.820584,-11.265467 0.290641,-0.16982 0.570986,-0.246028 0.84375,-0.25 z m -20.625,1.375 c 4.129893,0.3207 7.680609,16.713233 4.5625,20.875005 -0.235125,0.28828 -0.506327,0.52533 -0.78125,0.75 -3.897922,3.1854 -4.046108,-4.371026 -3.78125,-7.250005 0.313874,-3.411786 -3.81232,-12.40589 -0.40625,-14.34375 0.134237,-0.02406 0.273028,-0.0416 0.40625,-0.03125 z"
|
||||
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
|
||||
<path
|
||||
d="m 62.878316,69.141267 c -1.73286,-1.09783 -1.666444,-3.322247 -0.516173,-5.196413 1.150271,-1.874167 2.816894,-1.773196 4.779929,-0.290318 1.963034,1.482877 1.193223,3.534175 0.274458,4.798645 -0.918765,1.264469 -2.805353,1.785916 -4.538214,0.688086 z"
|
||||
style= "fill:rgb(100, 116, 139);fill-opacity:1;stroke:none" />
|
||||
</svg>
|
After Width: | Height: | Size: 6.5 KiB |
132
web/src/components/App.tsx
Normal file
132
web/src/components/App.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import Chat from "./Chat";
|
||||
import ChatBar from "./ChatBar";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import People from "./People";
|
||||
import { apiDomain } from "../treaty";
|
||||
|
||||
export default function App() {
|
||||
// #region State handling
|
||||
const [socketUrl, setSocketUrl] = useState(apiDomain.replace("http", "ws") + '/api/ws/');
|
||||
const [roomName, setRoomName] = useState(localStorage.lastRoom);
|
||||
const [sendRoomHidden, setSendRoomHidden] = useState<boolean>(localStorage.lastHidden == "true");
|
||||
|
||||
const [accountState, setAccountState] = useState<undefined | {
|
||||
username: string;
|
||||
}>(undefined)
|
||||
|
||||
const [staff, setStaff] = useState<boolean>(false);
|
||||
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [messageHistory, setMessageHistory] = useState<{
|
||||
userID: string;
|
||||
content: string;
|
||||
images?: string[];
|
||||
time: number;
|
||||
username: string;
|
||||
}[]>([]);
|
||||
const [people, setPeople] = useState([]);
|
||||
const [user, setUser] = useState<any>({});
|
||||
const [currentPeopleView, setCurrentPeopleView] = useState("people");
|
||||
|
||||
// #endregion State handling
|
||||
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
|
||||
shouldReconnect: (closeEvent) => {
|
||||
if (accountState == undefined) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sendRoomHidden) localStorage.lastHidden = "true";
|
||||
else localStorage.lastHidden = "false";
|
||||
sendMessage(JSON.stringify({
|
||||
type: "room",
|
||||
room: roomName,
|
||||
hidden: sendRoomHidden
|
||||
}))
|
||||
}, [roomName, sendRoomHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage !== null) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(lastMessage.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type == "hello") {
|
||||
toast.success("Connected to WS!", {
|
||||
position: "bottom-right"
|
||||
})
|
||||
setStaff(data.staff)
|
||||
sendMessage(JSON.stringify({
|
||||
type: "room",
|
||||
room: roomName
|
||||
}))
|
||||
}
|
||||
if (data.type == "notification") {
|
||||
toast(data.message);
|
||||
}
|
||||
if (data.type == "history") {
|
||||
setMessageHistory(data.messages);
|
||||
}
|
||||
if (data.type == "message") {
|
||||
setMessageHistory(z => z.concat([data.message]));
|
||||
}
|
||||
if (data.type == "rooms") {
|
||||
setRooms(data.rooms);
|
||||
}
|
||||
if (data.type == "people") {
|
||||
setPeople(data.people);
|
||||
}
|
||||
if (data.type == "room") {
|
||||
setRoomName(data.room);
|
||||
}
|
||||
if(data.type == "updateUser") {
|
||||
//{type: 'updateUser', updateType: 'pfp', id: 'ieuavxi5nqbqpgow'}
|
||||
|
||||
console.log(data);
|
||||
setPeople(z => {
|
||||
return z.map(g => {
|
||||
if(g.id == data.id) {
|
||||
|
||||
g = {...g,r:Math.random()}
|
||||
if(data.updateType == "status") {
|
||||
g = {...g,r:Math.random(),status:data.data}
|
||||
}
|
||||
}
|
||||
return g;
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [lastMessage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-row w-[100vw] h-[100vh] bg-slate-200 ">
|
||||
<div class="flex-grow max-w-60 flex flex-col bg-slate-300 rounded-tr-xl h-full">
|
||||
<Sidebar setCurrentPeopleView={setCurrentPeopleView} setUser={setUser} setSocketUrl={setSocketUrl} staff={staff} accountState={accountState} setAccountState={setAccountState} setRoomName={setRoomName} roomName={roomName} rooms={rooms} setSendRoomHidden={setSendRoomHidden}></Sidebar>
|
||||
</div>
|
||||
<div class="flex-grow flex flex-col">
|
||||
<div class="h-full overflow-y-scroll">
|
||||
<Chat messageHistory={messageHistory}></Chat>
|
||||
</div>
|
||||
<div class="w-full bg-slate-300 h-15">
|
||||
<ChatBar sendMessage={sendMessage} readyState={readyState}></ChatBar>
|
||||
</div>
|
||||
</div>
|
||||
<People currentPeopleView={currentPeopleView} setCurrentPeopleView={setCurrentPeopleView} user={user} setUser={setUser} people={people} roomName={roomName} staff={staff} messageHistory={messageHistory}></People>
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
)
|
||||
}
|
79
web/src/components/Chat.tsx
Normal file
79
web/src/components/Chat.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { apiDomain } from "../treaty";
|
||||
import Person from "../miniComponents/Person";
|
||||
|
||||
function truncateText(text, length) {
|
||||
let text2 = text;
|
||||
if (length < text.length) {
|
||||
text2 = text2.slice(0, length);
|
||||
text2 += "..."
|
||||
}
|
||||
|
||||
return text2;
|
||||
}
|
||||
export default function Chat({ messageHistory }) {
|
||||
const chatRef = useRef<HTMLDivElement>();
|
||||
|
||||
function SpotifyWidget({ id, type }: { id: string, type: "episode" | "track" }) {
|
||||
return <iframe style="border-radius:12px" src={"https://open.spotify.com/embed/" + type + "/" + id + "?utm_source=generator"} width="100%" height="152" frameBorder="0" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
|
||||
}
|
||||
|
||||
function ChatMessage({ username, id, chatMessage, time, images }) {
|
||||
const [isClosed, setIsClosed] = useState<boolean>(true);
|
||||
function onClick() {
|
||||
setIsClosed(z => !z);
|
||||
}
|
||||
const tenorRegex = /(https:\/\/media\.tenor\.com\/[^\/]*\/[^.]*\.gif)/gm;
|
||||
|
||||
return <div>
|
||||
<Person user={{username,id}} time={time}></Person>
|
||||
<div class="m-2 text-black " onClick={onClick}>
|
||||
{isClosed ? truncateText(chatMessage.replace(tenorRegex, ""), 200) : chatMessage}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="m-2">
|
||||
{(() => {
|
||||
const reg = /.*https:\/\/open.spotify.com\/(track|episode)\/([0-9a-zA-Z]*).*/gm;
|
||||
if(reg.test(chatMessage)) {
|
||||
const type = chatMessage.replace(reg, "$1");
|
||||
const id = chatMessage.replace(reg, "$2");
|
||||
return <SpotifyWidget type={type} id={id}></SpotifyWidget>
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-2">
|
||||
{images.map(z => {
|
||||
return <img src={apiDomain+"/images/" + z} class="max-w-72"></img>
|
||||
})}
|
||||
{
|
||||
[...chatMessage.matchAll(tenorRegex)].map(z => {
|
||||
return <img src={z}></img>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => { // TODO: This is a ugly workaround, please fix ASAP!
|
||||
if(chatRef.current.lastElementChild) {
|
||||
chatRef.current.lastElementChild.scrollIntoView();
|
||||
}
|
||||
}, 200)
|
||||
}, [messageHistory]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="" ref={chatRef}>
|
||||
{
|
||||
messageHistory.map(z => {
|
||||
return <ChatMessage id={z.userID} images={z.images || []} time={z.time} username={z.username} chatMessage={z.content} ></ChatMessage>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
81
web/src/components/ChatBar.tsx
Normal file
81
web/src/components/ChatBar.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import toast from "react-hot-toast";
|
||||
import { ReadyState } from "react-use-websocket";
|
||||
import { trty } from "../treaty";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import Input from "../miniComponents/Input";
|
||||
import GifPicker from "gif-picker-react";
|
||||
|
||||
export default function ChatBar({ sendMessage, readyState, }) {
|
||||
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [qpickerOpen, setGpickerOpen] = useState<boolean>(false);
|
||||
const chatInputRef = useRef<HTMLInputElement>();
|
||||
|
||||
async function uploadImage() {
|
||||
let image_input = document.getElementById("bar_input") as HTMLInputElement
|
||||
if (!image_input) {
|
||||
image_input = document.createElement("input");
|
||||
image_input.style.display = "none";
|
||||
image_input.type = "file"
|
||||
image_input.accept = "image/jpeg, image/png, image/jpg"
|
||||
image_input.id = "bar_input"
|
||||
document.body.appendChild(image_input)
|
||||
|
||||
image_input.addEventListener("change", () => {
|
||||
toast.promise((async () => {
|
||||
const req = await trty.api.profile.uploadImage.post({
|
||||
file: image_input.files[0]
|
||||
})
|
||||
if (!(req.data instanceof Response)) {
|
||||
const uploadId = req.data.uploadId;
|
||||
|
||||
setImages(z => z.concat([uploadId]))
|
||||
}
|
||||
})(), {
|
||||
loading: "Uploading..",
|
||||
success: "Uploaded!",
|
||||
error: "Upload failed. :("
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
image_input.click()
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
//class="h-6 bg-slate-500 text-white rounded-md p-4 w-full"
|
||||
<>
|
||||
<div class="m-2 flex flex-row items-center">
|
||||
<Input extraClass="w-full h-full p-3" ref={chatInputRef} readOnly={readyState !== ReadyState.OPEN} placeholder={readyState == ReadyState.OPEN ? "write something here" : "not connected yet.."} onKeyUp={(e) => {
|
||||
const me = e.target as HTMLInputElement;
|
||||
if (e.code == "Enter" && me.value.trim()) {
|
||||
sendMessage(JSON.stringify({
|
||||
type: "message",
|
||||
message: me.value.trim(),
|
||||
images
|
||||
}));
|
||||
setImages([]);
|
||||
me.value = "";
|
||||
}
|
||||
}}></Input>
|
||||
<button onClick={uploadImage}>
|
||||
<i class="fa-solid fa-upload text-3xl m-2 text-black "></i>
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
setGpickerOpen(z => !z);
|
||||
}}>
|
||||
<i class="fa-solid fa-explosion text-3xl m-2 text-black "></i>
|
||||
</button>
|
||||
<div class="absolute bottom-14 right-0">
|
||||
{ qpickerOpen? <GifPicker onGifClick={(img) => {
|
||||
console.log(img);
|
||||
chatInputRef.current.value += img.url;
|
||||
setGpickerOpen(false);
|
||||
}} tenorApiKey="AIzaSyCDq45efUHa0ZBa9RV7gl3v8WqxyQlb4X0" clientKey={"trillium"}></GifPicker> : ""}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
76
web/src/components/People.tsx
Normal file
76
web/src/components/People.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { useRef } from "preact/hooks";
|
||||
import PeopleView from "./peopleViews/PeopleView";
|
||||
import PersonView from "./peopleViews/PersonView";
|
||||
import Button from "../miniComponents/Button";
|
||||
|
||||
export default function ({
|
||||
roomName,
|
||||
people,
|
||||
staff,
|
||||
messageHistory,
|
||||
user,
|
||||
setUser,
|
||||
currentPeopleView,
|
||||
setCurrentPeopleView
|
||||
}) {
|
||||
const peopleBarRef = useRef<HTMLDivElement>();
|
||||
|
||||
const peopleViews = {
|
||||
people: <PeopleView setUser={setUser} staff={staff} setCurrentPeopleView={setCurrentPeopleView} roomName={roomName} people={people}></PeopleView>,
|
||||
person: <PersonView user={user}></PersonView>
|
||||
}
|
||||
|
||||
return <>
|
||||
<div id="peopleBar" class="bg-slate-300 w-52 overflow-auto flex flex-col break-all" style="display:none;" ref={peopleBarRef}>
|
||||
{
|
||||
(() => {
|
||||
const sidebar = peopleViews[currentPeopleView];
|
||||
return sidebar;
|
||||
})()
|
||||
}
|
||||
|
||||
{
|
||||
(() => {
|
||||
const e = <Button extraClass="mt-auto" onClick={() => {
|
||||
console.log(messageHistory)
|
||||
const blob = new Blob([JSON.stringify(messageHistory)], {type: "application/json"})
|
||||
const elem = window.document.createElement('a');
|
||||
elem.href = window.URL.createObjectURL(blob);
|
||||
elem.download = `${new Date().toUTCString()} trillium logs.json`;
|
||||
document.body.appendChild(elem);
|
||||
elem.click();
|
||||
document.body.removeChild(elem);
|
||||
}}>Export logs</Button>
|
||||
|
||||
if (!staff) return e;
|
||||
if (currentPeopleView == "person") {
|
||||
return <Button extraClass="mt-auto" onClick={
|
||||
() => {
|
||||
setCurrentPeopleView("people");
|
||||
}
|
||||
} >
|
||||
View people
|
||||
</Button>
|
||||
} else if (currentPeopleView == "people") {
|
||||
return e;
|
||||
}
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="absolute top-4 right-6" id="peopleBarButton" onClick={
|
||||
(e) => {
|
||||
const tt = (e.target as HTMLDivElement).parentElement;
|
||||
if (peopleBarRef.current.style.display != "none") {
|
||||
tt.className = tt.className.replace("right-64", 'right-6')
|
||||
peopleBarRef.current.style.display = "none"
|
||||
} else {
|
||||
tt.className = tt.className.replace('right-6', "right-64")
|
||||
|
||||
peopleBarRef.current.style.display = "flex";
|
||||
}
|
||||
}
|
||||
}>
|
||||
<i class="fa-solid fa-ellipsis text-5xl text-black "></i>
|
||||
</button></>;
|
||||
}
|
55
web/src/components/Sidebar.tsx
Normal file
55
web/src/components/Sidebar.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useState } from "preact/hooks"
|
||||
import SidebarAccount from "./sidebarViews/SidebarAccount"
|
||||
import SidebarRooms from "./sidebarViews/SidebarRooms";
|
||||
import SidebarStaff from "./sidebarViews/SidebarStaff";
|
||||
|
||||
export default function Sidebar({ setCurrentPeopleView, setUser, setSocketUrl, staff, setRoomName, roomName, rooms, setSendRoomHidden, accountState, setAccountState }) {
|
||||
const [currentSidebarView, setCurrentSidebarView] = useState<keyof typeof sidebarViews>(localStorage.sidebarView || "account");
|
||||
const sidebarViews = {
|
||||
account: <SidebarAccount setSocketUrl={setSocketUrl} accountState={accountState} setAccountState={setAccountState} currentView={currentSidebarView}></SidebarAccount>,
|
||||
rooms: <SidebarRooms currentView={currentSidebarView} setSendRoomHidden={setSendRoomHidden} setRoomName={setRoomName} roomName={roomName} rooms={rooms}></SidebarRooms>,
|
||||
staff: <SidebarStaff setCurrentPeopleView={setCurrentPeopleView} setUser={setUser} currentView={currentSidebarView}></SidebarStaff>
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.sidebarView = currentSidebarView;
|
||||
}, [currentSidebarView])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="self-top self-center my-5 w-[70%]">
|
||||
<img src="logo.svg"></img>
|
||||
<h1 class='text-center text-5xl font-bold'>trillium</h1>
|
||||
</div>
|
||||
<div class=" text-white h-full overflow-y-auto">
|
||||
{
|
||||
(() => {
|
||||
const sidebar = sidebarViews[currentSidebarView];
|
||||
return sidebar;
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="p-2 rounded-lg m-2 bg-slate-500 text-white text-center">
|
||||
<button class={currentSidebarView == "account" ? "text-green-300" : ""} onClick={() => {
|
||||
setCurrentSidebarView("account")
|
||||
}}>
|
||||
<i class="fa-solid fa-user text-4xl mr-2 hover:underline"></i>
|
||||
</button>
|
||||
<button class={currentSidebarView == "rooms" ? "text-green-300" : ""} onClick={() => {
|
||||
setCurrentSidebarView("rooms")
|
||||
}}>
|
||||
<i class="fa-solid fa-users-rays text-4xl mr-2 hover:underline"></i>
|
||||
</button>
|
||||
{staff ?
|
||||
<button class={currentSidebarView == "staff" ? "text-green-300" : ""} onClick={() => {
|
||||
setCurrentSidebarView("staff")
|
||||
}}>
|
||||
<i class="fa-solid fa-user-shield text-4xl mr-2 hover:underline"></i>
|
||||
</button> : ""}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
21
web/src/components/peopleViews/PeopleView.tsx
Normal file
21
web/src/components/peopleViews/PeopleView.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import toast from "react-hot-toast";
|
||||
import Person from "../../miniComponents/Person";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
export default function ({ people, staff, roomName, setCurrentPeopleView, setUser }) {
|
||||
return <>
|
||||
<h1 class="text-center text-black"><kbd>{roomName}</kbd></h1>
|
||||
<h1 class="text-center text-black">{people.length} pe{people.length == 1 ? "rson" : "ople"}</h1>
|
||||
{people.map(z => {
|
||||
return <Person user={z} viewStatus={true} onClick={async () => {
|
||||
if (staff) {
|
||||
setUser(z);
|
||||
setCurrentPeopleView("person")
|
||||
} else {
|
||||
await navigator.clipboard.writeText(z.id);
|
||||
toast("Copied ID.")
|
||||
}
|
||||
}}></Person>
|
||||
})}
|
||||
</>
|
||||
}
|
119
web/src/components/peopleViews/PersonView.tsx
Normal file
119
web/src/components/peopleViews/PersonView.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import toast from "react-hot-toast";
|
||||
import Button from "../../miniComponents/Button";
|
||||
import Input from "../../miniComponents/Input";
|
||||
import Person from "../../miniComponents/Person";
|
||||
import { trty } from "../../treaty";
|
||||
|
||||
export default function ({ user }) {
|
||||
|
||||
const [accountState, setAccountState] = useState<undefined | {
|
||||
isOnline: boolean,
|
||||
rooms: string[],
|
||||
name: string
|
||||
}>(undefined)
|
||||
|
||||
const banDialogRef = useRef<HTMLDialogElement>()
|
||||
const banPermanentRef = useRef<HTMLInputElement>()
|
||||
const banReasonRef = useRef<HTMLInputElement>()
|
||||
const banTimeRef = useRef<HTMLInputElement>()
|
||||
|
||||
const muteDialogRef = useRef<HTMLDialogElement>()
|
||||
const mutePermanentRef = useRef<HTMLInputElement>()
|
||||
const muteReasonRef = useRef<HTMLInputElement>()
|
||||
const muteTimeRef = useRef<HTMLInputElement>()
|
||||
|
||||
useEffect(() => {
|
||||
async function asyncFunc() {
|
||||
const data = (await trty.api.staff.isUserOnline.post({
|
||||
userId: user.id
|
||||
})).data;
|
||||
if (data) {
|
||||
setAccountState(data as {
|
||||
isOnline: boolean,
|
||||
rooms: string[],
|
||||
name: string
|
||||
})
|
||||
} else {
|
||||
setAccountState(undefined)
|
||||
}
|
||||
}
|
||||
asyncFunc()
|
||||
}, [user])
|
||||
|
||||
return <>
|
||||
<dialog class="bg-slate-400 p-5 rounded-xl" ref={banDialogRef}>
|
||||
<div class="flex flex-col items-center" >
|
||||
<Input placeholder="Reason" extraClass="mb-1" ref={banReasonRef}></Input>
|
||||
<Input placeholder="Time (in MS)" extraClass="mb-1" type="number" ref={banTimeRef}></Input>
|
||||
<div class="mb-2">
|
||||
<Input type="checkbox" ref={banPermanentRef}></Input> is Permanent?
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
banDialogRef.current.close();
|
||||
const time = banTimeRef.current.value;
|
||||
const reason = banReasonRef.current.value;
|
||||
const isPermanent = banPermanentRef.current.checked;
|
||||
|
||||
toast.promise((async () => {
|
||||
await trty.api.staff.ban.post({
|
||||
id: user.id,
|
||||
reason: reason,
|
||||
ms: +time,
|
||||
isPermanent: isPermanent,
|
||||
})
|
||||
})(), {
|
||||
loading: "Banning..",
|
||||
success: "Banned!",
|
||||
error: "Failed to ban."
|
||||
})
|
||||
}}>Ban them</Button>
|
||||
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog class="bg-slate-400 p-5 rounded-xl" ref={muteDialogRef}>
|
||||
<div class="flex flex-col items-center" >
|
||||
<Input placeholder="Reason" extraClass="mb-1" ref={muteReasonRef}></Input>
|
||||
<Input placeholder="Time (in MS)" extraClass="mb-1" type="number" ref={muteTimeRef}></Input>
|
||||
<div class="mb-2">
|
||||
<Input type="checkbox" ref={mutePermanentRef}></Input> is Permanent?
|
||||
</div>
|
||||
|
||||
<Button onClick={() => {
|
||||
muteDialogRef.current.close();
|
||||
const time = muteTimeRef.current.value;
|
||||
const reason = muteReasonRef.current.value;
|
||||
const isPermanent = mutePermanentRef.current.checked;
|
||||
|
||||
toast.promise((async () => {
|
||||
await trty.api.staff.mute.post({
|
||||
id: user.id,
|
||||
reason: reason,
|
||||
ms: +time,
|
||||
isPermanent: isPermanent,
|
||||
})
|
||||
})(), {
|
||||
loading: "Muting..",
|
||||
success: "Muted!",
|
||||
error: "Failed to mute."
|
||||
})
|
||||
}}>Mute them</Button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{accountState ? <>
|
||||
<Person user={{id: user.id,username:accountState.name}}></Person>
|
||||
|
||||
<p class="text-center text-black">This user is currently <strong>{accountState.isOnline ? "online" : "offline"}.</strong></p>
|
||||
{accountState.isOnline ? <p class="text-center text-black">They are in the room/s <strong>{accountState.rooms.join(", ")}</strong></p> : ""}
|
||||
<Button extraClass="font-bold" onClick={async () => {
|
||||
await navigator.clipboard.writeText(user.id)
|
||||
toast("Copied ID.")
|
||||
}}>{user.id}<br></br>(click to copy)</Button>
|
||||
<Button onClick={() => banDialogRef.current.showModal()}>Ban</Button>
|
||||
<Button onClick={() => muteDialogRef.current.showModal()}>Mute</Button>
|
||||
</> : "Loading.."}
|
||||
</>
|
||||
}
|
188
web/src/components/sidebarViews/SidebarAccount.tsx
Normal file
188
web/src/components/sidebarViews/SidebarAccount.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import toast from 'react-hot-toast';
|
||||
import { useGoogleReCaptcha } from "react-google-recaptcha-v3";
|
||||
import Button from "../../miniComponents/Button";
|
||||
import Input from "../../miniComponents/Input";
|
||||
import { trty } from "../../treaty";
|
||||
|
||||
export default function SidebarAccount({ setSocketUrl, currentView, accountState, setAccountState }) {
|
||||
|
||||
const rerun = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function asyncFunc() {
|
||||
const data = (await trty.api.auth.userInfo.get()).data;
|
||||
if (data) {
|
||||
setAccountState(data as { username: string })
|
||||
} else {
|
||||
setAccountState(undefined)
|
||||
}
|
||||
}
|
||||
asyncFunc()
|
||||
}, [rerun])
|
||||
|
||||
|
||||
function AccountSignedIn() {
|
||||
const statusRef = useRef<HTMLInputElement>();
|
||||
|
||||
async function handleLogout() {
|
||||
await trty.api.auth.logout.get();
|
||||
rerun[1](a => !a)
|
||||
}
|
||||
async function uploadImage() {
|
||||
let image_input = document.getElementById("profile_picture_input") as HTMLInputElement
|
||||
if (!image_input) {
|
||||
image_input = document.createElement("input");
|
||||
image_input.style.display = "none";
|
||||
image_input.type = "file"
|
||||
image_input.accept = "image/jpeg, image/png, image/jpg"
|
||||
image_input.id = "profile_picture_input"
|
||||
document.body.appendChild(image_input)
|
||||
|
||||
image_input.addEventListener("change", () => {
|
||||
toast.promise((async () => {
|
||||
await trty.api.profile.setProfilePicture.post({
|
||||
file: image_input.files[0]
|
||||
})
|
||||
})(), {
|
||||
loading: "Uploading..",
|
||||
success: "Uploaded!",
|
||||
error: "Upload failed. :("
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
image_input.click()
|
||||
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-center text-xl text-white ">Signed in as <strong>{accountState.username}</strong></h1>
|
||||
<Button onClick={handleLogout}>Logout</Button>
|
||||
<Button onClick={uploadImage}>
|
||||
<div class="flex items-center text-center">
|
||||
<i class="fa-solid fa-image text-3xl text-slate-500 mr-2"></i>Set a profile picture
|
||||
</div>
|
||||
</Button>
|
||||
<Input placeholder="Set a status!" ref={statusRef}></Input>
|
||||
<Button onClick={() => {
|
||||
toast.promise((async () => {
|
||||
if(statusRef.current.value.trim()) {
|
||||
await trty.api.profile.setStatus.post({
|
||||
status: statusRef.current.value
|
||||
});
|
||||
}
|
||||
})(), {
|
||||
success: "Status set!",
|
||||
loading: "Setting status..",
|
||||
error: "Failed to set status."
|
||||
})
|
||||
}}>Set status</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountSignedOut() {
|
||||
const usernameRef = useRef<HTMLInputElement>();
|
||||
const passwordRef = useRef<HTMLInputElement>();
|
||||
const { executeRecaptcha } = useGoogleReCaptcha();
|
||||
|
||||
async function handleSignIn() {
|
||||
const username = usernameRef.current.value.trim();
|
||||
const password = passwordRef.current.value.trim();
|
||||
if (!username || !password) {
|
||||
toast.error("Missing username or password!", {
|
||||
position: "bottom-right",
|
||||
})
|
||||
return;
|
||||
}
|
||||
const loadingToast = toast.loading("Logging in..", {
|
||||
position: "bottom-right",
|
||||
});
|
||||
const token = await executeRecaptcha("login");
|
||||
const request = await trty.api.auth.login.post({
|
||||
password: password,
|
||||
username: username,
|
||||
recaptcha: token
|
||||
})
|
||||
if (request.status != 200) {
|
||||
toast.error(request.error.value as unknown as string, {
|
||||
position: "bottom-right",
|
||||
});
|
||||
toast.remove(loadingToast);
|
||||
|
||||
return;
|
||||
}
|
||||
toast.success(`You've logged into ${username}!`, {
|
||||
position: "bottom-right",
|
||||
})
|
||||
toast.remove(loadingToast);
|
||||
setSocketUrl(z => z.replace(/\?.*$/gm, "") + "?a=" + Math.random())
|
||||
rerun[1](a => !a)
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const username = usernameRef.current.value.trim();
|
||||
const password = passwordRef.current.value.trim();
|
||||
if (!username || !password) {
|
||||
toast.error("Missing username or password!", {
|
||||
position: "bottom-right",
|
||||
})
|
||||
return;
|
||||
}
|
||||
const loadingToast = toast.loading("Registering..", {
|
||||
position: "bottom-right",
|
||||
});
|
||||
const token = await executeRecaptcha("register");
|
||||
const request = await trty.api.auth.register.post({
|
||||
password: password,
|
||||
username: username,
|
||||
recaptcha: token
|
||||
})
|
||||
if (request.status != 200) {
|
||||
toast.error(request.error.value as unknown as string, {
|
||||
position: "bottom-right",
|
||||
});
|
||||
toast.remove(loadingToast);
|
||||
|
||||
return;
|
||||
}
|
||||
toast.success(`Your account ${username} has been registered!`, {
|
||||
position: "bottom-right",
|
||||
})
|
||||
toast.remove(loadingToast);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-col">
|
||||
<Input placeholder="Username" extraClass="mb-1" type="username" ref={usernameRef}></Input>
|
||||
<Input placeholder="Password" extraClass="mb-1" type="password" ref={passwordRef}></Input>
|
||||
<Button onClick={handleSignIn}>Sign In</Button>
|
||||
<Button onClick={handleRegister}>Register</Button>
|
||||
<div class="text-sm m-2">
|
||||
This site is protected by reCAPTCHA and the Google <a class="text-gray-400" href="https://policies.google.com/privacy">Privacy Policy</a> and <a href="https://policies.google.com/terms" class="text-gray-400">Terms of Service</a> apply.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 class="text-center text-3xl text-white ">Account</h1>
|
||||
{
|
||||
(() => {
|
||||
if (!accountState) {
|
||||
return <AccountSignedOut></AccountSignedOut>
|
||||
} else {
|
||||
return <AccountSignedIn></AccountSignedIn>
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
60
web/src/components/sidebarViews/SidebarRooms.tsx
Normal file
60
web/src/components/sidebarViews/SidebarRooms.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { useRef } from "preact/hooks";
|
||||
import Button from "../../miniComponents/Button";
|
||||
import Input from "../../miniComponents/Input";
|
||||
|
||||
export default function SidebarRooms({ setSendRoomHidden, setRoomName, roomName, currentView, rooms }) {
|
||||
const dialogRef = useRef<HTMLDialogElement>();
|
||||
const roomInputRef = useRef<HTMLInputElement>();
|
||||
const roomIsHiddenRef = useRef<HTMLInputElement>();
|
||||
|
||||
function createRoomDialog() {
|
||||
dialogRef.current.showModal();
|
||||
}
|
||||
|
||||
function createRoom() {
|
||||
const input = roomInputRef.current.value.trim();
|
||||
if (input) {
|
||||
setSendRoomHidden(roomIsHiddenRef.current.checked);
|
||||
localStorage.lastRoom = input;
|
||||
setRoomName(input);
|
||||
dialogRef.current.close();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dialog ref={dialogRef} class="bg-slate-400 p-5 rounded-xl">
|
||||
<div class="flex flex-col">
|
||||
<Input ref={roomInputRef} placeholder="My Awsome Room"></Input>
|
||||
<div class="text-center text-white my-2">
|
||||
<Input type="checkbox" extraClasses="mr-2" ref={roomIsHiddenRef}></Input>
|
||||
Is room hidden?
|
||||
</div>
|
||||
<Button onClick={createRoom}>Create room</Button>
|
||||
|
||||
</div>
|
||||
</dialog>
|
||||
<h1 class="text-center text-3xl text-white ">Rooms</h1>
|
||||
<div class="flex flex-col">
|
||||
{
|
||||
rooms ?
|
||||
rooms.map(z => {
|
||||
return <Button onClick={
|
||||
() => {
|
||||
setSendRoomHidden(false);
|
||||
setRoomName(z.name)
|
||||
}
|
||||
} extraClass={roomName == z.name ? "bg-green-200" : "bg-slate-400"}>
|
||||
<div class="text-2xl">{z.name}</div>
|
||||
<div>{z.count} pe{z.count == 1 ? "rson" : "ople"}</div>
|
||||
</Button>
|
||||
}) : <h1 class="text-2xl text-black text-center">Loading..</h1>
|
||||
}
|
||||
<Button onClick={createRoomDialog}>
|
||||
Create your own room
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
63
web/src/components/sidebarViews/SidebarStaff.tsx
Normal file
63
web/src/components/sidebarViews/SidebarStaff.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { useRef, useState } from "preact/hooks";
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from "../../miniComponents/Button";
|
||||
import Input from "../../miniComponents/Input";
|
||||
import { trty } from "../../treaty";
|
||||
|
||||
export default function SidebarStaff({ setCurrentPeopleView, currentView, setUser }) {
|
||||
|
||||
const userIdRef = useRef<HTMLInputElement>();
|
||||
const [punishments, setPunishments] = useState<undefined | any[]>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 class="text-center text-3xl text-white">Staff</h1>
|
||||
|
||||
<div class="border-b-4">
|
||||
<Input placeholder="user ID" extraClass="m-0" ref={userIdRef}></Input>
|
||||
<Button extraClass="m-0 py-2 my-2 w-full" onClick={async () => {
|
||||
const req = await trty.api.staff.punishments.post({
|
||||
userId: userIdRef.current.value
|
||||
})
|
||||
if (req.data) setPunishments(req.data as any);
|
||||
}}>See punishments</Button>
|
||||
<Button extraClass="m-0 py-2 mb-2 w-full" onClick={() => {
|
||||
const bar = document.getElementById("peopleBar");
|
||||
const barButton = document.getElementById("peopleBarButton");
|
||||
if (bar.style.display !== "flex") {
|
||||
bar.style.display = "flex";
|
||||
barButton.className = barButton.className.replace('right-6', "right-64")
|
||||
}
|
||||
|
||||
setCurrentPeopleView("person");
|
||||
setUser({ id: userIdRef.current.value });
|
||||
}}>User actions</Button>
|
||||
</div>
|
||||
|
||||
{punishments === undefined ? "No user searched." : punishments.length == 0 ? "This user has no punishments!" : punishments.map((z, i) => {
|
||||
return <div class={(i == 0 ? "" : "border-t-2") + " text-black"}>
|
||||
<Button extraClass="p-2 m-1" onClick={() => {
|
||||
toast.promise((async () => {
|
||||
await trty.api.staff.invalidatePunishment.post({
|
||||
punishmentId: z.id
|
||||
})
|
||||
})(), {
|
||||
loading: "Invalidating punishment..",
|
||||
success: "Invalidated.",
|
||||
error: "Failed to invalidate!"
|
||||
})
|
||||
}}>Remove punishment</Button>
|
||||
<div><i class={"text-white fa-solid fa-" + (z.type == "ban" ? "hammer-crash" : "comment-slash")}></i> {z.type.toUpperCase()}</div>
|
||||
<div>ID: <strong>{z.id}</strong></div>
|
||||
<div>Reason: <strong>{z.reason}</strong></div>
|
||||
<div>Banned by: <strong>{z.staffId}</strong></div>
|
||||
<div>Time: <strong>{z.time}ms</strong></div>
|
||||
<div>Banned at: <strong>{z.at.toString()}</strong></div>
|
||||
|
||||
</div>
|
||||
})}
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
8
web/src/index.css
Normal file
8
web/src/index.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
}
|
35
web/src/index.tsx
Normal file
35
web/src/index.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
What if I never make it back from this damn panic attack?
|
||||
GTB sliding in a Hellcat bumping From First to Last
|
||||
When I die they're gonna make a park bench saying "This where he sat"
|
||||
Me and Yung Sherman going rehab, this shit is very sad
|
||||
Me and Yung Sherm in Venice Beach, man, this shit is very rad
|
||||
Me and Yung Sherman at the gym working out and getting tanned
|
||||
I never will see you again and I hope you understand
|
||||
I'm crashing down some like a wave over castles made of sand
|
||||
*/
|
||||
|
||||
import { render } from 'preact';
|
||||
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
|
||||
export function combineTailwind(first, second) {
|
||||
if(!second) return first;
|
||||
const secondSplit = second.split(" ")
|
||||
let classes = [];
|
||||
|
||||
const secondIds = secondSplit.map(z => z.split("-")[0]);
|
||||
|
||||
for(const defaultClass of first.split(" ")) {
|
||||
if(!secondIds.includes(defaultClass.split("-")[0])) {
|
||||
classes.push(defaultClass);
|
||||
}
|
||||
}
|
||||
|
||||
return classes.concat(secondSplit).join(" ")
|
||||
}
|
||||
|
||||
|
||||
import './index.css'
|
||||
import App from './components/App';
|
||||
|
||||
render(
|
||||
<GoogleReCaptchaProvider reCaptchaKey='6Lc8vOApAAAAAHAZlkLXSGe4Qe2J3gLMqRtodETV'><App /></GoogleReCaptchaProvider>, document.getElementById('app'));
|
6
web/src/miniComponents/Button.tsx
Normal file
6
web/src/miniComponents/Button.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { combineTailwind } from "..";
|
||||
|
||||
export default function Button({onClick = undefined, extraClass = "", children}) {
|
||||
const combined = combineTailwind("bg-slate-200 text-black m-2 p-4 rounded-md", extraClass);
|
||||
return <button class={combined} onClick={onClick}>{children}</button>;
|
||||
}
|
7
web/src/miniComponents/Input.tsx
Normal file
7
web/src/miniComponents/Input.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { ForwardedRef, forwardRef } from "preact/compat";
|
||||
import { combineTailwind } from "..";
|
||||
|
||||
export default forwardRef(function Input(props: any, ref: ForwardedRef<HTMLInputElement>) {
|
||||
const cssClass = combineTailwind("text-white bg-slate-500 rounded-md mx-2 mb-1 pl-2", props.extraClass)
|
||||
return <input class={cssClass} placeholder={props.placeholder} ref={ref} type={props.type} onKeyUp={props.onKeyUp}></input>;
|
||||
})
|
34
web/src/miniComponents/Person.tsx
Normal file
34
web/src/miniComponents/Person.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import toast from "react-hot-toast";
|
||||
import { apiDomain } from "../treaty";
|
||||
|
||||
export async function copyId(id) {
|
||||
await navigator.clipboard.writeText(id);
|
||||
toast("Copied ID.")
|
||||
}
|
||||
|
||||
export default function Person({ onClick = undefined, user, time = undefined, viewStatus = false }) {
|
||||
if (!onClick) onClick = _ => copyId(user.id);
|
||||
let hasEmoji = "";
|
||||
let status = "";
|
||||
|
||||
if (user.status && viewStatus) {
|
||||
status = user.status;
|
||||
const segmented = user.status.split(" ");
|
||||
const isFirstEmoji = /\p{Emoji_Presentation}/gu.test(segmented[0])
|
||||
if (isFirstEmoji) {
|
||||
hasEmoji = segmented[0]
|
||||
status = segmented.slice(1).join(" ");
|
||||
}
|
||||
}
|
||||
return <button class="flex text-black flex-col m-2" onClick={onClick}>
|
||||
<div class="flex-row flex items-center">
|
||||
<img src={apiDomain + "/pfps/" + user.id + "?r=" + user.r} class="w-12 h-12 rounded-full" onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = "https://placehold.co/400";
|
||||
}}></img>
|
||||
<div class="text-2xl ml-2">{user.username}</div>
|
||||
{time ? <span class="ml-2 text-slate-500 text-sm">{new Date(time).toUTCString()}</span> : ""}
|
||||
</div>
|
||||
{user.status && viewStatus ?
|
||||
<div class="text-sm text-left">{hasEmoji ? <span class="text-xl">{hasEmoji} </span> : ""}{status}</div> : ""}
|
||||
</button>
|
||||
}
|
15
web/src/treaty.tsx
Normal file
15
web/src/treaty.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
import { treaty } from '@elysiajs/eden'
|
||||
|
||||
import type { App } from '../../server/src/index'
|
||||
export let apiDomain = 'https://api.chat.sad.ovh';
|
||||
|
||||
if(location.hostname == "127.0.0.1" || location.hostname == "localhost") {
|
||||
apiDomain = "http://localhost:8001"
|
||||
}
|
||||
//@ts-ignore
|
||||
export const trty = treaty<App>(apiDomain, {
|
||||
fetch: {
|
||||
credentials: "include"
|
||||
}
|
||||
});
|
11
web/tailwind.config.js
Normal file
11
web/tailwind.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
darkMode: "class",
|
||||
plugins: [],
|
||||
}
|
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"noErrorTruncation": true,
|
||||
/* Preact Config */
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"react": ["./node_modules/preact/compat/"],
|
||||
"react-dom": ["./node_modules/preact/compat/"]
|
||||
}
|
||||
},
|
||||
"include": ["node_modules/vite/client.d.ts", "**/*"]
|
||||
}
|
7
web/vite.config.ts
Normal file
7
web/vite.config.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
});
|
Loading…
Reference in a new issue