mirror of
https://github.com/thomiceli/opengist.git
synced 2024-12-22 12:32:40 +00:00
Initial commit
This commit is contained in:
commit
bee5d045c3
52 changed files with 8560 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
templates/**/* linguist-vendored
|
||||
public/**/* linguist-vendored
|
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
gist.db
|
||||
.idea/
|
||||
.DS_Store
|
||||
/**/.DS_Store
|
||||
public/assets/*
|
||||
public/manifest.json
|
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/>.
|
49
config.yml
Normal file
49
config.yml
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Directory where Opengist will store its data. Default: ~/.opengist/
|
||||
opengist-home:
|
||||
|
||||
# Name of the SQLite database file. Default: opengist.db
|
||||
db-filename: opengist.db
|
||||
|
||||
# Prevents the creation of new accounts (either `true` or `false`). Default: false
|
||||
disable-signup: false
|
||||
|
||||
# Set the log level to one of the following: trace, debug, info, warn, error, fatal, panic. Default: warn
|
||||
log-level: warn
|
||||
|
||||
# HTTP server configuration
|
||||
http:
|
||||
|
||||
# Host to bind to. Default: 0.0.0.0
|
||||
host: 0.0.0.0
|
||||
|
||||
# Port to bind to. Default: 6157
|
||||
port: 6157
|
||||
|
||||
# Domain to use in links. Default: localhost
|
||||
domain: localhost
|
||||
|
||||
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
|
||||
git-enabled: true
|
||||
|
||||
# SSH built-in server configuration
|
||||
# Note: it is not using the SSH daemon from your machine (yet)
|
||||
ssh:
|
||||
|
||||
# Enable or disable SSH built-in server
|
||||
# for git operations (clone, pull, push) via SSH (either `true` or `false`). Default: true
|
||||
enabled: true
|
||||
|
||||
# Host to bind to. Default: 0.0.0.0
|
||||
host: 0.0.0.0
|
||||
|
||||
# Port to bind to. Default: 2222
|
||||
# Note: it cannot be the same port as the SSH daemon if it's currently running
|
||||
# If you want to use the port 22 for the built-in SSH server,
|
||||
# you can either change the port of the SSH daemon or stop it
|
||||
port: 2222
|
||||
|
||||
# Domain to use in links. Default: localhost
|
||||
domain: localhost
|
||||
|
||||
# Path or alias to ssh-keygen executable. Default: ssh-keygen
|
||||
keygen-executable: ssh-keygen
|
35
go.mod
Normal file
35
go.mod
Normal file
|
@ -0,0 +1,35 @@
|
|||
module opengist
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.11.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/labstack/echo/v4 v4.10.0
|
||||
github.com/rs/zerolog v1.29.0
|
||||
golang.org/x/crypto v0.2.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/sqlite v1.3.2
|
||||
gorm.io/gorm v1.23.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.13 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/net v0.4.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.5.0 // indirect
|
||||
golang.org/x/time v0.2.0 // indirect
|
||||
)
|
110
go.sum
Normal file
110
go.sum
Normal file
|
@ -0,0 +1,110 @@
|
|||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw=
|
||||
github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.10.0 h1:5CiyngihEO4HXsz3vVsJn7f8xAlWwRr3aY6Ih280ZKA=
|
||||
github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ=
|
||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
|
||||
github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE=
|
||||
golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE=
|
||||
golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.3.2 h1:nWTy4cE52K6nnMhv23wLmur9Y3qWbZvOBz+V4PrGAxg=
|
||||
gorm.io/driver/sqlite v1.3.2/go.mod h1:B+8GyC9K7VgzJAcrcXMRPdnMcck+8FgJynEehEPM16U=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.5 h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM=
|
||||
gorm.io/gorm v1.23.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
83
internal/config/config.go
Normal file
83
internal/config/config.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var OpengistVersion = "0.0.1"
|
||||
|
||||
var C *config
|
||||
|
||||
type config struct {
|
||||
OpengistHome string `yaml:"opengist-home"`
|
||||
DBFilename string `yaml:"db-filename"`
|
||||
DisableSignup bool `yaml:"disable-signup"`
|
||||
LogLevel string `yaml:"log-level"`
|
||||
|
||||
HTTP struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Domain string `yaml:"domain"`
|
||||
Git bool `yaml:"git-enabled"`
|
||||
} `yaml:"http"`
|
||||
|
||||
SSH struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Domain string `yaml:"domain"`
|
||||
Keygen string `yaml:"keygen-executable"`
|
||||
} `yaml:"ssh"`
|
||||
}
|
||||
|
||||
func InitConfig(configPath string) error {
|
||||
c := &config{}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.OpengistHome = filepath.Join(homeDir, ".opengist")
|
||||
c.LogLevel = "warn"
|
||||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
d := yaml.NewDecoder(file)
|
||||
if err = d.Decode(&c); err != nil {
|
||||
return err
|
||||
}
|
||||
C = c
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitLog() {
|
||||
if err := os.MkdirAll(filepath.Join(GetHomeDir(), "log"), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
file, err := os.OpenFile(filepath.Join(GetHomeDir(), "log", "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
multi := zerolog.MultiLevelWriter(zerolog.NewConsoleWriter(), file)
|
||||
|
||||
var level zerolog.Level
|
||||
level, err = zerolog.ParseLevel(C.LogLevel)
|
||||
if err != nil {
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
log.Logger = zerolog.New(multi).Level(level).With().Timestamp().Logger()
|
||||
}
|
||||
|
||||
func GetHomeDir() string {
|
||||
absolutePath, _ := filepath.Abs(C.OpengistHome)
|
||||
return filepath.Clean(absolutePath)
|
||||
}
|
293
internal/git/commands.go
Normal file
293
internal/git/commands.go
Normal file
|
@ -0,0 +1,293 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
"opengist/internal/config"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetRepositoryPath(user string, gist string) (string, error) {
|
||||
return filepath.Join(config.GetHomeDir(), "repos", strings.ToLower(user), gist), nil
|
||||
}
|
||||
|
||||
func getTmpRepositoryPath(gistId string) (string, error) {
|
||||
dirname, err := getTmpRepositoriesPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dirname, gistId), nil
|
||||
}
|
||||
|
||||
func getTmpRepositoriesPath() (string, error) {
|
||||
return filepath.Join(config.GetHomeDir(), "tmp", "repos"), nil
|
||||
}
|
||||
|
||||
func InitRepository(user string, gist string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"init",
|
||||
"--bare",
|
||||
repositoryPath,
|
||||
)
|
||||
|
||||
_, err = cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f1, err := os.OpenFile(filepath.Join(repositoryPath, "git-daemon-export-ok"), os.O_RDONLY|os.O_CREATE, 0644)
|
||||
defer f1.Close()
|
||||
|
||||
preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", "pre-receive"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0744)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
preReceiveSrc, err := os.OpenFile(filepath.Join("internal", "resources", "pre-receive"), os.O_RDONLY, os.ModeAppend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(preReceiveDst, preReceiveSrc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer preReceiveDst.Close()
|
||||
defer preReceiveSrc.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func GetNumberOfCommitsOfRepository(user string, gist string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"rev-list",
|
||||
"--all",
|
||||
"--count",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return strings.TrimSuffix(string(stdout), "\n"), err
|
||||
}
|
||||
|
||||
func GetFilesOfRepository(user string, gist string, commit string) ([]string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"ls-tree",
|
||||
commit,
|
||||
"--name-only",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slice := strings.Split(string(stdout), "\n")
|
||||
return slice[:len(slice)-1], nil
|
||||
}
|
||||
|
||||
func GetFileContent(user string, gist string, commit string, filename string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"--no-pager",
|
||||
"show",
|
||||
commit+":"+filename,
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return string(stdout), err
|
||||
}
|
||||
|
||||
func GetLog(user string, gist string, skip string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"--no-pager",
|
||||
"log",
|
||||
"-n",
|
||||
"11",
|
||||
"--no-prefix",
|
||||
"--no-color",
|
||||
"-p",
|
||||
"--skip",
|
||||
skip,
|
||||
"--format=format:%n=commit %H:%aN:%at",
|
||||
"--shortstat",
|
||||
"--ignore-missing", // avoid errors if a wrong hash is given
|
||||
"HEAD",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return string(stdout), err
|
||||
}
|
||||
|
||||
func CloneTmp(user string, gist string, gistTmpId string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath, err := getTmpRepositoriesPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpRepositoryPath := path.Join(tmpPath, gistTmpId)
|
||||
|
||||
err = os.RemoveAll(tmpRepositoryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "clone", repositoryPath, gistTmpId)
|
||||
cmd.Dir = tmpPath
|
||||
if err = cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("git", "config", "user.name", user)
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
if err = cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove every file (and not the .git directory!)
|
||||
cmd = exec.Command("find", ".", "-maxdepth", "1", "-type", "f", "-delete")
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func SetFileContent(gistTmpId string, filename string, content string) error {
|
||||
repositoryPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func AddAll(gistTmpId string) error {
|
||||
tmpPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// in case of a change where only a file name has its case changed
|
||||
cmd := exec.Command("git", "rm", "-r", "--cached", "--ignore-unmatch", ".")
|
||||
cmd.Dir = tmpPath
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("git", "add", "-A")
|
||||
cmd.Dir = tmpPath
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func Commit(gistTmpId string) error {
|
||||
cmd := exec.Command("git", "commit", "--allow-empty", "-m", `"Opengist commit"`)
|
||||
tmpPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Dir = tmpPath
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func Push(gistTmpId string) error {
|
||||
tmpRepositoryPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"push",
|
||||
)
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.RemoveAll(tmpRepositoryPath)
|
||||
}
|
||||
|
||||
func DeleteRepository(user string, gist string) error {
|
||||
return os.RemoveAll(filepath.Join(config.GetHomeDir(), "repos", strings.ToLower(user), gist))
|
||||
}
|
||||
|
||||
func UpdateServerInfo(user string, gist string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "update-server-info")
|
||||
cmd.Dir = repositoryPath
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func RPCRefs(user string, gist string, service string) ([]byte, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", service, "--stateless-rpc", "--advertise-refs", ".")
|
||||
cmd.Dir = repositoryPath
|
||||
stdout, err := cmd.Output()
|
||||
return stdout, err
|
||||
}
|
||||
|
||||
func GetGitVersion() (string, error) {
|
||||
cmd := exec.Command("git", "--version")
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
versionFields := strings.Fields(string(stdout))
|
||||
if len(versionFields) < 3 {
|
||||
return string(stdout), nil
|
||||
}
|
||||
|
||||
return versionFields[2], nil
|
||||
}
|
31
internal/models/db.go
Normal file
31
internal/models/db.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
func Setup(dbpath string) error {
|
||||
var err error
|
||||
|
||||
if db, err = gorm.Open(sqlite.Open(dbpath+"?_fk=true"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(&User{}, &SSHKey{}, &Gist{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CountAll(table interface{}) (int64, error) {
|
||||
var count int64
|
||||
err := db.Model(table).Count(&count).Error
|
||||
return count, err
|
||||
}
|
136
internal/models/gist.go
Normal file
136
internal/models/gist.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Gist struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Uuid string
|
||||
Title string `validate:"max=50" form:"title"`
|
||||
Preview string
|
||||
PreviewFilename string
|
||||
Description string `validate:"max=150" form:"description"`
|
||||
Private bool `form:"private"`
|
||||
UserID uint
|
||||
User User `validate:"-"`
|
||||
NbFiles int
|
||||
NbLikes int
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
|
||||
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
|
||||
Files []File `gorm:"-" validate:"min=1,dive"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=50"`
|
||||
OldFilename string `validate:"excludes=\x2f,excludes=\x5c,max=50"`
|
||||
Content string `validate:"required"`
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Hash string
|
||||
Author string
|
||||
Timestamp string
|
||||
Changed string
|
||||
Files []File
|
||||
}
|
||||
|
||||
func GetGist(user string, gistUuid string) (*Gist, error) {
|
||||
gist := new(Gist)
|
||||
err := db.Preload("User").
|
||||
Where("gists.uuid = ? AND users.username like ?", gistUuid, user).
|
||||
Joins("join users on gists.user_id = users.id").
|
||||
First(&gist).Error
|
||||
|
||||
return gist, err
|
||||
}
|
||||
|
||||
func GetGistByID(gistId string) (*Gist, error) {
|
||||
gist := new(Gist)
|
||||
err := db.Preload("User").
|
||||
Where("gists.id = ?", gistId).
|
||||
First(&gist).Error
|
||||
|
||||
return gist, err
|
||||
}
|
||||
|
||||
func GetAllGistsForCurrentUser(currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
err := db.Preload("User").
|
||||
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order(sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
|
||||
return gists, err
|
||||
}
|
||||
|
||||
func GetAllGists(offset int) ([]*Gist, error) {
|
||||
var all []*Gist
|
||||
err := db.Preload("User").
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("id asc").
|
||||
Find(&all).Error
|
||||
|
||||
return all, err
|
||||
}
|
||||
|
||||
func GetAllGistsFromUser(fromUser string, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
err := db.Preload("User").
|
||||
Where("users.username = ? and ((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", fromUser, currentUserId).
|
||||
Joins("join users on gists.user_id = users.id").
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("gists." + sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
|
||||
return gists, err
|
||||
}
|
||||
|
||||
func CreateGist(gist *Gist) error {
|
||||
return db.Create(&gist).Error
|
||||
}
|
||||
|
||||
func UpdateGist(gist *Gist) error {
|
||||
return db.Save(&gist).Error
|
||||
}
|
||||
|
||||
func DeleteGist(gist *Gist) error {
|
||||
return db.Delete(&gist).Error
|
||||
}
|
||||
|
||||
func GistLastActiveNow(gistID uint) error {
|
||||
return db.Model(&Gist{}).
|
||||
Where("id = ?", gistID).
|
||||
Update("updated_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func AppendUserLike(gist *Gist, user *User) error {
|
||||
db.Model(&gist).Omit("updated_at").Update("nb_likes", gist.NbLikes+1)
|
||||
return db.Model(&gist).Omit("updated_at").Association("Likes").Append(user)
|
||||
}
|
||||
|
||||
func RemoveUserLike(gist *Gist, user *User) error {
|
||||
db.Model(&gist).Omit("updated_at").Update("nb_likes", gist.NbLikes-1)
|
||||
return db.Model(&gist).Omit("updated_at").Association("Likes").Delete(user)
|
||||
}
|
||||
|
||||
func GetUsersLikesForGists(gist *Gist, offset int) ([]*User, error) {
|
||||
var users []*User
|
||||
err := db.Model(&gist).
|
||||
Where("gist_id = ?", gist.ID).
|
||||
Limit(31).
|
||||
Offset(offset * 30).
|
||||
Association("Likes").Find(&users)
|
||||
return users, err
|
||||
}
|
||||
|
||||
func UserCanWrite(user *User, gist *Gist) bool {
|
||||
return !(user == nil) && (gist.UserID == user.ID)
|
||||
}
|
56
internal/models/sshkey.go
Normal file
56
internal/models/sshkey.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SSHKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `form:"title" validate:"required,max=50"`
|
||||
Content string `form:"content" validate:"required"`
|
||||
SHA string
|
||||
CreatedAt int64
|
||||
LastUsedAt int64
|
||||
UserID uint
|
||||
User User `validate:"-" `
|
||||
}
|
||||
|
||||
func GetSSHKeysByUserID(userId uint) ([]*SSHKey, error) {
|
||||
var sshKeys []*SSHKey
|
||||
err := db.
|
||||
Where("user_id = ?", userId).
|
||||
Order("created_at asc").
|
||||
Find(&sshKeys).Error
|
||||
|
||||
return sshKeys, err
|
||||
}
|
||||
|
||||
func GetSSHKeyByID(sshKeyId uint) (*SSHKey, error) {
|
||||
sshKey := new(SSHKey)
|
||||
err := db.
|
||||
Where("id = ?", sshKeyId).
|
||||
First(&sshKey).Error
|
||||
|
||||
return sshKey, err
|
||||
}
|
||||
|
||||
func GetSSHKeyByContent(sshKeyContent string) (*SSHKey, error) {
|
||||
sshKey := new(SSHKey)
|
||||
err := db.
|
||||
Where("content like ?", sshKeyContent+"%").
|
||||
First(&sshKey).Error
|
||||
|
||||
return sshKey, err
|
||||
}
|
||||
|
||||
func AddSSHKey(sshKey *SSHKey) error {
|
||||
return db.Create(&sshKey).Error
|
||||
}
|
||||
|
||||
func RemoveSSHKey(sshKey *SSHKey) error {
|
||||
return db.Delete(&sshKey).Error
|
||||
}
|
||||
|
||||
func SSHKeyLastUsedNow(sshKeyID uint) error {
|
||||
return db.Model(&SSHKey{}).
|
||||
Where("id = ?", sshKeyID).
|
||||
Update("last_used_at", time.Now().Unix()).Error
|
||||
}
|
77
internal/models/user.go
Normal file
77
internal/models/user.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package models
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `form:"username" gorm:"uniqueIndex" validate:"required,max=24,alphanum,notreserved"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
IsAdmin bool
|
||||
CreatedAt int64
|
||||
|
||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
SSHKeys []SSHKey `gorm:"foreignKey:UserID"`
|
||||
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func DoesUserExists(userName string, count *int64) error {
|
||||
return db.Table("users").
|
||||
Where("username like ?", userName).
|
||||
Count(count).Error
|
||||
}
|
||||
|
||||
func GetAllUsers(offset int) ([]*User, error) {
|
||||
var all []*User
|
||||
err := db.
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("id asc").
|
||||
Find(&all).Error
|
||||
|
||||
return all, err
|
||||
}
|
||||
|
||||
func GetLoginUser(user *User) error {
|
||||
return db.
|
||||
Where("username like ?", user.Username).
|
||||
First(&user).Error
|
||||
}
|
||||
|
||||
func GetLoginUserById(user *User) error {
|
||||
return db.
|
||||
Where("id = ?", user.ID).
|
||||
First(&user).Error
|
||||
}
|
||||
|
||||
func CreateUser(user *User) error {
|
||||
return db.Create(&user).Error
|
||||
}
|
||||
|
||||
func DeleteUserByID(userid string) error {
|
||||
return db.Delete(&User{}, "id = ?", userid).Error
|
||||
}
|
||||
|
||||
func SetAdminUser(user *User) error {
|
||||
return db.Model(&user).Update("is_admin", true).Error
|
||||
}
|
||||
|
||||
func GetUserBySSHKeyID(sshKeyId uint) (*User, error) {
|
||||
user := new(User)
|
||||
err := db.
|
||||
Preload("SSHKeys").
|
||||
Joins("join ssh_keys on users.id = ssh_keys.user_id").
|
||||
Where("ssh_keys.id = ?", sshKeyId).
|
||||
First(&user).Error
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
func UserHasLikedGist(user *User, gist *Gist) (bool, error) {
|
||||
association := db.Model(&gist).Where("user_id = ?", user.ID).Association("Likes")
|
||||
if association.Error != nil {
|
||||
return false, association.Error
|
||||
}
|
||||
|
||||
if association.Count() == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
21
internal/resources/pre-receive
Normal file
21
internal/resources/pre-receive
Normal file
|
@ -0,0 +1,21 @@
|
|||
#!/bin/sh
|
||||
|
||||
disallowed_files=()
|
||||
|
||||
while read old_rev new_rev ref
|
||||
do
|
||||
for file in $(git diff --name-only $old_rev $new_rev)
|
||||
do
|
||||
if [[ $file =~ / ]]; then
|
||||
disallowed_files+=($file)
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#disallowed_files[@]} -gt 0 ]; then
|
||||
echo "Pushing files in folders is not allowed:"
|
||||
for file in "${disallowed_files[@]}"; do
|
||||
echo " $file"
|
||||
done
|
||||
exit 1
|
||||
fi
|
103
internal/ssh/git-ssh.go
Normal file
103
internal/ssh/git-ssh.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runGitCommand(ch ssh.Channel, gitCmd string, keyID uint) error {
|
||||
verb, args := parseCommand(gitCmd)
|
||||
if !strings.HasPrefix(verb, "git-") {
|
||||
verb = ""
|
||||
}
|
||||
verb = strings.TrimPrefix(verb, "git-")
|
||||
|
||||
if verb != "upload-pack" && verb != "receive-pack" {
|
||||
return errors.New("invalid command")
|
||||
}
|
||||
|
||||
repoFullName := strings.ToLower(strings.Trim(args, "'"))
|
||||
repoFields := strings.SplitN(repoFullName, "/", 2)
|
||||
if len(repoFields) != 2 {
|
||||
return errors.New("invalid gist path")
|
||||
}
|
||||
|
||||
userName := strings.ToLower(repoFields[0])
|
||||
gistName := strings.TrimSuffix(strings.ToLower(repoFields[1]), ".git")
|
||||
|
||||
gist, err := models.GetGist(userName, gistName)
|
||||
if err != nil {
|
||||
return errors.New("gist not found")
|
||||
}
|
||||
|
||||
if verb == "receive-pack" {
|
||||
user, err := models.GetUserBySSHKeyID(keyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
errorSsh("Failed to get user by SSH key id", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
if user.ID != gist.UserID {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
_ = models.SSHKeyLastUsedNow(keyID)
|
||||
|
||||
repositoryPath, err := git.GetRepositoryPath(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
errorSsh("Failed to get repository path", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", verb, repositoryPath)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
errorSsh("Failed to start git command", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
// avoid blocking
|
||||
go func() {
|
||||
_, _ = io.Copy(stdin, ch)
|
||||
}()
|
||||
_, _ = io.Copy(ch, stdout)
|
||||
_, _ = io.Copy(ch, stderr)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
errorSsh("Failed to wait for git command", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
// updatedAt is updated only if serviceType is receive-pack
|
||||
if verb == "receive-pack" {
|
||||
_ = models.GistLastActiveNow(gist.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCommand(cmd string) (string, string) {
|
||||
split := strings.SplitN(cmd, " ", 2)
|
||||
|
||||
if len(split) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return split[0], strings.Replace(split[1], "'/", "'", 1)
|
||||
}
|
153
internal/ssh/run.go
Normal file
153
internal/ssh/run.go
Normal file
|
@ -0,0 +1,153 @@
|
|||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"net"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func Start() {
|
||||
if !config.C.SSH.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
pkey, err := models.GetSSHKeyByContent(strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return &ssh.Permissions{Extensions: map[string]string{"key-id": strconv.Itoa(int(pkey.ID))}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
key, err := setupHostKey()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("SSH: Could not setup host key")
|
||||
}
|
||||
|
||||
sshConfig.AddHostKey(key)
|
||||
go listen(sshConfig)
|
||||
}
|
||||
|
||||
func listen(serverConfig *ssh.ServerConfig) {
|
||||
log.Info().Msg("Starting SSH server on ssh://" + config.C.SSH.Host + ":" + config.C.SSH.Port)
|
||||
listener, err := net.Listen("tcp", config.C.SSH.Host+":"+config.C.SSH.Port)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("SSH: Failed to start SSH server")
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
for {
|
||||
nConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
errorSsh("Failed to accept incoming connection", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
sConn, channels, reqs, err := ssh.NewServerConn(nConn, serverConfig)
|
||||
if err != nil {
|
||||
if !(err != io.EOF && !errors.Is(err, syscall.ECONNRESET)) {
|
||||
errorSsh("Failed to handshake", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
keyID, _ := strconv.Atoi(sConn.Permissions.Extensions["key-id"])
|
||||
go handleConnexion(channels, uint(keyID))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnexion(channels <-chan ssh.NewChannel, keyID uint) {
|
||||
for channel := range channels {
|
||||
if channel.ChannelType() != "session" {
|
||||
_ = channel.Reject(ssh.UnknownChannelType, "Unknown channel type")
|
||||
continue
|
||||
}
|
||||
|
||||
ch, reqs, err := channel.Accept()
|
||||
if err != nil {
|
||||
errorSsh("Could not accept channel", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func(in <-chan *ssh.Request) {
|
||||
defer func() {
|
||||
_ = ch.Close()
|
||||
}()
|
||||
for req := range in {
|
||||
switch req.Type {
|
||||
case "env":
|
||||
|
||||
case "shell":
|
||||
_, _ = ch.Write([]byte("Successfully connected to Opengist SSH server.\r\n"))
|
||||
_, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
|
||||
return
|
||||
case "exec":
|
||||
payloadCmd := string(req.Payload)
|
||||
i := strings.Index(payloadCmd, "git")
|
||||
if i != -1 {
|
||||
payloadCmd = payloadCmd[i:]
|
||||
}
|
||||
|
||||
if err = runGitCommand(ch, payloadCmd, keyID); err != nil {
|
||||
_, _ = ch.Stderr().Write([]byte("Opengist: " + err.Error() + "\r\n"))
|
||||
}
|
||||
_, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
}(reqs)
|
||||
}
|
||||
}
|
||||
|
||||
func setupHostKey() (ssh.Signer, error) {
|
||||
dir := filepath.Join(config.GetHomeDir(), "ssh")
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(dir, "opengist-ed25519")
|
||||
if _, err := os.Stat(keyPath); err != nil && !os.IsExist(err) {
|
||||
cmd := exec.Command(config.C.SSH.Keygen,
|
||||
"-t", "ssh-ed25519",
|
||||
"-f", keyPath,
|
||||
"-m", "PEM",
|
||||
"-N", "")
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func errorSsh(message string, err error) {
|
||||
log.Error().Err(err).Msg("SSH: " + message)
|
||||
}
|
106
internal/web/admin.go
Normal file
106
internal/web/admin.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func adminIndex(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Admin panel")
|
||||
setData(ctx, "adminHeaderPage", "index")
|
||||
|
||||
setData(ctx, "opengistVersion", config.OpengistVersion)
|
||||
setData(ctx, "goVersion", runtime.Version())
|
||||
gitVersion, err := git.GetGitVersion()
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get git version", err)
|
||||
}
|
||||
setData(ctx, "gitVersion", gitVersion)
|
||||
|
||||
countUsers, err := models.CountAll(&models.User{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count users", err)
|
||||
}
|
||||
setData(ctx, "countUsers", countUsers)
|
||||
|
||||
countGists, err := models.CountAll(&models.Gist{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count gists", err)
|
||||
}
|
||||
setData(ctx, "countGists", countGists)
|
||||
|
||||
countKeys, err := models.CountAll(&models.SSHKey{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count SSH keys", err)
|
||||
}
|
||||
setData(ctx, "countKeys", countKeys)
|
||||
|
||||
return html(ctx, "admin_index.html")
|
||||
}
|
||||
|
||||
func adminUsers(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Users")
|
||||
setData(ctx, "adminHeaderPage", "users")
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
var data []*models.User
|
||||
var err error
|
||||
if data, err = models.GetAllUsers(pageInt - 1); err != nil {
|
||||
return errorRes(500, "Cannot get users", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, data, pageInt, 10, "data", "admin/users"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "admin_users.html")
|
||||
}
|
||||
|
||||
func adminGists(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Users")
|
||||
setData(ctx, "adminHeaderPage", "gists")
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
var data []*models.Gist
|
||||
var err error
|
||||
if data, err = models.GetAllGists(pageInt - 1); err != nil {
|
||||
return errorRes(500, "Cannot get gists", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, data, pageInt, 10, "data", "admin/gists"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "admin_gists.html")
|
||||
}
|
||||
|
||||
func adminUserDelete(ctx echo.Context) error {
|
||||
if err := models.DeleteUserByID(ctx.Param("user")); err != nil {
|
||||
return errorRes(500, "Cannot delete this user", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "User has been deleted", "success")
|
||||
return redirect(ctx, "/admin/users")
|
||||
}
|
||||
|
||||
func adminGistDelete(ctx echo.Context) error {
|
||||
gist, err := models.GetGistByID(ctx.Param("gist"))
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot retrieve gist", err)
|
||||
}
|
||||
|
||||
if err = git.DeleteRepository(gist.User.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Cannot delete the repository", err)
|
||||
}
|
||||
|
||||
if err = models.DeleteGist(gist); err != nil {
|
||||
return errorRes(500, "Cannot delete this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist has been deleted", "success")
|
||||
return redirect(ctx, "/admin/gists")
|
||||
|
||||
}
|
103
internal/web/auth.go
Normal file
103
internal/web/auth.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
)
|
||||
|
||||
func register(ctx echo.Context) error {
|
||||
setData(ctx, "title", "New account")
|
||||
setData(ctx, "htmlTitle", "New account")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
func processRegister(ctx echo.Context) error {
|
||||
if config.C.DisableSignup {
|
||||
return errorRes(403, "Signing up is disabled", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "title", "New account")
|
||||
setData(ctx, "htmlTitle", "New account")
|
||||
|
||||
sess := getSession(ctx)
|
||||
|
||||
var user = new(models.User)
|
||||
if err := ctx.Bind(user); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(user); err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
password, err := argon2id.hash(user.Password)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot hash password", err)
|
||||
}
|
||||
user.Password = password
|
||||
|
||||
var count int64
|
||||
if err = models.DoesUserExists(user.Username, &count); err != nil || count >= 1 {
|
||||
addFlash(ctx, "Username already exists", "error")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
if err = models.CreateUser(user); err != nil {
|
||||
return errorRes(500, "Cannot create user", err)
|
||||
}
|
||||
|
||||
if user.ID == 1 {
|
||||
user.IsAdmin = true
|
||||
if err = models.SetAdminUser(user); err != nil {
|
||||
return errorRes(500, "Cannot set user admin", err)
|
||||
}
|
||||
}
|
||||
|
||||
sess.Values["user"] = user.ID
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func login(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Login")
|
||||
setData(ctx, "htmlTitle", "Login")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
func processLogin(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
|
||||
user := &models.User{}
|
||||
if err := ctx.Bind(user); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
password := user.Password
|
||||
|
||||
if err := models.GetLoginUser(user); err != nil {
|
||||
addFlash(ctx, "Invalid credentials", "error")
|
||||
return redirect(ctx, "/login")
|
||||
}
|
||||
|
||||
if ok, err := argon2id.verify(password, user.Password); !ok {
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot check for password", err)
|
||||
}
|
||||
addFlash(ctx, "Invalid credentials", "error")
|
||||
return redirect(ctx, "/login")
|
||||
}
|
||||
|
||||
sess.Values["user"] = user.ID
|
||||
saveSession(sess, ctx)
|
||||
deleteCsrfCookie(ctx)
|
||||
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func logout(ctx echo.Context) error {
|
||||
deleteSession(ctx)
|
||||
deleteCsrfCookie(ctx)
|
||||
return redirect(ctx, "/all")
|
||||
}
|
520
internal/web/gist.go
Normal file
520
internal/web/gist.go
Normal file
|
@ -0,0 +1,520 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func gistInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
userName := ctx.Param("user")
|
||||
gistName := ctx.Param("gistname")
|
||||
|
||||
if strings.HasSuffix(gistName, ".git") {
|
||||
gistName = strings.TrimSuffix(gistName, ".git")
|
||||
}
|
||||
|
||||
gist, err := models.GetGist(userName, gistName)
|
||||
if err != nil {
|
||||
return notFound("Gist not found")
|
||||
}
|
||||
setData(ctx, "gist", gist)
|
||||
|
||||
if config.C.SSH.Port == "22" {
|
||||
setData(ctx, "ssh_clone_url", config.C.SSH.Domain+":"+userName+"/"+gistName+".git")
|
||||
} else {
|
||||
setData(ctx, "ssh_clone_url", "ssh://"+config.C.SSH.Domain+":"+config.C.SSH.Port+"/"+userName+"/"+gistName+".git")
|
||||
}
|
||||
|
||||
setData(ctx, "httpCloneUrl", "http://"+ctx.Request().Host+"/"+userName+"/"+gistName+".git")
|
||||
setData(ctx, "httpCopyUrl", "http://"+ctx.Request().Host+"/"+userName+"/"+gistName)
|
||||
|
||||
setData(ctx, "currentUrl", template.URL(ctx.Request().URL.Path))
|
||||
|
||||
nbCommits, err := git.GetNumberOfCommitsOfRepository(userName, gistName)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching number of commits", err)
|
||||
}
|
||||
setData(ctx, "nbCommits", nbCommits)
|
||||
|
||||
if currUser := getUserLogged(ctx); currUser != nil {
|
||||
hasLiked, err := models.UserHasLikedGist(currUser, gist)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get user like status", err)
|
||||
}
|
||||
setData(ctx, "hasLiked", hasLiked)
|
||||
}
|
||||
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func allGists(ctx echo.Context) error {
|
||||
var err error
|
||||
fromUser := ctx.Param("user")
|
||||
userLogged := getUserLogged(ctx)
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
sort := "created"
|
||||
order := "desc"
|
||||
orderText := "Recently"
|
||||
|
||||
if ctx.QueryParam("sort") == "updated" {
|
||||
sort = "updated"
|
||||
}
|
||||
|
||||
if ctx.QueryParam("order") == "asc" {
|
||||
order = "asc"
|
||||
orderText = "Least recently"
|
||||
}
|
||||
|
||||
setData(ctx, "sort", sort)
|
||||
setData(ctx, "order", orderText)
|
||||
|
||||
var gists []*models.Gist
|
||||
var currentUserId uint
|
||||
if userLogged != nil {
|
||||
currentUserId = userLogged.ID
|
||||
} else {
|
||||
currentUserId = 0
|
||||
}
|
||||
if fromUser == "" {
|
||||
setData(ctx, "htmlTitle", "All gists")
|
||||
fromUser = "all"
|
||||
gists, err = models.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
|
||||
} else {
|
||||
setData(ctx, "htmlTitle", "All gists from "+fromUser)
|
||||
setData(ctx, "fromUser", fromUser)
|
||||
|
||||
var count int64
|
||||
if err = models.DoesUserExists(fromUser, &count); err != nil {
|
||||
return errorRes(500, "Error fetching user", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return notFound("User not found")
|
||||
}
|
||||
|
||||
gists, err = models.GetAllGistsFromUser(fromUser, currentUserId, pageInt-1, sort, order)
|
||||
}
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching gists", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, gists, pageInt, 10, "gists", fromUser, "&sort="+sort+"&order="+order); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "all.html")
|
||||
}
|
||||
|
||||
func gist(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
userName := gist.User.Username
|
||||
gistName := gist.Uuid
|
||||
revision := ctx.Param("revision")
|
||||
|
||||
if revision == "" {
|
||||
revision = "HEAD"
|
||||
}
|
||||
|
||||
nbCommits := getData(ctx, "nbCommits")
|
||||
files := make(map[string]string)
|
||||
if nbCommits != "0" {
|
||||
filesStr, err := git.GetFilesOfRepository(userName, gistName, revision)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(userName, gistName, revision, file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "page", "code")
|
||||
setData(ctx, "commit", revision)
|
||||
setData(ctx, "files", files)
|
||||
setData(ctx, "revision", revision)
|
||||
setData(ctx, "htmlTitle", gist.Title)
|
||||
|
||||
return html(ctx, "gist.html")
|
||||
}
|
||||
|
||||
func revisions(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
userName := gist.User.Username
|
||||
gistName := gist.Uuid
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
nbCommits := getData(ctx, "nbCommits")
|
||||
commits := make([]*models.Commit, 0)
|
||||
if nbCommits != "0" {
|
||||
gitlogStr, err := git.GetLog(userName, gistName, strconv.Itoa((pageInt-1)*10))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching commits log", err)
|
||||
}
|
||||
|
||||
gitlog := strings.Split(gitlogStr, "\n=commit ")
|
||||
for _, commitStr := range gitlog[1:] {
|
||||
logContent := strings.SplitN(commitStr, "\n", 3)
|
||||
|
||||
header := strings.Split(logContent[0], ":")
|
||||
commitStruct := models.Commit{
|
||||
Hash: header[0],
|
||||
Author: header[1],
|
||||
Timestamp: header[2],
|
||||
Files: make([]models.File, 0),
|
||||
}
|
||||
|
||||
if len(logContent) > 2 {
|
||||
changed := strings.ReplaceAll(logContent[1], "(+)", "")
|
||||
changed = strings.ReplaceAll(changed, "(-)", "")
|
||||
commitStruct.Changed = changed
|
||||
}
|
||||
|
||||
files := strings.Split(logContent[len(logContent)-1], "diff --git ")
|
||||
if len(files) > 1 {
|
||||
for _, fileStr := range files {
|
||||
content := strings.SplitN(fileStr, "\n@@", 2)
|
||||
if len(content) > 1 {
|
||||
header := strings.Split(content[0], "\n")
|
||||
commitStruct.Files = append(commitStruct.Files, models.File{Content: "@@" + content[1], Filename: header[len(header)-1][4:], OldFilename: header[len(header)-2][4:]})
|
||||
} else {
|
||||
// in case there is no content but a file renamed
|
||||
header := strings.Split(content[0], "\n")
|
||||
if len(header) > 3 {
|
||||
commitStruct.Files = append(commitStruct.Files, models.File{Content: "", Filename: header[3][10:], OldFilename: header[2][12:]})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commits = append(commits, &commitStruct)
|
||||
}
|
||||
}
|
||||
|
||||
if err := paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "page", "revisions")
|
||||
setData(ctx, "revision", "HEAD")
|
||||
setData(ctx, "htmlTitle", "Revision of "+gist.Title)
|
||||
|
||||
return html(ctx, "revisions.html")
|
||||
}
|
||||
|
||||
func create(ctx echo.Context) error {
|
||||
setData(ctx, "htmlTitle", "Create a new gist")
|
||||
return html(ctx, "create.html")
|
||||
}
|
||||
|
||||
func processCreate(ctx echo.Context) error {
|
||||
isCreate := false
|
||||
if ctx.Request().URL.Path == "/" {
|
||||
isCreate = true
|
||||
}
|
||||
|
||||
err := ctx.Request().ParseForm()
|
||||
if err != nil {
|
||||
return errorRes(400, "Bad request", err)
|
||||
}
|
||||
|
||||
var gist *models.Gist
|
||||
|
||||
if isCreate {
|
||||
gist = new(models.Gist)
|
||||
setData(ctx, "htmlTitle", "Create a new gist")
|
||||
} else {
|
||||
gist = getData(ctx, "gist").(*models.Gist)
|
||||
setData(ctx, "htmlTitle", "Edit "+gist.Title)
|
||||
}
|
||||
|
||||
if err := ctx.Bind(gist); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
gist.Files = make([]models.File, 0)
|
||||
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
|
||||
name := ctx.Request().PostForm["name"][i]
|
||||
content := ctx.Request().PostForm["content"][i]
|
||||
|
||||
if name == "" {
|
||||
name = "gistfile" + strconv.Itoa(i+1) + ".txt"
|
||||
}
|
||||
|
||||
escapedValue, err := url.QueryUnescape(content)
|
||||
if err != nil {
|
||||
return errorRes(400, "Invalid character unescaped", err)
|
||||
}
|
||||
|
||||
gist.Files = append(gist.Files, models.File{
|
||||
Filename: name,
|
||||
Content: escapedValue,
|
||||
})
|
||||
}
|
||||
user := getUserLogged(ctx)
|
||||
gist.NbFiles = len(gist.Files)
|
||||
|
||||
if isCreate {
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return errorRes(500, "Error creating an UUID", err)
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
|
||||
gist.UserID = user.ID
|
||||
}
|
||||
|
||||
if gist.Title == "" {
|
||||
if ctx.Request().PostForm["name"][0] == "" {
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
} else {
|
||||
gist.Title = ctx.Request().PostForm["name"][0]
|
||||
}
|
||||
}
|
||||
|
||||
err = ctx.Validate(gist)
|
||||
if err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
if isCreate {
|
||||
return html(ctx, "create.html")
|
||||
} else {
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, "HEAD")
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, "HEAD", file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "files", files)
|
||||
return html(ctx, "edit.html")
|
||||
}
|
||||
}
|
||||
|
||||
if len(gist.Files) > 0 {
|
||||
split := strings.Split(gist.Files[0].Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = gist.Files[0].Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = gist.Files[0].Filename
|
||||
}
|
||||
|
||||
if err = git.InitRepository(user.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error creating the repository", err)
|
||||
}
|
||||
|
||||
if err = git.CloneTmp(user.Username, gist.Uuid, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error cloning the repository", err)
|
||||
}
|
||||
|
||||
for _, file := range gist.Files {
|
||||
if err = git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return errorRes(500, "Error setting file content for file "+file.Filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = git.AddAll(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error adding files to the repository", err)
|
||||
}
|
||||
|
||||
if err = git.Commit(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error committing files to the local repository", err)
|
||||
}
|
||||
|
||||
if err = git.Push(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error pushing the local repository", err)
|
||||
}
|
||||
|
||||
if isCreate {
|
||||
if err = models.CreateGist(gist); err != nil {
|
||||
return errorRes(500, "Error creating the gist", err)
|
||||
}
|
||||
} else {
|
||||
if err = models.UpdateGist(gist); err != nil {
|
||||
return errorRes(500, "Error updating the gist", err)
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(ctx, "/"+user.Username+"/"+gist.Uuid)
|
||||
}
|
||||
|
||||
func toggleVisibility(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
gist.Private = !gist.Private
|
||||
if err := models.UpdateGist(gist); err != nil {
|
||||
return errorRes(500, "Error updating this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist visibility has been changed", "success")
|
||||
return redirect(ctx, "/"+gist.User.Username+"/"+gist.Uuid)
|
||||
}
|
||||
|
||||
func deleteGist(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
err := git.DeleteRepository(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error deleting the repository", err)
|
||||
}
|
||||
|
||||
if err := models.DeleteGist(gist); err != nil {
|
||||
return errorRes(500, "Error deleting this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist has been deleted", "success")
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func like(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
currentUser := getUserLogged(ctx)
|
||||
|
||||
hasLiked, err := models.UserHasLikedGist(currentUser, gist)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error checking if user has liked a gist", err)
|
||||
}
|
||||
|
||||
if hasLiked {
|
||||
err = models.RemoveUserLike(gist, getUserLogged(ctx))
|
||||
} else {
|
||||
err = models.AppendUserLike(gist, getUserLogged(ctx))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errorRes(500, "Error liking/dislking this gist", err)
|
||||
}
|
||||
|
||||
redirectTo := "/" + gist.User.Username + "/" + gist.Uuid
|
||||
if r := ctx.QueryParam("redirecturl"); r != "" {
|
||||
redirectTo = r
|
||||
}
|
||||
return redirect(ctx, redirectTo)
|
||||
}
|
||||
|
||||
func rawFile(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
fileContent, err := git.GetFileContent(
|
||||
gist.User.Username,
|
||||
gist.Uuid,
|
||||
ctx.Param("revision"),
|
||||
ctx.Param("file"))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error getting file content", err)
|
||||
}
|
||||
|
||||
filebytes := []byte(fileContent)
|
||||
|
||||
if len(filebytes) == 0 {
|
||||
return notFound("File not found")
|
||||
}
|
||||
|
||||
return plainText(ctx, 200, string(filebytes))
|
||||
}
|
||||
|
||||
func edit(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, "HEAD")
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, "HEAD", file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "files", files)
|
||||
setData(ctx, "htmlTitle", "Edit "+gist.Title)
|
||||
|
||||
return html(ctx, "edit.html")
|
||||
}
|
||||
|
||||
func downloadZip(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
var revision = ctx.Param("revision")
|
||||
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, revision)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, revision, file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
zipFile := new(bytes.Buffer)
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
|
||||
for fileName, fileContent := range files {
|
||||
f, err := zipWriter.Create(fileName)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error adding a file the to the zip archive", err)
|
||||
}
|
||||
_, err = f.Write([]byte(fileContent))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error adding file content the to the zip archive", err)
|
||||
}
|
||||
}
|
||||
err = zipWriter.Close()
|
||||
if err != nil {
|
||||
return errorRes(500, "Error closing the zip archive", err)
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", "application/zip")
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Uuid+".zip")
|
||||
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes())))
|
||||
_, err = ctx.Response().Write(zipFile.Bytes())
|
||||
if err != nil {
|
||||
return errorRes(500, "Error writing the zip archive", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func likes(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
likers, err := models.GetUsersLikesForGists(gist, pageInt-1)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error getting users who liked this gist", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Uuid+"/likes"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "htmlTitle", "Likes for "+gist.Title)
|
||||
setData(ctx, "revision", "HEAD")
|
||||
return html(ctx, "likes.html")
|
||||
}
|
253
internal/web/git-http.go
Normal file
253
internal/web/git-http.go
Normal file
|
@ -0,0 +1,253 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var routes = []struct {
|
||||
gitUrl string
|
||||
method string
|
||||
handler func(ctx echo.Context) error
|
||||
}{
|
||||
{"(.*?)/git-upload-pack$", "POST", uploadPack},
|
||||
{"(.*?)/git-receive-pack$", "POST", receivePack},
|
||||
{"(.*?)/info/refs$", "GET", infoRefs},
|
||||
{"(.*?)/HEAD$", "GET", textFile},
|
||||
{"(.*?)/objects/info/alternates$", "GET", textFile},
|
||||
{"(.*?)/objects/info/http-alternates$", "GET", textFile},
|
||||
{"(.*?)/objects/info/packs$", "GET", infoPacks},
|
||||
{"(.*?)/objects/info/[^/]*$", "GET", textFile},
|
||||
{"(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$", "GET", looseObject},
|
||||
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$", "GET", packFile},
|
||||
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
|
||||
}
|
||||
|
||||
func gitHttp(ctx echo.Context) error {
|
||||
for _, route := range routes {
|
||||
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
|
||||
if ctx.Request().Method == route.method && matched {
|
||||
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
|
||||
continue
|
||||
}
|
||||
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
noAuth := ctx.QueryParam("service") == "git-upload-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") ||
|
||||
ctx.Request().Method == "GET"
|
||||
|
||||
repositoryPath, err := git.GetRepositoryPath(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get repository path", err)
|
||||
}
|
||||
|
||||
if _, err = os.Stat(repositoryPath); os.IsNotExist(err) {
|
||||
if err != nil {
|
||||
return errorRes(500, "Repository does not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Set("repositoryPath", repositoryPath)
|
||||
|
||||
// Requires Basic Auth if we push the repository
|
||||
if noAuth {
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername {
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot verify password", err)
|
||||
}
|
||||
return errorRes(403, "Unauthorized", nil)
|
||||
}
|
||||
|
||||
return route.handler(ctx)
|
||||
}
|
||||
}
|
||||
return notFound("Gist not found")
|
||||
}
|
||||
|
||||
func uploadPack(ctx echo.Context) error {
|
||||
return pack(ctx, "upload-pack")
|
||||
}
|
||||
|
||||
func receivePack(ctx echo.Context) error {
|
||||
return pack(ctx, "receive-pack")
|
||||
}
|
||||
|
||||
func pack(ctx echo.Context, serviceType string) error {
|
||||
noCacheHeaders(ctx)
|
||||
defer ctx.Request().Body.Close()
|
||||
|
||||
if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
|
||||
return errorRes(401, "Git client unsupported", nil)
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", "application/x-git-"+serviceType+"-result")
|
||||
|
||||
var err error
|
||||
reqBody := ctx.Request().Body
|
||||
|
||||
if ctx.Request().Header.Get("Content-Encoding") == "gzip" {
|
||||
reqBody, err = gzip.NewReader(reqBody)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot create gzip reader", err)
|
||||
}
|
||||
}
|
||||
|
||||
repositoryPath := ctx.Get("repositoryPath").(string)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command("git", serviceType, "--stateless-rpc", repositoryPath)
|
||||
cmd.Dir = repositoryPath
|
||||
cmd.Stdin = reqBody
|
||||
cmd.Stdout = ctx.Response().Writer
|
||||
cmd.Stderr = &stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
return errorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
|
||||
}
|
||||
|
||||
// updatedAt is updated only if serviceType is receive-pack
|
||||
if serviceType == "receive-pack" {
|
||||
_ = models.GistLastActiveNow(getData(ctx, "gist").(*models.Gist).ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func infoRefs(ctx echo.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
var service string
|
||||
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
serviceType := ctx.QueryParam("service")
|
||||
if !strings.HasPrefix(serviceType, "git-") {
|
||||
service = ""
|
||||
}
|
||||
service = strings.TrimPrefix(serviceType, "git-")
|
||||
|
||||
if service != "upload-pack" && service != "receive-pack" {
|
||||
if err := git.UpdateServerInfo(gist.User.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Cannot update server info", err)
|
||||
}
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
refs, err := git.RPCRefs(gist.User.Username, gist.Uuid, service)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot run git "+service, err)
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
|
||||
ctx.Response().WriteHeader(200)
|
||||
_, _ = ctx.Response().Write(packetWrite("# service=git-" + service + "\n"))
|
||||
_, _ = ctx.Response().Write([]byte("0000"))
|
||||
_, _ = ctx.Response().Write(refs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func textFile(ctx echo.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
return sendFile(ctx, "text/plain")
|
||||
}
|
||||
|
||||
func infoPacks(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
func looseObject(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-loose-object")
|
||||
}
|
||||
|
||||
func packFile(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects")
|
||||
}
|
||||
|
||||
func idxFile(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects-toc")
|
||||
}
|
||||
|
||||
func noCacheHeaders(ctx echo.Context) {
|
||||
ctx.Response().Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 UTC")
|
||||
ctx.Response().Header().Set("Pragma", "no-cache")
|
||||
ctx.Response().Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
||||
}
|
||||
|
||||
func cacheHeadersForever(ctx echo.Context) {
|
||||
now := time.Now().Unix()
|
||||
expires := now + 31536000
|
||||
ctx.Response().Header().Set("Date", fmt.Sprintf("%d", now))
|
||||
ctx.Response().Header().Set("Expires", fmt.Sprintf("%d", expires))
|
||||
ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
}
|
||||
|
||||
func basicAuth(ctx echo.Context) error {
|
||||
ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
|
||||
return plainText(ctx, 401, "Requires authentication")
|
||||
}
|
||||
|
||||
func basicAuthDecode(encoded string) (string, string, error) {
|
||||
s, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
auth := strings.SplitN(string(s), ":", 2)
|
||||
return auth[0], auth[1], nil
|
||||
}
|
||||
|
||||
func sendFile(ctx echo.Context, contentType string) error {
|
||||
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
|
||||
gitFile = path.Join(ctx.Get("repositoryPath").(string), gitFile)
|
||||
fi, err := os.Stat(gitFile)
|
||||
if os.IsNotExist(err) {
|
||||
return errorRes(404, "File not found", nil)
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", contentType)
|
||||
ctx.Response().Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
||||
ctx.Response().Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
|
||||
return ctx.File(gitFile)
|
||||
}
|
||||
|
||||
func packetWrite(str string) []byte {
|
||||
s := strconv.FormatInt(int64(len(str)+4), 16)
|
||||
|
||||
if len(s)%4 != 0 {
|
||||
s = strings.Repeat("0", 4-len(s)%4) + s
|
||||
}
|
||||
|
||||
return []byte(s + str)
|
||||
}
|
255
internal/web/run.go
Normal file
255
internal/web/run.go
Normal file
|
@ -0,0 +1,255 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var store *sessions.CookieStore
|
||||
var re = regexp.MustCompile("[^a-z0-9]+")
|
||||
|
||||
type Template struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
func Start() {
|
||||
store = sessions.NewCookieStore([]byte("opengist"))
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
e.Use(dataInit)
|
||||
e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
|
||||
Getter: middleware.MethodFromForm("_method"),
|
||||
}))
|
||||
e.Pre(middleware.RemoveTrailingSlash())
|
||||
e.Use(middleware.CORS())
|
||||
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
||||
LogURI: true, LogStatus: true, LogMethod: true,
|
||||
LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error {
|
||||
log.Info().Str("URI", v.URI).Int("status", v.Status).Str("method", v.Method).
|
||||
Msg("HTTP")
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
|
||||
e.Renderer = &Template{
|
||||
templates: template.Must(template.New("t").Funcs(
|
||||
template.FuncMap{
|
||||
"split": strings.Split,
|
||||
"indexByte": strings.IndexByte,
|
||||
"toInt": func(i string) int64 {
|
||||
val, _ := strconv.ParseInt(i, 10, 64)
|
||||
return val
|
||||
},
|
||||
"inc": func(i int64) int64 {
|
||||
return i + 1
|
||||
},
|
||||
"splitGit": func(i string) []string {
|
||||
return strings.FieldsFunc(i, func(r rune) bool {
|
||||
return r == ',' || r == ' '
|
||||
})
|
||||
},
|
||||
"lines": func(i string) []string {
|
||||
return strings.Split(i, "\n")
|
||||
},
|
||||
"isMarkdown": func(i string) bool {
|
||||
return ".md" == strings.ToLower(filepath.Ext(i))
|
||||
},
|
||||
"httpStatusText": http.StatusText,
|
||||
"loadedTime": func(startTime time.Time) string {
|
||||
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
|
||||
},
|
||||
"slug": func(s string) string {
|
||||
return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-")
|
||||
},
|
||||
}).ParseGlob("templates/*/*.html")),
|
||||
}
|
||||
|
||||
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
|
||||
if err, ok := er.(*echo.HTTPError); ok {
|
||||
if err.Code >= 500 {
|
||||
log.Error().Int("code", err.Code).Err(err.Internal).Msg("HTTP: " + err.Message.(string))
|
||||
}
|
||||
|
||||
setData(ctx, "error", err)
|
||||
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil {
|
||||
log.Fatal().Err(errHtml).Send()
|
||||
}
|
||||
} else {
|
||||
log.Fatal().Err(er).Send()
|
||||
}
|
||||
}
|
||||
|
||||
e.Use(basicInit)
|
||||
|
||||
e.Validator = NewValidator()
|
||||
|
||||
e.Static("/assets", "./public/assets")
|
||||
|
||||
// Web based routes
|
||||
g1 := e.Group("")
|
||||
{
|
||||
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "form:_csrf",
|
||||
CookiePath: "/",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
}))
|
||||
g1.Use(csrfInit)
|
||||
|
||||
g1.GET("/", create, logged)
|
||||
g1.POST("/", processCreate, logged)
|
||||
|
||||
g1.GET("/register", register)
|
||||
g1.POST("/register", processRegister)
|
||||
g1.GET("/login", login)
|
||||
g1.POST("/login", processLogin)
|
||||
g1.GET("/logout", logout)
|
||||
|
||||
g1.GET("/ssh-keys", sshKeys, logged)
|
||||
g1.POST("/ssh-keys", sshKeysProcess, logged)
|
||||
g1.DELETE("/ssh-keys/:id", sshKeysDelete, logged)
|
||||
|
||||
g2 := g1.Group("/admin")
|
||||
{
|
||||
g2.Use(adminPermission)
|
||||
g2.GET("", adminIndex)
|
||||
g2.GET("/users", adminUsers)
|
||||
g2.POST("/users/:user/delete", adminUserDelete)
|
||||
g2.GET("/gists", adminGists)
|
||||
g2.POST("/gists/:gist/delete", adminGistDelete)
|
||||
}
|
||||
|
||||
g1.GET("/all", allGists)
|
||||
g1.GET("/:user", allGists)
|
||||
|
||||
g3 := g1.Group("/:user/:gistname")
|
||||
{
|
||||
g3.Use(gistInit)
|
||||
g3.GET("", gist)
|
||||
g3.GET("/rev/:revision", gist)
|
||||
g3.GET("/revisions", revisions)
|
||||
g3.GET("/archive/:revision", downloadZip)
|
||||
g3.POST("/visibility", toggleVisibility, logged, writePermission)
|
||||
g3.POST("/delete", deleteGist, logged, writePermission)
|
||||
g3.GET("/raw/:revision/:file", rawFile)
|
||||
g3.GET("/edit", edit, logged, writePermission)
|
||||
g3.POST("/edit", processCreate, logged, writePermission)
|
||||
g3.POST("/like", like, logged)
|
||||
g3.GET("/likes", likes)
|
||||
}
|
||||
}
|
||||
|
||||
debugStr := ""
|
||||
// Git HTTP routes
|
||||
if config.C.HTTP.Git {
|
||||
e.Any("/:user/:gistname/*", gitHttp, gistInit)
|
||||
debugStr = " (with Git HTTP support)"
|
||||
}
|
||||
|
||||
e.Any("/*", noRouteFound)
|
||||
|
||||
addr := config.C.HTTP.Host + ":" + config.C.HTTP.Port
|
||||
log.Info().Msg("Starting HTTP server on http://" + addr + debugStr)
|
||||
|
||||
if err := e.Start(addr); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start HTTP server")
|
||||
}
|
||||
}
|
||||
|
||||
func dataInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
ctxValue := context.WithValue(ctx.Request().Context(), "data", echo.Map{})
|
||||
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
|
||||
setData(ctx, "loadStartTime", time.Now())
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func basicInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
setData(ctx, "signupDisabled", config.C.DisableSignup)
|
||||
|
||||
sess := getSession(ctx)
|
||||
if sess.Values["user"] != nil {
|
||||
user := &models.User{ID: sess.Values["user"].(uint)}
|
||||
if err := models.GetLoginUserById(user); err != nil {
|
||||
sess.Values["user"] = nil
|
||||
saveSession(sess, ctx)
|
||||
setData(ctx, "userLogged", nil)
|
||||
return redirect(ctx, "/all")
|
||||
}
|
||||
if user != nil {
|
||||
setData(ctx, "userLogged", user)
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
setData(ctx, "userLogged", nil)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
setCsrfHtmlForm(ctx)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func writePermission(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist")
|
||||
user := getUserLogged(ctx)
|
||||
if !models.UserCanWrite(user, gist.(*models.Gist)) {
|
||||
return redirect(ctx, "/"+gist.(*models.Gist).User.Username+"/"+gist.(*models.Gist).Uuid)
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func adminPermission(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
if user == nil || !user.IsAdmin {
|
||||
return notFound("User not found")
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func logged(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
if user != nil {
|
||||
return next(ctx)
|
||||
}
|
||||
return redirect(ctx, "/all")
|
||||
}
|
||||
}
|
||||
|
||||
func noRouteFound(echo.Context) error {
|
||||
return notFound("Page not found")
|
||||
}
|
79
internal/web/ssh.go
Normal file
79
internal/web/ssh.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func sshKeys(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
keys, err := models.GetSSHKeysByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get SSH keys", err)
|
||||
}
|
||||
|
||||
setData(ctx, "sshKeys", keys)
|
||||
setData(ctx, "htmlTitle", "Manage SSH keys")
|
||||
return html(ctx, "ssh_keys.html")
|
||||
}
|
||||
|
||||
func sshKeysProcess(ctx echo.Context) error {
|
||||
setData(ctx, "htmlTitle", "Manage SSH keys")
|
||||
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
var key = new(models.SSHKey)
|
||||
if err := ctx.Bind(key); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(key); err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
key.UserID = user.ID
|
||||
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
|
||||
if err != nil {
|
||||
addFlash(ctx, "Invalid SSH key", "error")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
sha := sha256.Sum256(pubKey.Marshal())
|
||||
key.SHA = base64.StdEncoding.EncodeToString(sha[:])
|
||||
|
||||
if err := models.AddSSHKey(key); err != nil {
|
||||
return errorRes(500, "Cannot add SSH key", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "SSH key added", "success")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
func sshKeysDelete(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||
|
||||
if err != nil {
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
key, err := models.GetSSHKeyByID(uint(keyId))
|
||||
|
||||
if err != nil || key.UserID != user.ID {
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
if err := models.RemoveSSHKey(key); err != nil {
|
||||
return errorRes(500, "Cannot delete SSH key", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "SSH key deleted", "success")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
254
internal/web/util.go
Normal file
254
internal/web/util.go
Normal file
|
@ -0,0 +1,254 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func setData(ctx echo.Context, key string, value any) {
|
||||
data := ctx.Request().Context().Value("data").(echo.Map)
|
||||
data[key] = value
|
||||
ctxValue := context.WithValue(ctx.Request().Context(), "data", data)
|
||||
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
|
||||
}
|
||||
|
||||
func getData(ctx echo.Context, key string) any {
|
||||
data := ctx.Request().Context().Value("data").(echo.Map)
|
||||
return data[key]
|
||||
}
|
||||
|
||||
func html(ctx echo.Context, template string) error {
|
||||
return htmlWithCode(ctx, 200, template)
|
||||
}
|
||||
|
||||
func htmlWithCode(ctx echo.Context, code int, template string) error {
|
||||
setErrorFlashes(ctx)
|
||||
return ctx.Render(code, template, ctx.Request().Context().Value("data"))
|
||||
}
|
||||
|
||||
func redirect(ctx echo.Context, location string) error {
|
||||
return ctx.Redirect(302, location)
|
||||
}
|
||||
|
||||
func plainText(ctx echo.Context, code int, message string) error {
|
||||
return ctx.String(code, message)
|
||||
}
|
||||
|
||||
func notFound(message string) error {
|
||||
return errorRes(404, message, nil)
|
||||
}
|
||||
|
||||
func errorRes(code int, message string, err error) error {
|
||||
return &echo.HTTPError{Code: code, Message: message, Internal: err}
|
||||
}
|
||||
|
||||
func getUserLogged(ctx echo.Context) *models.User {
|
||||
user := getData(ctx, "userLogged")
|
||||
if user != nil {
|
||||
return user.(*models.User)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setErrorFlashes(ctx echo.Context) {
|
||||
sess, _ := store.Get(ctx.Request(), "flash")
|
||||
|
||||
setData(ctx, "flashErrors", sess.Flashes("error"))
|
||||
setData(ctx, "flashSuccess", sess.Flashes("success"))
|
||||
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func addFlash(ctx echo.Context, flashMessage string, flashType string) {
|
||||
sess, _ := store.Get(ctx.Request(), "flash")
|
||||
sess.AddFlash(flashMessage, flashType)
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func getSession(ctx echo.Context) *sessions.Session {
|
||||
sess, _ := store.Get(ctx.Request(), "session")
|
||||
return sess
|
||||
}
|
||||
|
||||
func saveSession(sess *sessions.Session, ctx echo.Context) {
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func deleteSession(ctx echo.Context) {
|
||||
sess := getSession(ctx)
|
||||
sess.Options.MaxAge = -1
|
||||
sess.Values["user"] = nil
|
||||
saveSession(sess, ctx)
|
||||
}
|
||||
|
||||
func setCsrfHtmlForm(ctx echo.Context) {
|
||||
if csrfToken, ok := ctx.Get("csrf").(string); ok {
|
||||
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrfToken+`">`))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCsrfCookie(ctx echo.Context) {
|
||||
ctx.SetCookie(&http.Cookie{Name: "_csrf", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
type OpengistValidator struct {
|
||||
v *validator.Validate
|
||||
}
|
||||
|
||||
func NewValidator() *OpengistValidator {
|
||||
v := validator.New()
|
||||
_ = v.RegisterValidation("notreserved", validateReservedKeywords)
|
||||
return &OpengistValidator{v}
|
||||
}
|
||||
|
||||
func (cv *OpengistValidator) Validate(i interface{}) error {
|
||||
if err := cv.v.Struct(i); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validationMessages(err *error) string {
|
||||
errs := (*err).(validator.ValidationErrors)
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
switch e.Tag() {
|
||||
case "max":
|
||||
messages[i] = e.Field() + " is too long"
|
||||
case "required":
|
||||
messages[i] = e.Field() + " should not be empty"
|
||||
case "excludes":
|
||||
messages[i] = e.Field() + " should not include a sub directory"
|
||||
case "alphanum":
|
||||
messages[i] = e.Field() + " should only contain alphanumeric characters"
|
||||
case "min":
|
||||
messages[i] = "Not enough " + e.Field()
|
||||
case "notreserved":
|
||||
messages[i] = "Invalid " + e.Field()
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(messages, " ; ")
|
||||
}
|
||||
|
||||
func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||
name := fl.Field().String()
|
||||
|
||||
restrictedNames := map[string]struct{}{}
|
||||
for _, restrictedName := range []string{"register", "login", "logout", "ssh-keys", "admin", "all"} {
|
||||
restrictedNames[restrictedName] = struct{}{}
|
||||
}
|
||||
|
||||
// if the name is not in the restricted names, it is valid
|
||||
_, ok := restrictedNames[name]
|
||||
return !ok
|
||||
}
|
||||
|
||||
func getPage(ctx echo.Context) int {
|
||||
page := ctx.QueryParam("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil {
|
||||
pageInt = 1
|
||||
}
|
||||
setData(ctx, "currPage", pageInt)
|
||||
|
||||
return pageInt
|
||||
}
|
||||
|
||||
func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, urlParams ...string) error {
|
||||
lenData := len(data)
|
||||
if lenData == 0 && pageInt != 1 {
|
||||
return errors.New("page not found")
|
||||
}
|
||||
|
||||
if lenData > perPage {
|
||||
if lenData > 1 {
|
||||
data = data[:lenData-1]
|
||||
}
|
||||
setData(ctx, "nextPage", pageInt+1)
|
||||
}
|
||||
if pageInt > 1 {
|
||||
setData(ctx, "prevPage", pageInt-1)
|
||||
}
|
||||
|
||||
if len(urlParams) > 0 {
|
||||
setData(ctx, "urlParams", template.URL(urlParams[0]))
|
||||
}
|
||||
|
||||
setData(ctx, "urlPage", urlPage)
|
||||
setData(ctx, templateDataName, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Argon2ID struct {
|
||||
format string
|
||||
version int
|
||||
time uint32
|
||||
memory uint32
|
||||
keyLen uint32
|
||||
saltLen uint32
|
||||
threads uint8
|
||||
}
|
||||
|
||||
var argon2id = Argon2ID{
|
||||
format: "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
version: argon2.Version,
|
||||
time: 1,
|
||||
memory: 64 * 1024,
|
||||
keyLen: 32,
|
||||
saltLen: 16,
|
||||
threads: 4,
|
||||
}
|
||||
|
||||
func (a Argon2ID) hash(plain string) (string, error) {
|
||||
salt := make([]byte, a.saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, a.keyLen)
|
||||
|
||||
return fmt.Sprintf(a.format, a.version, a.memory, a.time, a.threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (a Argon2ID) verify(plain, hash string) (bool, error) {
|
||||
hashParts := strings.Split(hash, "$")
|
||||
|
||||
_, err := fmt.Sscanf(hashParts[3], "m=%d,t=%d,p=%d", &a.memory, &a.time, &a.threads)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(hashParts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
hashToCompare := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, uint32(len(decodedHash)))
|
||||
|
||||
return subtle.ConstantTimeCompare(decodedHash, hashToCompare) == 1, nil
|
||||
}
|
54
opengist.go
Normal file
54
opengist.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"github.com/rs/zerolog/log"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
"opengist/internal/ssh"
|
||||
"opengist/internal/web"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func initialize() {
|
||||
configPath := flag.String("config", "config.yml", "Path to a config file in YML format")
|
||||
flag.Parse()
|
||||
absolutePath, _ := filepath.Abs(*configPath)
|
||||
absolutePath = filepath.Clean(absolutePath)
|
||||
if err := config.InitConfig(absolutePath); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
config.InitLog()
|
||||
|
||||
log.Info().Msg("Opengist v" + config.OpengistVersion)
|
||||
log.Info().Msg("Using config file: " + absolutePath)
|
||||
|
||||
homePath := config.GetHomeDir()
|
||||
log.Info().Msg("Data directory: " + homePath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(homePath, "repos"), 0755); err != nil {
|
||||
log.Fatal().Err(err).Send()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755); err != nil {
|
||||
log.Fatal().Err(err).Send()
|
||||
}
|
||||
|
||||
log.Info().Msg("Database file: " + filepath.Join(homePath, config.C.DBFilename))
|
||||
if err := models.Setup(filepath.Join(homePath, config.C.DBFilename)); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to initialize database")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
initialize()
|
||||
|
||||
go web.Start()
|
||||
go ssh.Start()
|
||||
|
||||
select {}
|
||||
}
|
2705
package-lock.json
generated
Normal file
2705
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
package.json
Normal file
25
package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "opengist",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "node_modules/.bin/vite",
|
||||
"build": "node_modules/.bin/vite build",
|
||||
"preview": "node_modules/.bin/vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.1",
|
||||
"@tailwindcss/forms": "^0.5.1",
|
||||
"@tailwindcss/typography": "^0.5.2",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"codemirror": "^6.0.0",
|
||||
"highlight.js": "^11.5.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"moment": "^2.29.3",
|
||||
"postcss": "^8.4.13",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"vite": "^2.9.2"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
}
|
75
public/editor.js
vendored
Normal file
75
public/editor.js
vendored
Normal file
|
@ -0,0 +1,75 @@
|
|||
import {EditorView, keymap, gutter, lineNumbers} from "@codemirror/view"
|
||||
import {indentWithTab} from "@codemirror/commands"
|
||||
|
||||
EditorView.theme({}, {dark: true})
|
||||
|
||||
let editorsjs = []
|
||||
let editorsParentdom = document.getElementById('editors')
|
||||
let allEditorsdom = document.querySelectorAll('#editors > .editor')
|
||||
let firstEditordom = allEditorsdom[0]
|
||||
|
||||
const newEditor = (dom, value = '') => {
|
||||
return new EditorView({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(), gutter({class: "cm-mygutter"}),
|
||||
keymap.of([indentWithTab]),
|
||||
],
|
||||
parent: dom
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
document.onsubmit = () => {
|
||||
console.log('onsubmit');
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
let arr = [...allEditorsdom]
|
||||
arr.forEach(el => {
|
||||
// in case we edit the gist contents
|
||||
let currEditor = newEditor(el, el.querySelector('.form-filecontent').value)
|
||||
editorsjs.push(currEditor)
|
||||
|
||||
currEditor.dom.addEventListener("input", function inputConfirmLeave() {
|
||||
if (!currEditor.inView) return; // skip events outside the viewport
|
||||
|
||||
currEditor.dom.removeEventListener("input", inputConfirmLeave);
|
||||
window.onbeforeunload = () => {
|
||||
return 'Are you sure you want to quit?';
|
||||
}
|
||||
});
|
||||
|
||||
// remove editor on delete
|
||||
let deleteBtns = el.querySelector('button.delete-file')
|
||||
if (deleteBtns !== null) {
|
||||
|
||||
deleteBtns.onclick = () => {
|
||||
|
||||
editorsjs.splice(editorsjs.indexOf(currEditor), 1);
|
||||
el.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('add-file').onclick = () => {
|
||||
let newEditorDom = firstEditordom.cloneNode(true)
|
||||
|
||||
// reset the filename of the new cloned element
|
||||
newEditorDom.querySelector('input[name="name"]').value = ""
|
||||
|
||||
// removing the previous codemirror editor
|
||||
let newEditorDomCM = newEditorDom.querySelector('.cm-editor')
|
||||
newEditorDomCM.remove()
|
||||
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom))
|
||||
editorsParentdom.append(newEditorDom)
|
||||
}
|
||||
|
||||
document.querySelector('form#create').onsubmit = () => {
|
||||
let j = 0
|
||||
document.querySelectorAll('.form-filecontent').forEach((e) => {
|
||||
e.value = encodeURIComponent(editorsjs[j++].state.doc.toString())
|
||||
})
|
||||
}
|
3
public/favicon.svg
vendored
Normal file
3
public/favicon.svg
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 294 B |
132
public/main.js
vendored
Normal file
132
public/main.js
vendored
Normal file
|
@ -0,0 +1,132 @@
|
|||
import './style.css'
|
||||
import './markdown.css'
|
||||
import 'highlight.js/styles/tokyo-night-dark.css'
|
||||
import moment from 'moment'
|
||||
import md from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.moment-timestamp').forEach((e) => {
|
||||
e.title = moment.unix(e.innerHTML).format('LLLL')
|
||||
e.innerHTML = moment.unix(e.innerHTML).fromNow()
|
||||
})
|
||||
|
||||
document.querySelectorAll('.moment-timestamp-date').forEach((e) => {
|
||||
e.innerHTML = moment.unix(e.innerHTML).format('DD/MM/YYYY HH:mm')
|
||||
})
|
||||
|
||||
let rev = document.querySelector('.revision-text')
|
||||
if (rev) {
|
||||
let fullRev = rev.innerHTML
|
||||
let smallRev = fullRev.substring(0, 8)
|
||||
rev.innerHTML = smallRev
|
||||
|
||||
rev.onmouseover = () => {
|
||||
rev.innerHTML = fullRev
|
||||
}
|
||||
rev.onmouseout = () => {
|
||||
rev.innerHTML = smallRev
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.markdown').forEach((e) => {
|
||||
e.innerHTML = md().render(e.innerHTML);
|
||||
})
|
||||
|
||||
document.querySelectorAll('.table-code').forEach((el) => {
|
||||
let ext = el.dataset.filename.split('.').pop()
|
||||
if (ext!== 'txt') {
|
||||
|
||||
el.querySelectorAll('td.line-code').forEach((ell) => {
|
||||
ell.classList.add('language-'+ext)
|
||||
hljs.highlightElement(ell);
|
||||
});
|
||||
}
|
||||
|
||||
// more efficient
|
||||
el.addEventListener('click', event => {
|
||||
if (event.target.matches('.line-num')) {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
|
||||
event.target.nextSibling.classList.add('selected')
|
||||
|
||||
let filename = el.dataset.filenameSlug
|
||||
let line = event.target.textContent
|
||||
let url = location.protocol + '//' + location.host + location.pathname
|
||||
let hash = '#file-'+ filename + '-' +line
|
||||
window.history.pushState(null, null, url+hash);
|
||||
location.hash = hash;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
let colorhash = () => {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
let lineEl = document.querySelector(location.hash)
|
||||
if (lineEl) {
|
||||
lineEl.nextSibling.classList.add('selected')
|
||||
}
|
||||
}
|
||||
|
||||
if (location.hash) {
|
||||
colorhash()
|
||||
}
|
||||
window.onhashchange = colorhash
|
||||
|
||||
document.getElementById('main-menu-button').onclick = () => {
|
||||
document.getElementById('mobile-menu').classList.toggle('hidden')
|
||||
}
|
||||
|
||||
let tabs = document.getElementById('gist-tabs')
|
||||
if (tabs) {
|
||||
tabs.onchange = (e) => {
|
||||
// navigate to the url in data-url
|
||||
window.location.href = e.target.selectedOptions[0].dataset.url
|
||||
}
|
||||
}
|
||||
|
||||
let gistmenutoggle = document.getElementById('gist-menu-toggle');
|
||||
if (gistmenutoggle) {
|
||||
let gistmenucopy = document.getElementById('gist-menu-copy')
|
||||
let gistmenubuttoncopy = document.getElementById('gist-menu-button-copy')
|
||||
let gistmenuinput = document.getElementById('gist-menu-input')
|
||||
let gistmenutitle = document.getElementById('gist-menu-title')
|
||||
gistmenutoggle.onclick = () => {
|
||||
gistmenucopy.classList.toggle('hidden')
|
||||
}
|
||||
|
||||
for (let item of gistmenucopy.children) {
|
||||
item.onclick = () => {
|
||||
gistmenutitle.textContent = item.firstChild.textContent
|
||||
gistmenuinput.value = item.dataset.link
|
||||
gistmenucopy.classList.toggle('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
gistmenubuttoncopy.onclick = () => {
|
||||
let text = gistmenuinput.value
|
||||
navigator.clipboard.writeText(text).then(null, function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let sortgist = document.getElementById('sort-gists-button')
|
||||
if (sortgist) {
|
||||
sortgist.onclick = () => {
|
||||
document.getElementById('sort-gists-dropdown').classList.toggle('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.copy-gist-btn').forEach((e) => {
|
||||
e.onclick = () => {
|
||||
navigator.clipboard.writeText(e.parentNode.querySelector('.gist-content').textContent).then(null, function (err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
});
|
942
public/markdown.css
vendored
Normal file
942
public/markdown.css
vendored
Normal file
|
@ -0,0 +1,942 @@
|
|||
/* https://github.com/sindresorhus/github-markdown-css/blob/main/github-markdown-dark.css */
|
||||
|
||||
.markdown-body {
|
||||
color-scheme: dark;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
margin: 0;
|
||||
color: #c9d1d9;
|
||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body .octicon {
|
||||
display: inline-block;
|
||||
fill: currentColor;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor .octicon-link:before,
|
||||
.markdown-body h2:hover .anchor .octicon-link:before,
|
||||
.markdown-body h3:hover .anchor .octicon-link:before,
|
||||
.markdown-body h4:hover .anchor .octicon-link:before,
|
||||
.markdown-body h5:hover .anchor .octicon-link:before,
|
||||
.markdown-body h6:hover .anchor .octicon-link:before {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>");
|
||||
mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>");
|
||||
}
|
||||
|
||||
.markdown-body details,
|
||||
.markdown-body figcaption,
|
||||
.markdown-body figure {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.markdown-body [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
background-color: transparent;
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:active,
|
||||
.markdown-body a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
.markdown-body abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.markdown-body b,
|
||||
.markdown-body strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
margin: .67em 0;
|
||||
font-weight: 600;
|
||||
padding-bottom: .3em;
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.markdown-body mark {
|
||||
background-color: rgba(187,128,9,0.15);
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body small {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.markdown-body sub,
|
||||
.markdown-body sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.markdown-body sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
.markdown-body sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
border-style: none;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.markdown-body code,
|
||||
.markdown-body kbd,
|
||||
.markdown-body pre,
|
||||
.markdown-body samp {
|
||||
font-family: monospace,monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-body figure {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
box-sizing: content-box;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #21262d;
|
||||
height: .25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #30363d;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
font: inherit;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.markdown-body [type=button],
|
||||
.markdown-body [type=reset],
|
||||
.markdown-body [type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
.markdown-body [type=button]::-moz-focus-inner,
|
||||
.markdown-body [type=reset]::-moz-focus-inner,
|
||||
.markdown-body [type=submit]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body [type=button]:-moz-focusring,
|
||||
.markdown-body [type=reset]:-moz-focusring,
|
||||
.markdown-body [type=submit]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
.markdown-body [type=checkbox],
|
||||
.markdown-body [type=radio] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body [type=number]::-webkit-inner-spin-button,
|
||||
.markdown-body [type=number]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-body [type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.markdown-body [type=search]::-webkit-search-cancel-button,
|
||||
.markdown-body [type=search]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.markdown-body ::-webkit-input-placeholder {
|
||||
color: inherit;
|
||||
opacity: .54;
|
||||
}
|
||||
|
||||
.markdown-body ::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body hr::before {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body hr::after {
|
||||
display: table;
|
||||
clear: both;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-body td,
|
||||
.markdown-body th {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body details:not([open])>*:not(summary) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.markdown-body kbd {
|
||||
display: inline-block;
|
||||
padding: 3px 5px;
|
||||
font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
|
||||
line-height: 10px;
|
||||
color: #c9d1d9;
|
||||
vertical-align: middle;
|
||||
background-color: #161b22;
|
||||
border: solid 1px rgba(110,118,129,0.4);
|
||||
border-bottom-color: rgba(110,118,129,0.4);
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 -1px 0 rgba(110,118,129,0.4);
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-weight: 600;
|
||||
padding-bottom: .3em;
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-body h5 {
|
||||
font-weight: 600;
|
||||
font-size: .875em;
|
||||
}
|
||||
|
||||
.markdown-body h6 {
|
||||
font-weight: 600;
|
||||
font-size: .85em;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
color: #8b949e;
|
||||
border-left: .25em solid #30363d;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ul ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.markdown-body ul ul ol,
|
||||
.markdown-body ul ol ol,
|
||||
.markdown-body ol ul ol,
|
||||
.markdown-body ol ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-body dd {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.markdown-body tt,
|
||||
.markdown-body code {
|
||||
font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
|
||||
font-size: 12px;
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
.markdown-body .octicon {
|
||||
display: inline-block;
|
||||
overflow: visible !important;
|
||||
vertical-align: text-bottom;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.markdown-body ::placeholder {
|
||||
color: #484f58;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.markdown-body input::-webkit-outer-spin-button,
|
||||
.markdown-body input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c1,
|
||||
.markdown-body .pl-s .pl-v {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.markdown-body .pl-e,
|
||||
.markdown-body .pl-en {
|
||||
color: #d2a8ff;
|
||||
}
|
||||
|
||||
.markdown-body .pl-smi,
|
||||
.markdown-body .pl-s .pl-s1 {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ent {
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
.markdown-body .pl-k {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
.markdown-body .pl-s,
|
||||
.markdown-body .pl-pds,
|
||||
.markdown-body .pl-s .pl-pse .pl-s1,
|
||||
.markdown-body .pl-sr,
|
||||
.markdown-body .pl-sr .pl-cce,
|
||||
.markdown-body .pl-sr .pl-sre,
|
||||
.markdown-body .pl-sr .pl-sra {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.markdown-body .pl-v,
|
||||
.markdown-body .pl-smw {
|
||||
color: #ffa657;
|
||||
}
|
||||
|
||||
.markdown-body .pl-bu {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ii {
|
||||
color: #f0f6fc;
|
||||
background-color: #8e1519;
|
||||
}
|
||||
|
||||
.markdown-body .pl-c2 {
|
||||
color: #f0f6fc;
|
||||
background-color: #b62324;
|
||||
}
|
||||
|
||||
.markdown-body .pl-sr .pl-cce {
|
||||
font-weight: bold;
|
||||
color: #7ee787;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ml {
|
||||
color: #f2cc60;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mh,
|
||||
.markdown-body .pl-mh .pl-en,
|
||||
.markdown-body .pl-ms {
|
||||
font-weight: bold;
|
||||
color: #1f6feb;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi {
|
||||
font-style: italic;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mb {
|
||||
font-weight: bold;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body .pl-md {
|
||||
color: #ffdcd7;
|
||||
background-color: #67060c;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi1 {
|
||||
color: #aff5b4;
|
||||
background-color: #033a16;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mc {
|
||||
color: #ffdfb6;
|
||||
background-color: #5a1e02;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mi2 {
|
||||
color: #c9d1d9;
|
||||
background-color: #1158c7;
|
||||
}
|
||||
|
||||
.markdown-body .pl-mdr {
|
||||
font-weight: bold;
|
||||
color: #d2a8ff;
|
||||
}
|
||||
|
||||
.markdown-body .pl-ba {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.markdown-body .pl-sg {
|
||||
color: #484f58;
|
||||
}
|
||||
|
||||
.markdown-body .pl-corl {
|
||||
text-decoration: underline;
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.markdown-body [data-catalyst] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-body g-emoji {
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
|
||||
font-size: 1em;
|
||||
font-style: normal !important;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
vertical-align: -0.075em;
|
||||
}
|
||||
|
||||
.markdown-body g-emoji img {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.markdown-body::before {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body::after {
|
||||
display: table;
|
||||
clear: both;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.markdown-body>*:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body>*:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.markdown-body a:not([href]) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body .absent {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.markdown-body .anchor {
|
||||
float: left;
|
||||
padding-right: 4px;
|
||||
margin-left: -20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-body .anchor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.markdown-body p,
|
||||
.markdown-body blockquote,
|
||||
.markdown-body ul,
|
||||
.markdown-body ol,
|
||||
.markdown-body dl,
|
||||
.markdown-body table,
|
||||
.markdown-body pre,
|
||||
.markdown-body details {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote>:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote>:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body sup>a::before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
.markdown-body sup>a::after {
|
||||
content: "]";
|
||||
}
|
||||
|
||||
.markdown-body h1 .octicon-link,
|
||||
.markdown-body h2 .octicon-link,
|
||||
.markdown-body h3 .octicon-link,
|
||||
.markdown-body h4 .octicon-link,
|
||||
.markdown-body h5 .octicon-link,
|
||||
.markdown-body h6 .octicon-link {
|
||||
color: #c9d1d9;
|
||||
vertical-align: middle;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor,
|
||||
.markdown-body h2:hover .anchor,
|
||||
.markdown-body h3:hover .anchor,
|
||||
.markdown-body h4:hover .anchor,
|
||||
.markdown-body h5:hover .anchor,
|
||||
.markdown-body h6:hover .anchor {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body h1:hover .anchor .octicon-link,
|
||||
.markdown-body h2:hover .anchor .octicon-link,
|
||||
.markdown-body h3:hover .anchor .octicon-link,
|
||||
.markdown-body h4:hover .anchor .octicon-link,
|
||||
.markdown-body h5:hover .anchor .octicon-link,
|
||||
.markdown-body h6:hover .anchor .octicon-link {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.markdown-body h1 tt,
|
||||
.markdown-body h1 code,
|
||||
.markdown-body h2 tt,
|
||||
.markdown-body h2 code,
|
||||
.markdown-body h3 tt,
|
||||
.markdown-body h3 code,
|
||||
.markdown-body h4 tt,
|
||||
.markdown-body h4 code,
|
||||
.markdown-body h5 tt,
|
||||
.markdown-body h5 code,
|
||||
.markdown-body h6 tt,
|
||||
.markdown-body h6 code {
|
||||
padding: 0 .2em;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.markdown-body ul.no-list,
|
||||
.markdown-body ol.no-list {
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.markdown-body ol[type="1"] {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body ol[type=a] {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-body ol[type=i] {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
.markdown-body div>ol:not([type]) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body ul ul,
|
||||
.markdown-body ul ol,
|
||||
.markdown-body ol ol,
|
||||
.markdown-body ol ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body li>p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.markdown-body li+li {
|
||||
margin-top: .25em;
|
||||
}
|
||||
|
||||
.markdown-body dl {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body dl dt {
|
||||
padding: 0;
|
||||
margin-top: 16px;
|
||||
font-size: 1em;
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body dl dd {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.markdown-body table tr {
|
||||
background-color: #0d1117;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: #161b22;
|
||||
}
|
||||
|
||||
.markdown-body table img {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body img[align=right] {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body img[align=left] {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.markdown-body .emoji {
|
||||
max-width: none;
|
||||
vertical-align: text-top;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body span.frame {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-body span.frame>span {
|
||||
display: block;
|
||||
float: left;
|
||||
width: auto;
|
||||
padding: 7px;
|
||||
margin: 13px 0 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.markdown-body span.frame span img {
|
||||
display: block;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.markdown-body span.frame span span {
|
||||
display: block;
|
||||
padding: 5px 0 0;
|
||||
clear: both;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body span.align-center {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown-body span.align-center>span {
|
||||
display: block;
|
||||
margin: 13px auto 0;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.markdown-body span.align-center span img {
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.markdown-body span.align-right {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.markdown-body span.align-right>span {
|
||||
display: block;
|
||||
margin: 13px 0 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.markdown-body span.align-right span img {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.markdown-body span.float-left {
|
||||
display: block;
|
||||
float: left;
|
||||
margin-right: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-body span.float-left span {
|
||||
margin: 13px 0 0;
|
||||
}
|
||||
|
||||
.markdown-body span.float-right {
|
||||
display: block;
|
||||
float: right;
|
||||
margin-left: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-body span.float-right>span {
|
||||
display: block;
|
||||
margin: 13px auto 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.markdown-body code,
|
||||
.markdown-body tt {
|
||||
padding: .2em .4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(110,118,129,0.4);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-body code br,
|
||||
.markdown-body tt br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-body del code {
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.markdown-body pre>code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body .highlight {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body .highlight pre {
|
||||
margin-bottom: 0;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.markdown-body .highlight pre,
|
||||
.markdown-body pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #161b22;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-body pre code,
|
||||
.markdown-body pre tt {
|
||||
display: inline;
|
||||
max-width: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
line-height: inherit;
|
||||
word-wrap: normal;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body .csv-data td,
|
||||
.markdown-body .csv-data th {
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.markdown-body .csv-data .blob-num {
|
||||
padding: 10px 8px 9px;
|
||||
text-align: right;
|
||||
background: #0d1117;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body .csv-data tr {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body .csv-data th {
|
||||
font-weight: 600;
|
||||
background: #161b22;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
border-top: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes ol {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes li:target::before {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
left: -24px;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
border: 2px solid #1f6feb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes li:target {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.markdown-body .footnotes .data-footnote-backref g-emoji {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item label {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item.enabled label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item+.task-list-item {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item .handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item-checkbox {
|
||||
margin: 0 .2em .25em -1.6em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox {
|
||||
margin: 0 -1.6em .25em .2em;
|
||||
}
|
||||
|
||||
.markdown-body ::-webkit-calendar-picker-indicator {
|
||||
filter: invert(50%);
|
||||
}
|
109
public/style.css
vendored
Normal file
109
public/style.css
vendored
Normal file
|
@ -0,0 +1,109 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
ul, ol {
|
||||
list-style: revert;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply text-primary-400;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply placeholder-gray-400;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"], pre[class*="language-"] {
|
||||
@apply bg-gray-900 mt-1 pt-1 !important;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 0.8em !important;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: Menlo,Consolas,Liberation Mono,monospace;
|
||||
}
|
||||
|
||||
.code .line-num {
|
||||
width: 4%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.red-diff {
|
||||
background-color: rgba(255, 0, 0, .1);
|
||||
}
|
||||
|
||||
.green-diff {
|
||||
background-color: rgba(0, 255, 128, .1);
|
||||
}
|
||||
|
||||
.gray-diff {
|
||||
background-color: rgba(143, 143, 143, 0.38);
|
||||
@apply py-4 !important
|
||||
}
|
||||
|
||||
#logged-button:hover .username {
|
||||
@apply hidden !important
|
||||
}
|
||||
|
||||
#logged-button:hover .logout {
|
||||
@apply block !important
|
||||
}
|
||||
|
||||
.cm-line, .cm-gutter {
|
||||
@apply bg-gray-900 !important;
|
||||
caret-color: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.cm-activeLine, .cm-activeLineGutter {
|
||||
@apply bg-gray-800 !important;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.cm-gutterElement {
|
||||
@apply text-gray-300 px-4 !important
|
||||
}
|
||||
|
||||
.code td {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.code tbody {
|
||||
line-height: 18.2px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
height: 337px;
|
||||
max-height: 337px;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 337px;
|
||||
max-height: 337px;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.line-code.selected {
|
||||
background-color: rgba(65, 25, 63, 0.46) !important;
|
||||
box-shadow: inset 4px 0 0 rgb(107, 38, 102) !important;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
@apply cursor-pointer text-slate-400 hover:text-white;
|
||||
}
|
37
tailwind.config.js
Normal file
37
tailwind.config.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
const colors = require('tailwindcss/colors')
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./templates/**/*.html",
|
||||
],
|
||||
theme: {
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
white: colors.white,
|
||||
black: colors.black,
|
||||
gray: {
|
||||
50: "#EEEFF1",
|
||||
100: "#DEDFE3",
|
||||
200: "#BABCC5",
|
||||
300: "#999CA8",
|
||||
400: "#75798A",
|
||||
500: "#585B68",
|
||||
600: "#464853",
|
||||
700: "#363840",
|
||||
800: "#232429",
|
||||
900: "#131316"
|
||||
},
|
||||
emerald: colors.emerald,
|
||||
rose: colors.rose,
|
||||
primary: colors.sky,
|
||||
slate: colors.slate
|
||||
},
|
||||
extend: {
|
||||
borderWidth: {
|
||||
'1': '1px',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography"),require('@tailwindcss/forms')],
|
||||
}
|
9
templates/base/admin_footer.html
vendored
Normal file
9
templates/base/admin_footer.html
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{ define "admin_footer" }}
|
||||
{{ if .urlPage }}
|
||||
<div class="flex mt-4 justify-center space-x-2">
|
||||
{{ template "pagination" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
22
templates/base/admin_header.html
vendored
Normal file
22
templates/base/admin_header.html
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
{{ define "admin_header" }}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">Admin panel</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="mb-4">
|
||||
<div class="">
|
||||
<nav class="flex space-x-4" aria-label="Tabs">
|
||||
<a href="/admin" class="{{ if eq .adminHeaderPage "index" }}bg-gray-700 text-slate-300 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-400 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}">General</a>
|
||||
<a href="/admin/users" class="{{ if eq .adminHeaderPage "users" }}bg-gray-700 text-slate-300 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-400 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">Users</a>
|
||||
<a href="/admin/gists" class="{{ if eq .adminHeaderPage "gists" }}bg-gray-700 text-slate-300 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
|
||||
{{ else }} text-gray-400 hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">Gists</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
21
templates/base/base_footer.html
vendored
Normal file
21
templates/base/base_footer.html
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
{{ define "footer" }}
|
||||
<p class="text-slate-400 py-8 [&>*]:mx-1.5 flex">
|
||||
<span>
|
||||
<a target="_blank" style="margin-left: 0 !important;" class="text-gray-500 hover:text-white font-bold inline-flex" href="https://github.com/thomiceli/opengist">
|
||||
<span class="mr-1">Opengist</span>
|
||||
<svg width="24" height="24" fill="currentColor">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.463 2 11.97c0 4.404 2.865 8.14 6.839 9.458.5.092.682-.216.682-.48 0-.236-.008-.864-.013-1.695-2.782.602-3.369-1.337-3.369-1.337-.454-1.151-1.11-1.458-1.11-1.458-.908-.618.069-.606.069-.606 1.003.07 1.531 1.027 1.531 1.027.892 1.524 2.341 1.084 2.91.828.092-.643.35-1.083.636-1.332-2.22-.251-4.555-1.107-4.555-4.927 0-1.088.39-1.979 1.029-2.675-.103-.252-.446-1.266.098-2.638 0 0 .84-.268 2.75 1.022A9.606 9.606 0 0112 6.82c.85.004 1.705.114 2.504.336 1.909-1.29 2.747-1.022 2.747-1.022.546 1.372.202 2.386.1 2.638.64.696 1.028 1.587 1.028 2.675 0 3.83-2.339 4.673-4.566 4.92.359.307.678.915.678 1.846 0 1.332-.012 2.407-.012 2.734 0 .267.18.577.688.48C19.137 20.107 22 16.373 22 11.969 22 6.463 17.522 2 12 2z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span> ⋅
|
||||
<span class="text-gray-500">Load: <span class="font-bold">{{ loadedTime .loadStartTime }}</span></span>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{ end }}
|
142
templates/base/base_header.html
vendored
Normal file
142
templates/base/base_header.html
vendored
Normal file
|
@ -0,0 +1,142 @@
|
|||
{{ define "header" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="http://localhost:3000/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
html {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="http://localhost:3000/main.js"></script>
|
||||
|
||||
{{ if .htmlTitle }}
|
||||
<title>{{ .htmlTitle }} - Opengist</title>
|
||||
{{ else }}
|
||||
<title>Opengist</title>
|
||||
{{ end }}
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="app" class="text-white min-h-full bg-gray-900">
|
||||
<div class="min-h-full">
|
||||
<nav class="bg-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div class="relative flex items-center justify-between h-16">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
|
||||
<!-- Mobile menu button-->
|
||||
<button id="main-menu-button" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-slate-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" aria-controls="mobile-menu" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg id="main-menu-open" class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg id="main-menu-close" class="hidden h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a href="/">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 flex text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden sm:block sm:ml-6">
|
||||
<div class="flex space-x-4">
|
||||
<a href="/all" class="text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium" aria-current="page">All</a>
|
||||
{{ if .userLogged }}
|
||||
<a href="/{{ .userLogged.Username }}" class="text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">My gists</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||
{{ if .userLogged }}
|
||||
{{ if .userLogged.IsAdmin }}
|
||||
<a href="/admin" class="hidden sm:block text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium" aria-current="page">Admin</a>
|
||||
{{ end }}
|
||||
<a href="/ssh-keys" class="hidden sm:block text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium" aria-current="page">SSH Keys</a>
|
||||
|
||||
<a href="/logout" id="logged-button" class="inline-flex text-slate-300 hover:bg-rose-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
<p class="text-slate-300 mr-1 username">{{ .userLogged.Username }}</p>
|
||||
<p class="text-slate-300 mr-1 logout hidden">Logout</p>
|
||||
<span class="sr-only">User</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
{{ else }}
|
||||
{{ if not .signupDisabled }}
|
||||
<a href="/register" class="inline-flex text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
<p class="text-slate-300 mr-1">Register</p>
|
||||
</a>
|
||||
{{ end }}
|
||||
<a href="/login" class="inline-flex text-slate-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium">
|
||||
<p class="text-slate-300 mr-1">Login</p>
|
||||
</a>
|
||||
{{ end }}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden hidden" id="mobile-menu">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="/all" class="bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" aria-current="page">All</a>
|
||||
{{ if .userLogged }}
|
||||
<a href="/{{ .userLogged.Username }}" class="text-slate-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">My gists</a>
|
||||
<a href="/ssh-keys" class="text-slate-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">SSH Keys</a>
|
||||
|
||||
{{ if .userLogged.IsAdmin }}
|
||||
<a href="/admin" class="text-slate-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium">Admin</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-slate-300">
|
||||
|
||||
|
||||
<div>
|
||||
{{range .flashErrors}}
|
||||
<div class="mt-4 rounded-md bg-gray-800 border-l-4 border-rose-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-rose-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-rose-400">{{.}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .flashSuccess}}
|
||||
<div class="mt-4 rounded-md bg-gray-800 border-l-4 border-primary-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-primary-400">{{.}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{ end }}
|
4
templates/base/gist_footer.html
vendored
Normal file
4
templates/base/gist_footer.html
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
{{ define "gist_footer" }}
|
||||
</main>
|
||||
</div>
|
||||
{{ end }}
|
165
templates/base/gist_header.html
vendored
Normal file
165
templates/base/gist_header.html
vendored
Normal file
|
@ -0,0 +1,165 @@
|
|||
{{ define "gist_header" }}
|
||||
<div class="py-10">
|
||||
<header>
|
||||
<div class="flex">
|
||||
|
||||
<h1 class="text-2xl font-bold leading-tight break-all">
|
||||
<a href="/{{ .gist.User.Username }}">{{ .gist.User.Username }}</a> <span class="text-slate-300">/</span> <a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}">{{ .gist.Title }}</a>
|
||||
</h1>
|
||||
{{ if .userLogged }}
|
||||
<form id="like" class="ml-auto flex items-center" method="post" action="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/like?redirecturl={{ .currentUrl }}">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="ml-auto focus-within:z-10 text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
{{ if not .hasLiked }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
Like
|
||||
{{ else }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 mr-2">
|
||||
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
|
||||
</svg>
|
||||
Unlike
|
||||
{{ end }}
|
||||
</button>
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/likes" class="text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-700 bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbLikes }}
|
||||
</a>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="ml-auto flex items-center">
|
||||
<a href="/login" type="submit" class="ml-auto focus-within:z-10 text-slate-300 relative inline-flex items-center space-x-2 rounded-l-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
Like
|
||||
</a>
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/likes" class="text-slate-300 relative inline-flex align-middle items-center space-x-2 rounded-r-md border border-gray-700 bg-gray-900 px-2 py-1.5 -ml-px text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
{{ .gist.NbLikes }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .userLogged }}{{ if eq .gist.User.Username .userLogged.Username }}
|
||||
<form id="visibility" class="ml-2" method="post" action="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/visibility">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="ml-auto text-slate-300 relative inline-flex items-center space-x-2 rounded-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
{{ if .gist.Private }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Make public
|
||||
{{ else }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
Make unlisted
|
||||
{{ end }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Small hack to avoid ugly button behavior -->
|
||||
<form>
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/edit" class="ml-2 relative inline-flex items-center space-x-2 rounded-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-xs font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Edit
|
||||
</a>
|
||||
</form>
|
||||
<form id="delete" onsubmit="alert('Are you sure you want to delete this gist ?')" class="ml-2" method="post" action="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/delete">
|
||||
{{ .csrfHtml }}
|
||||
<button type="submit" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-xs font-medium text-rose-400 hover:bg-rose-700 hover:border-rose-600 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{{ end }}{{ end }}
|
||||
|
||||
</div>
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-500">Last active <span class="moment-timestamp"> {{ .gist.UpdatedAt }} </span> •
|
||||
{{ if .gist.Private }}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-slate-300"> Unlisted </span>
|
||||
{{else}}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-emerald-700 text-emerald-300"> Public </span>{{ end }}
|
||||
|
||||
</p>
|
||||
<p class="mt-3 max-w-2xl text-slate-300">{{ .gist.Description }}</p>
|
||||
</header>
|
||||
<main class="mt-4">
|
||||
|
||||
<div class="my-4">
|
||||
<div class="sm:hidden">
|
||||
<label for="gist-tabs" class="sr-only">Select a tab</label>
|
||||
<select id="gist-tabs" name="tabs" class="block bg-gray-800 w-full pl-3 pr-10 py-2 text-base border-gray-700 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md">
|
||||
<option {{ if eq .page "code"}}selected{{end}} data-url="/{{ .gist.User.Username }}/{{ .gist.Uuid }}">Code</option>
|
||||
<option {{ if eq .page "revisions"}}selected{{end}} data-url="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/revisions">Revisions ({{ if .nbCommits }}{{ .nbCommits }}{{else}}0{{ end }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="border-b flex border-gray-700">
|
||||
<nav class="-mb-px flex-auto space-x-4" aria-label="Tabs">
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}" class="inline-flex items-center {{ if eq .page "code"}}border-primary-500 {{else}}border-transparent text-slate-300 hover:border-gray-300{{end}} hover:text-slate-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm" aria-current="page">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
Code
|
||||
</a>
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/revisions" class="inline-flex items-center {{ if eq .page "revisions"}}border-primary-500 {{else}}border-transparent text-slate-300 hover:border-gray-300{{end}} hover:text-slate-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
Revisions
|
||||
<span class="inline-flex items-center ml-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-700 text-slate-300"> {{ if .nbCommits }}{{ .nbCommits }}{{else}}0{{ end }} </span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="float-right inline-flex items-center space-x-2">
|
||||
<div>
|
||||
<div class="flex rounded-md shadow-sm">
|
||||
<div class="relative">
|
||||
<button type="button" id="gist-menu-toggle" class="relative text-xs inline-flex items-center space-x-2 rounded-l-md border border-gray-600 bg-gray-800 px-2 py-1.5 text-sm font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3 focus-within:z-10 -mr-px">
|
||||
<span id="gist-menu-title">Clone via HTTP</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute left-0 z-10 mt-2 w-56 origin-top-left bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
|
||||
<div class="py-1 cursor-pointer border-1 rounded-md border-gray-700 hidden" id="gist-menu-copy" role="none">
|
||||
<div class="text-slate-300 block px-4 py-2 text-sm hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-http" data-link="{{ .httpCloneUrl }}"><p>Clone via HTTP</p>
|
||||
<p class="text-xs font-normal text-gray-400">Clone with Git using HTTP basic authentication.</p>
|
||||
</div>
|
||||
<div class="text-slate-300 block px-4 py-2 text-sm hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-ssh" data-link="{{ .ssh_clone_url }}"><p>Clone via SSH</p>
|
||||
<p class="text-xs font-normal text-gray-400">Clone with Git using an SSH key.</p>
|
||||
</div>
|
||||
<div class="text-slate-300 block px-4 py-2 text-sm hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-embed" data-link="(soon)"><p>Embed</p>
|
||||
<p class="text-xs font-normal text-gray-400">Embed this gist in your website. (soon)</p>
|
||||
</div>
|
||||
<div class="text-slate-300 block px-4 py-2 text-sm hover:bg-gray-700 gist-menu-item" role="menuitem" id="gist-menu-share" data-link="{{ .httpCopyUrl }}"><p>Share</p>
|
||||
<p class="text-xs font-normal text-gray-400">Copy shareable link for this gist.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex flex-grow items-stretch focus-within:z-10">
|
||||
<input id="gist-menu-input" value="{{ .httpCloneUrl }}" class="block code bg-gray-900 w-full rounded-none border border-gray-600 focus:border-primary-500 focus:ring-primary-500 focus:outline-none focus:ring-1 text-xs px-2">
|
||||
</div>
|
||||
<button id="gist-menu-button-copy" type="button" class="relative text-xs -ml-px inline-flex items-center space-x-2 rounded-r-md border border-gray-600 bg-gray-800 px-2 py-1 text-sm font-medium text-slate-300 hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 leading-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/archive/{{ .revision }}" class="text-slate-300 rounded border border-gray-600 bg-gray-800 px-2.5 py-2 text-xs font-medium text-white shadow-sm hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
|
||||
Download ZIP</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .revision }} {{ if ne .revision "HEAD" }}
|
||||
<p class="italic text-xs mt-3">Revision <span class="revision-text">{{ .revision }}</span></p>
|
||||
{{ end }} {{ end }}
|
||||
</div>
|
||||
|
||||
{{ end }}
|
31
templates/base/pagination.html
vendored
Normal file
31
templates/base/pagination.html
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
{{ define "pagination" }}
|
||||
<div class="flex justify-center space-x-2">
|
||||
{{ if .prevPage }}
|
||||
<a href="/{{ .urlPage }}?page={{ .prevPage }}{{ .urlParams }}" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-900 bg-gray-900 px-2 py-1.5 font-medium text-slate-300 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-1 w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
|
||||
Newer</a>
|
||||
{{ else }}
|
||||
<span class="relative inline-flex items-center space-x-2 rounded-md border border-gray-900 bg-gray-900 px-2 py-1.5 font-medium text-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mr-1 w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Newer</span>
|
||||
{{ end }}
|
||||
{{ if .nextPage }}
|
||||
<a href="/{{ .urlPage }}?page={{ .nextPage }}{{ .urlParams }}" class="relative inline-flex items-center space-x-2 rounded-md border border-gray-900 bg-gray-900 px-2 py-1.5 font-medium text-slate-300 hover:border-gray-500 hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">Older
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="ml-1 w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
{{ else }}
|
||||
<span class="relative inline-flex items-center space-x-2 rounded-md border border-gray-900 bg-gray-900 px-2 py-1.5 font-medium text-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 text-sm leading-4">Older
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="ml-1 w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
43
templates/pages/admin_gists.html
vendored
Normal file
43
templates/pages/admin_gists.html
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-800 rounded-md border border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-500">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-bold text-slate-300 sm:pl-0">ID</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">Title</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">User</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">Private ?</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300"># files</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300"># likes</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">Created at</th>
|
||||
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">Delete</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-500">
|
||||
{{ range $gist := .data }}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-slate-300 sm:pl-0">{{ $gist.ID }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300"><a href="/{{ $gist.User.Username }}/{{ $gist.Uuid }}">{{ $gist.Title }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300"><a href="/{{ $gist.User.Username }}">{{ $gist.User.Username }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300">{{ $gist.Private }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300">{{ $gist.NbFiles }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300">{{ $gist.NbLikes }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300"><span class="moment-timestamp-date">{{ $gist.CreatedAt }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<form action="/admin/gists/{{ $gist.ID }}/delete" method="POST" onsubmit="return confirm('Do you want to delete this user ?')">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" class="text-rose-500 hover:text-rose-600">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
55
templates/pages/admin_index.html
vendored
Normal file
55
templates/pages/admin_index.html
vendored
Normal file
|
@ -0,0 +1,55 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
|
||||
<div class="sm:flex sm:space-x-4 space-y-4 sm:space-y-0">
|
||||
<div class="sm:overflow-hidden ">
|
||||
<div class="space-y-2 bg-gray-800 py-6 px-6 rounded-md border border-gray-700">
|
||||
<div>
|
||||
<span class="text-base font-bold leading-6 text-slate-300">Versions</span>
|
||||
</div>
|
||||
<table class="table-fixed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">Opengist</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .opengistVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">Go</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .goVersion }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">Git</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .gitVersion }} </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:overflow-hidden ">
|
||||
<div class="space-y-2 bg-gray-800 py-6 px-6 rounded-md border border-gray-700">
|
||||
<div>
|
||||
<span class="text-base font-bold leading-6 text-slate-300">Stats</span>
|
||||
</div>
|
||||
<table class="table-fixed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">Users</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .countUsers }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">Gists</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .countGists }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pr-3 text-sm text-slate-300 ">SSH keys</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm font-medium text-slate-300">{{ .countKeys }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
35
templates/pages/admin_users.html
vendored
Normal file
35
templates/pages/admin_users.html
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "admin_header" .}}
|
||||
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-800 rounded-md border border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-500">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-bold text-slate-300 sm:pl-0">ID</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">Username</th>
|
||||
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-300">Created</th>
|
||||
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">Delete</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-500">
|
||||
{{ range $user := .data }}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-slate-300 sm:pl-0">{{ $user.ID }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300"><a href="/{{ $user.Username }}">{{ $user.Username }}</a></td>
|
||||
<td class="whitespace-nowrap px-2 py-2 text-sm text-slate-300"><span class="moment-timestamp-date">{{ $user.CreatedAt }}</span></td>
|
||||
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<form action="/admin/users/{{ $user.ID }}/delete" method="POST" onsubmit="return confirm('Do you want to delete this user ?')">
|
||||
{{ $.csrfHtml }}
|
||||
<button type="submit" class="text-rose-500 hover:text-rose-600">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ template "admin_footer" .}}
|
||||
{{ template "footer" .}}
|
109
templates/pages/all.html
vendored
Normal file
109
templates/pages/all.html
vendored
Normal file
|
@ -0,0 +1,109 @@
|
|||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header class="pb-4 flex ">
|
||||
<div class="flex-auto">
|
||||
<h1 class="text-2xl font-bold leading-tight">All gists {{if .fromUser}} from {{.fromUser}} {{end}}</h1>
|
||||
</div>
|
||||
<div class="float-right">
|
||||
<div class="relative inline-block text-left">
|
||||
<div>
|
||||
<button type="button" class="inline-flex text-slate-300 rounded border border-gray-600 bg-gray-800 px-2.5 py-2 text-xs font-medium text-white shadow-sm hover:bg-gray-700 hover:border-gray-500 hover:text-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 leading-3" id="sort-gists-button">
|
||||
<span class="text-gray-300">Sort : <span class="text-slate-300">{{.order}} {{.sort}}</span></span>
|
||||
<svg class="-mr-1 ml-2 h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sort-gists-dropdown" class="hidden absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-gray-700 rounded-md rounded border border-gray-600 bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
|
||||
<div class="" role="none">
|
||||
<a href="/{{if .fromUser}}{{.fromUser}}{{else}}all{{end}}?sort=created&order=desc" class="text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-700 hover:text-white hover:bg-primary-500 hover:rounded-t-md" role="menuitem">
|
||||
Recently created
|
||||
</a>
|
||||
</div>
|
||||
<div class="" role="none">
|
||||
<a href="/{{if .fromUser}}{{.fromUser}}{{else}}all{{end}}?sort=created&order=asc" class="text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-700 hover:text-white hover:bg-primary-500" role="menuitem">
|
||||
Least recently created
|
||||
</a>
|
||||
</div><div class="" role="none">
|
||||
<a href="/{{if .fromUser}}{{.fromUser}}{{else}}all{{end}}?sort=updated&order=desc" class="text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-700 hover:text-white hover:bg-primary-500" role="menuitem">
|
||||
Recently updated
|
||||
</a>
|
||||
</div>
|
||||
<div class="" role="none">
|
||||
<a href="/{{if .fromUser}}{{.fromUser}}{{else}}all{{end}}?sort=updated&order=asc" class="text-slate-300 group flex items-center px-3 py-2 text-xs hover:bg-gray-700 hover:text-white hover:bg-primary-500 hover:rounded-b-md" role="menuitem">
|
||||
Least recently updated
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div>
|
||||
{{ if ne (len .gists) 0 }}
|
||||
{{ range $gist := .gists }}
|
||||
<div class="mb-8">
|
||||
<div class="flex">
|
||||
<h4 class="text-md leading-tight break-all py-1 flex-auto">
|
||||
<a href="/{{ $gist.User.Username }}">{{ $gist.User.Username }}</a> <span class="text-slate-300">/</span> <a class="font-bold" href="/{{ $gist.User.Username }}/{{ $gist.Uuid }}">{{ $gist.Title }}</a>
|
||||
</h4>
|
||||
<div class="flex space-x-4">
|
||||
<div class="flex items-center float-right text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1 inline-flex">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
<span>{{ $gist.NbLikes }} likes</span>
|
||||
</div>
|
||||
<div class="flex items-center float-right text-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1 inline-flex">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
<span>{{ $gist.NbFiles }} files</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<h5 class="text-sm text-slate-500 pb-1">Last active <span class="moment-timestamp">{{ $gist.UpdatedAt }}</span>
|
||||
{{ if $gist.Private }} • <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-slate-300"> Unlisted </span>{{ end }}</h5>
|
||||
<h5 class="text-xs text-slate-300 py-1">{{ $gist.Description }}</h5>
|
||||
<a href="/{{ $gist.User.Username }}/{{ $gist.Uuid }}" class="hover:text-slate-300">
|
||||
<div class="rounded-md border border-1 border-gray-700 overflow-auto hover:border-primary-600">
|
||||
<div class="code overflow-auto">
|
||||
{{ if isMarkdown $gist.PreviewFilename }}
|
||||
<div class="markdown markdown-body p-8">{{ $gist.Preview }}</div>
|
||||
{{ else }}
|
||||
<table class="table-code w-full whitespace-pre" data-filename="{{ $gist.PreviewFilename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := lines $gist.Preview }}
|
||||
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">{{$i}}</td>
|
||||
<td class="line-code">{{ $line }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "pagination" . }}
|
||||
{{ else }}
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-slate-300">No gists</h3>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{ template "footer" .}}
|
58
templates/pages/auth_form.html
vendored
Normal file
58
templates/pages/auth_form.html
vendored
Normal file
|
@ -0,0 +1,58 @@
|
|||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header>
|
||||
|
||||
<h1 class="text-2xl font-bold leading-tight text-slate-300">
|
||||
{{ .title }}
|
||||
</h1>
|
||||
|
||||
</header>
|
||||
<main class="mt-4">
|
||||
{{ if and .signupDisabled (ne .title "Login") }}
|
||||
<p class="italic">Administrator has disabled signing up</p>
|
||||
{{ else }}
|
||||
<div class="sm:col-span-6">
|
||||
<div class="mt-8 sm:w-full sm:max-w-md">
|
||||
<div class="bg-gray-900 rounded-md border border-1 border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<form class="space-y-6" action="#" method="post">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-300"> Username </label>
|
||||
<div class="mt-1">
|
||||
<input id="username" name="username" type="text" required class="bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-700 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<label for="password" class="block text-sm font-medium text-slate-300"> Password </label>
|
||||
<div class="mt-1">
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required class="bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-700 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
{{ if eq .title "Login" }}
|
||||
<div class="flex">
|
||||
<div class="flex-auto">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">Login</button>
|
||||
</div>
|
||||
{{ if not .signupDisabled }}
|
||||
<span class="float-right text-sm py-2 underline"><a href="/register">Register instead →</a></span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="flex">
|
||||
<div class="flex-auto">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">Register</button>
|
||||
</div>
|
||||
<span class="float-right text-sm py-2 underline"><a href="/login">Login instead →</a></span>
|
||||
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{ template "footer" .}}
|
49
templates/pages/create.html
vendored
Normal file
49
templates/pages/create.html
vendored
Normal file
|
@ -0,0 +1,49 @@
|
|||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header>
|
||||
|
||||
<h1 class="text-2xl font-bold leading-tight text-slate-300">
|
||||
New Gist
|
||||
</h1>
|
||||
|
||||
</header>
|
||||
<main class="mt-4">
|
||||
<form id="create" class="space-y-4" method="post" action="/">
|
||||
<div class="grid grid-cols-12 gap-x-4">
|
||||
<div class="col-span-4">
|
||||
<div class="mt-1">
|
||||
<input type="text" placeholder="Title" name="title" id="title" class="bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-8">
|
||||
<div class="mt-1">
|
||||
<input type="text" placeholder="Description" name="description" id="description" class="bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div id="editors" class="space-y-4">
|
||||
<div class="rounded-md border border-1 border-gray-700 editor">
|
||||
<div class="border-b-1 border-gray-700 bg-gray-800 my-auto">
|
||||
<p class="mx-2 my-2 inline-flex">
|
||||
<input type="text" name="name" placeholder="Filename with extension" style="line-height: 0.05em" class="form-filename bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-md">
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" value="" name="content" class="form-filecontent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">Add file</button>
|
||||
<button type="submit" name="private" value="1" class="ml-auto inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">Create unlisted gist</button>
|
||||
<button type="submit" name="private" value="0" class="ml-2 inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">Create public gist</button>
|
||||
</div>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="http://localhost:3000/editor.js"></script>
|
||||
|
||||
{{ template "footer" .}}
|
57
templates/pages/edit.html
vendored
Normal file
57
templates/pages/edit.html
vendored
Normal file
|
@ -0,0 +1,57 @@
|
|||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header>
|
||||
|
||||
<h1 class="text-2xl font-bold leading-tight text-slate-300">
|
||||
Editing {{ .gist.Title }}
|
||||
</h1>
|
||||
|
||||
</header>
|
||||
<main class="mt-4">
|
||||
<form id="create" class="space-y-4" method="post" action="/{{ .gist.User.Username }}/{{ .gist.Uuid }}/edit">
|
||||
<div class="grid grid-cols-12 gap-x-4">
|
||||
<div class="sm:col-span-4">
|
||||
<div class="mt-1">
|
||||
<input type="text" value="{{ .gist.Title }}" placeholder="Title" name="title" id="title" class="bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-8">
|
||||
<div class="mt-1">
|
||||
<input type="text" value="{{ .gist.Description }}" placeholder="Description" name="description" id="description" class="bg-black shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div id="editors" class="space-y-4">
|
||||
{{ range $filename, $content := .files }}
|
||||
<div class="rounded-md border border-1 border-gray-700 editor">
|
||||
<div class="border-b-1 border-gray-700 bg-gray-800 my-auto">
|
||||
<p class="mx-2 my-2 inline-flex">
|
||||
<input type="text" value="{{ $filename }}" name="name" placeholder="Filename with extension" style="line-height: 0.05em; z-index: 99999" class="form-filename bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-700 rounded-l-md">
|
||||
<button style="line-height: 0.05em" class="delete-file -ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-700 text-sm font-medium rounded-r-md text-slate-300 bg-gray-800 hover:bg-gray-900 focus:outline-none" type="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" value="{{ $content }}" name="content" class="form-filecontent">
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">Add file</button>
|
||||
<a href="/{{ .gist.User.Username }}/{{ .gist.Uuid }}" type="submit" name="private" value="1" class="ml-auto inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 text-rose-400 hover:text-rose-400">Cancel</a>
|
||||
<button type="submit" name="private" value="0" class="ml-2 inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">Save</button>
|
||||
</div>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="http://localhost:3000/editor.js"></script>
|
||||
|
||||
|
||||
{{ template "footer" .}}
|
14
templates/pages/error.html
vendored
Normal file
14
templates/pages/error.html
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
{{ template "header" .}}
|
||||
|
||||
<div class="mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-12 w-12 text-slate-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
|
||||
<h1 class="mt-2 text-3xl font-medium text-slate-300">Error {{ .error.Code }}</h1>
|
||||
<h3 class="mt-2 text-md font-medium text-slate-300">{{ httpStatusText .error.Code }}</h3>
|
||||
{{ if lt .error.Code 500 }}
|
||||
<p class="mt-2 text-sm font-medium text-slate-300">{{ .error.Message }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "footer" .}}
|
45
templates/pages/gist.html
vendored
Normal file
45
templates/pages/gist.html
vendored
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "gist_header" .}}
|
||||
{{ if .files }}
|
||||
<div class="grid gap-y-4">
|
||||
{{ range $filename, $content := .files }}
|
||||
<div class="rounded-md border border-1 border-gray-700 overflow-auto">
|
||||
<div class="border-b-1 border-gray-700 bg-gray-800 my-auto block">
|
||||
<div class="ml-4 py-1.5 flex">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 flex text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
<span class="flex-auto ml-2 text-sm text-slate-300 filename" id="file-{{ slug $filename }}"><a href="#file-{{ slug $filename }}">{{ $filename }}</a></span>
|
||||
|
||||
<button class="float-right mx-2 px-2.5 py-0.5 leading-4 rounded-full text-xs font-medium bg-gray-600 border border-gray-500 hover:bg-gray-700 hover:text-slate-300 select-none copy-gist-btn"> Copy </button>
|
||||
<a href="/{{ $.gist.User.Username }}/{{ $.gist.Uuid }}/raw/{{ $.commit }}/{{$filename}}" class="float-right mr-2 px-2.5 py-0.5 leading-4 rounded-full text-xs font-medium bg-gray-600 border border-gray-500 hover:bg-gray-700 hover:text-slate-300 select-none"> Raw </a>
|
||||
<div class="hidden gist-content">{{ $content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code overflow-auto">
|
||||
{{ if isMarkdown $filename }}
|
||||
<div class="markdown markdown-body p-8">{{ $content }}</div>
|
||||
{{ else }}
|
||||
{{ $fileslug := slug $filename }}
|
||||
<table class="table-code w-full whitespace-pre" data-filename-slug="{{ $fileslug }}" data-filename="{{ $filename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := lines $content }}<tr><td id="file-{{ $fileslug }}-{{$i}}" class="select-none line-num px-4">{{$i}}</td><td class="line-code">{{ $line }}</td></tr>{{ $i = inc $i }}{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-slate-300">No content</h3>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "gist_footer" .}}
|
||||
{{ template "footer" .}}
|
27
templates/pages/likes.html
vendored
Normal file
27
templates/pages/likes.html
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "gist_header" .}}
|
||||
{{ if ne (len .likers) 0 }}
|
||||
<h3 class="text-xl font-bold leading-tight break-all py-2">Likes</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
|
||||
{{ range $user := .likers }}
|
||||
<div class="relative flex items-center space-x-3 rounded-lg border border-gray-600 bg-gray-800 px-6 py-5 shadow-sm focus-within:ring-1 focus-within:border-primary-500 focus-within:ring-primary-500 hover:border-gray-400">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="/{{ $user.Username }}" class="focus:outline-none">
|
||||
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||
<p class="text-sm font-medium text-slate-300">{{ $user.Username }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="mx-auto h-12 w-12 text-slate-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
|
||||
<h3 class="mt-2 text-sm font-medium text-slate-300">No likes yet</h3>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "gist_footer" .}}
|
||||
{{ template "footer" .}}
|
110
templates/pages/revisions.html
vendored
Normal file
110
templates/pages/revisions.html
vendored
Normal file
|
@ -0,0 +1,110 @@
|
|||
{{ template "header" .}}
|
||||
{{ template "gist_header" .}}
|
||||
{{ if ne (len .commits) 0 }}
|
||||
|
||||
<div>
|
||||
{{ range $commit := .commits }}
|
||||
<div class="pb-8">
|
||||
<div class="flex">
|
||||
<h3 class="text-sm py-2 flex-auto"><svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg><span class="font-bold">{{ $commit.Author }}</span> revised this gist <span class="moment-timestamp font-bold">{{ $commit.Timestamp }}</span>. <a class="underline" href="/{{ $.gist.User.Username }}/{{ $.gist.Uuid }}/rev/{{ $commit.Hash }}">Go to revision</a></h3>
|
||||
{{ if ne $commit.Changed "" }}
|
||||
<p class="text-sm float-right py-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline-flex">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
{{ $commit.Changed }}
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-y-4">
|
||||
{{ if ne (len $commit.Files) 0 }}
|
||||
{{ range $file := $commit.Files }}
|
||||
<div class="rounded-md border border-1 border-gray-700 overflow-auto">
|
||||
<div class="border-b-1 border-gray-700 bg-gray-800 my-auto">
|
||||
<p class="ml-4 mt-2 inline-flex">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 flex text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
{{ if eq $file.Filename $file.OldFilename }}
|
||||
<span class="flex text-sm ml-2 text-slate-300">{{ $file.Filename }}</span>
|
||||
{{ else }}
|
||||
{{ if eq $file.OldFilename "/dev/null" }}
|
||||
<span class="flex text-sm ml-2 text-slate-300">{{ $file.Filename }}<span class="italic text-gray-400 ml-1">(file created)</span></span>
|
||||
{{ else if eq $file.Filename "/dev/null" }}
|
||||
<span class="flex text-sm ml-2 text-slate-300">{{ $file.OldFilename }} <span class="italic text-gray-400 ml-1">(file deleted)</span></span>
|
||||
{{ else }}
|
||||
<span class="flex text-sm ml-2 text-slate-300">{{ $file.OldFilename }} <span class="italic text-gray-400 mx-1">renamed to</span> {{ $file.Filename }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="code overflow-auto">
|
||||
{{ if eq $file.Content "" }}
|
||||
<p class="m-2 ml-4 text-sm">
|
||||
File renamed without changes.
|
||||
</p>
|
||||
{{ else }}
|
||||
<table class="table-code w-full whitespace-pre" data-filename="{{ $file.Filename }}" style="font-size: 0.8em; border-spacing: 0">
|
||||
<tbody>
|
||||
{{ $left := 0 }}
|
||||
{{ $right := 0 }}
|
||||
{{ range $line := split $file.Content "\n" }}
|
||||
{{ if ne $line "" }}{{ if ne (index $line 0) 92 }}
|
||||
|
||||
{{ if eq (index $line 0) 64 }}
|
||||
{{ $left = toInt (index (splitGit (index (split $line "-") 1)) 0) }}
|
||||
{{ $right = toInt (index (splitGit (index (split $line "+") 1)) 0) }}
|
||||
{{ end }}
|
||||
<tr class="{{ if eq (index $line 0) 64 }}gray-diff{{ end }}{{ if eq (index $line 0) 43 }}green-diff{{ end }}{{ if eq (index $line 0) 45 }}red-diff{{ end }}" >
|
||||
{{ if eq (index $line 0) 64 }}
|
||||
<td colspan="2" class="select-none py-3"></td>
|
||||
{{ else }}
|
||||
{{ if eq (index $line 0) 43 }}
|
||||
<td class="select-none line-num px-2"></td>
|
||||
<td class="select-none line-num px-2">{{ $right }}</td>
|
||||
{{ $right = inc $right }}
|
||||
{{ else if eq (index $line 0) 45 }}
|
||||
<td class="select-none line-num px-2">{{ $left }}</td>
|
||||
<td class="select-none line-num px-2"></td>
|
||||
{{ $left = inc $left }}
|
||||
{{ else if eq (index $line 0) 32 }}
|
||||
<td class="select-none line-num px-2">{{ $left }}</td>
|
||||
<td class="select-none line-num px-2">{{ $right }}</td>
|
||||
{{ $left = inc $left }}
|
||||
{{ $right = inc $right }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<td class="select-none" style="width: 2%;">{{ if ne (index $line 0) 64 }}{{ slice $line 0 1 }}{{ end }}</td>
|
||||
<td>{{ if ne (index $line 0) 64 }}{{ slice $line 1 }}{{ else }}{{ $line }}{{ end }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-left text-sm text-slate-300 italic">No changes</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
{{ template "pagination" . }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-slate-300">No revisions to show</h3>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ template "gist_footer" .}}
|
||||
{{ template "footer" .}}
|
73
templates/pages/ssh_keys.html
vendored
Normal file
73
templates/pages/ssh_keys.html
vendored
Normal file
|
@ -0,0 +1,73 @@
|
|||
{{ template "header" .}}
|
||||
<div class="py-10">
|
||||
<header class="pb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold leading-tight">SSH Keys</h1>
|
||||
<p class="text-sm text-gray-400 italic">Used only to pull/push gists using Git via SSH</p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div >
|
||||
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-16">
|
||||
<div class="w-full">
|
||||
<div class="bg-gray-900 rounded-md border border-1 border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<h2 class="text-md font-bold text-slate-300 mb-4">
|
||||
Add SSH Key
|
||||
</h2>
|
||||
<form class="space-y-6" action="#" method="post">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-slate-300"> Title </label>
|
||||
<div class="mt-1">
|
||||
<input id="title" name="title" type="text" required autocomplete="off" class="bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-700 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<label for="sshkey" class="block text-sm font-medium text-slate-300"> Key </label>
|
||||
<div class="mt-1">
|
||||
<textarea id="sshkey" required autocomplete="off" name="content" class="bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-700 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-700 text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">Add key</button>
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mt-6 flow-root">
|
||||
<ul role="list" class="-my-5 divide-y divide-gray-700 list-none">
|
||||
{{ if .sshKeys }}
|
||||
{{ range $key := .sshKeys }}
|
||||
<li class="py-5">
|
||||
<div class="inline-flex">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mr-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-300">{{ .Title }}</h3>
|
||||
<p class="mt-1 text-xs text-slate-400 line-clamp-2 code" style="overflow-wrap: anywhere">SHA256:{{.SHA}}</p>
|
||||
<p class="text-xs text-gray-500 line-clamp-2">Added <span class="moment-timestamp-date">{{ .CreatedAt }}</span></p>
|
||||
{{ if eq .LastUsedAt 0 }}
|
||||
<p class="text-xs text-gray-500 line-clamp-2">Never used</p>
|
||||
{{ else }}
|
||||
<p class="text-xs text-gray-500 line-clamp-2">Last used <span class="moment-timestamp">{{ .LastUsedAt }}</span></p>
|
||||
{{ end }}
|
||||
</div>
|
||||
<form action="/ssh-keys/{{.ID}}" method="post" class="inline-block" onsubmit="return confirm('Confirm deletion of SSH key')">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
{{ $.csrfHtml }}
|
||||
|
||||
<button type="submit" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-700 text-xs font-medium rounded-md shadow-sm text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{ template "footer" .}}
|
16
vite.config.js
Normal file
16
vite.config.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: './public',
|
||||
|
||||
build: {
|
||||
// generate manifest.json in outDir
|
||||
outDir: '',
|
||||
assetsDir: 'assets',
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
// overwrite default .html entry
|
||||
input: ['./public/main.js', './public/editor.js']
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Reference in a new issue