Compare commits
139 Commits
497ec62990
...
main
Author | SHA1 | Date | |
---|---|---|---|
8723ce88dc | |||
92548e6bad
|
|||
93634f230f
|
|||
e0de885cdd
|
|||
ccccb9d238
|
|||
71f795bae5
|
|||
973274fed3
|
|||
502f1c59de
|
|||
0c820183a6
|
|||
cfb676a453
|
|||
2bf5f4faa3
|
|||
3b7a7db0ca
|
|||
79d84394c0
|
|||
e45fed69bb
|
|||
51eadc20ec
|
|||
303e032673
|
|||
22526c953e
|
|||
bd1ba6c087
|
|||
1e23959e52
|
|||
68d109f428
|
|||
b56ab2522c
|
|||
24d6d7cebf
|
|||
0020902737
|
|||
d227932878
|
|||
db8d32113c
|
|||
f61b618f1e
|
|||
615cd36eab
|
|||
8a00500387
|
|||
72709226c0
|
|||
eb9cadd36d
|
|||
46d125fa18
|
|||
9e786893b3
|
|||
1a37ccfd86
|
|||
3e9f771ad3
|
|||
bf2bcc4a7f
|
|||
dacc5a8d7b
|
|||
bda68ed7f4
|
|||
cf66336e78
|
|||
8e646666d1
|
|||
aa49d8e4b9
|
|||
1e5e2a2c27
|
|||
1a96612544
|
|||
8ea9afd39d
|
|||
873a4c0c15
|
|||
90cacad449
|
|||
d1e29822ac
|
|||
8cd4695794
|
|||
c79cc5797a
|
|||
d44c1156b7
|
|||
1087e0d511
|
|||
e46883c3c1
|
|||
ea83a31b16
|
|||
94f58fef73
|
|||
6eee661b58
|
|||
07a65e9633
|
|||
a2d3672fa8
|
|||
1e9809e4b2 | |||
9f6541c90c | |||
c426c8aa2a
|
|||
a4a79d964e
|
|||
025b3063a6
|
|||
5e7dec08b9
|
|||
95e4384f22
|
|||
82fb724770
|
|||
ca0256268b
|
|||
8a9a5e5bd9
|
|||
ccb2819b01
|
|||
fbe582ccbc
|
|||
22f97dcc82
|
|||
2773ba5243
|
|||
2a22f6d2ce
|
|||
ed34f394ce
|
|||
11dbec0793
|
|||
69bfaa8db0
|
|||
66318698e5
|
|||
ec3f144b4e
|
|||
e7260090ac
|
|||
738b4163a8
|
|||
3dde2ba49a
|
|||
12269dd9b3
|
|||
800cd6a1bf
|
|||
f3aaa6d24d
|
|||
f071919fa8
|
|||
d70b27cda0
|
|||
1038e8ea1e
|
|||
17e231ed74
|
|||
7f17d4c29e
|
|||
4fa80aa8c7
|
|||
2ccacf12a3
|
|||
0d7ed52679
|
|||
af20b626d5
|
|||
ddad153875
|
|||
74a0ae5027
|
|||
d4ab245297
|
|||
a28572003e
|
|||
511687c8c3
|
|||
7d761bae2e
|
|||
7f10dde1ea
|
|||
9438d3704b
|
|||
16127983ab
|
|||
1cb9262ad7
|
|||
9b42d05174
|
|||
fd261ec8c0
|
|||
f8da57224f
|
|||
24c210e395
|
|||
f18e31811c
|
|||
f5ba312032
|
|||
8e7b167bc2
|
|||
96922fdd76
|
|||
ecf89dba19
|
|||
2eddb70d63
|
|||
3bd474d7fe
|
|||
82b25946a0
|
|||
a1055b0c43
|
|||
7cc16047cb
|
|||
8c7ef09567
|
|||
f1f218fc75
|
|||
8609c33f00
|
|||
9b689a08e2
|
|||
c473d2b1a0
|
|||
00c56f1417
|
|||
a5a7175365
|
|||
349f4d38ef
|
|||
70a780909a
|
|||
6181701da6
|
|||
85b1319c79
|
|||
5ec458702a
|
|||
4cb390348c
|
|||
15a3a62dec
|
|||
94c735b913
|
|||
785eafd646
|
|||
4039d6d299
|
|||
f5485702a8
|
|||
86b568d0f4
|
|||
836ad72521
|
|||
9c327957d9
|
|||
ac51e5c0e8
|
|||
03a20128f7
|
|||
91d4fa59f3
|
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
logs/
|
||||||
|
nginx.conf.compiled
|
||||||
|
.vscode/
|
||||||
|
.local/
|
||||||
|
data/db/*
|
||||||
|
secrets
|
||||||
|
secrets/.touched*
|
||||||
|
sass
|
7
.gitignore
vendored
@ -1,2 +1,9 @@
|
|||||||
logs/
|
logs/
|
||||||
nginx.conf.compiled
|
nginx.conf.compiled
|
||||||
|
.vscode/
|
||||||
|
.local/
|
||||||
|
data/db/*
|
||||||
|
secrets/secrets.lua
|
||||||
|
secrets/.touched*
|
||||||
|
data/static/avatars/*
|
||||||
|
!data/static/avatars/default.webp
|
||||||
|
20
Dockerfile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# HOW TO:
|
||||||
|
#
|
||||||
|
# docker compose up
|
||||||
|
#
|
||||||
|
# it exposes the data/ and secrets/ volumes in app root
|
||||||
|
#
|
||||||
|
FROM openresty/openresty:1.25.3.2-5-alpine-fat
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make gcc g++ musl-dev libffi-dev openssl-dev sqlite-dev libsodium libsodium-dev imagemagick-dev openssl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN eval "$(luarocks --lua-version=5.1 path)"
|
||||||
|
# if using openresty images, make sure the image version is >= 1.25.3.2-5 or >= 1.27.1.2-2
|
||||||
|
# see https://github.com/openresty/docker-openresty/issues/276#issuecomment-2950726213
|
||||||
|
# otherwise, make sure your image uses luarocks >= 3.12.0
|
||||||
|
# see https://github.com/luarocks/luarocks/issues/1797
|
||||||
|
RUN luarocks --lua-version=5.1 build --only-deps
|
||||||
|
EXPOSE 8080
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
ENTRYPOINT ["/app/start.sh", "production"]
|
483
LICENSE.md
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
|
||||||
|
COOPERATIVE NON-VIOLENT PUBLIC LICENSE (\"LICENSE\"). THE WORK IS
|
||||||
|
PROTECTED BY COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE
|
||||||
|
WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
|
||||||
|
PROHIBITED. BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS
|
||||||
|
LICENSE, YOU AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE
|
||||||
|
EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR
|
||||||
|
GRANTS YOU THE RIGHTS CONTAINED HERE IN AS CONSIDERATION FOR ACCEPTING
|
||||||
|
THE TERMS AND CONDITIONS OF THIS LICENSE AND FOR AGREEING TO BE BOUND BY
|
||||||
|
THE TERMS AND CONDITIONS OF THIS LICENSE.
|
||||||
|
|
||||||
|
# Definitions
|
||||||
|
|
||||||
|
An Act of War is any action of one country against any group either with
|
||||||
|
an intention to provoke a conflict or an action that occurs during a
|
||||||
|
declared war or during armed conflict between military forces of any
|
||||||
|
origin. This includes but is not limited to enforcing sanctions or
|
||||||
|
sieges, supplying armed forces, or profiting from the manufacture of
|
||||||
|
tools or weaponry used in military conflict.
|
||||||
|
|
||||||
|
An Adaptation is a work based upon the Work, or upon the Work and other
|
||||||
|
pre-existing works, such as a translation, adaptation, derivative work,
|
||||||
|
arrangement of music or other alterations of a literary or artistic
|
||||||
|
work, or phonogram or performance and includes cinematographic
|
||||||
|
adaptations or any other form in which the Work may be recast,
|
||||||
|
transformed, or adapted including in any form recognizably derived from
|
||||||
|
the original, except that a work that constitutes a Collection will not
|
||||||
|
be considered an Adaptation for the purpose of this License. For the
|
||||||
|
avoidance of doubt, where the Work is a musical work, performance or
|
||||||
|
phonogram, the synchronization of the Work in timed-relation with a
|
||||||
|
moving image (\"synching\") will be considered an Adaptation for the
|
||||||
|
purpose of this License. In addition, where the Work is designed to
|
||||||
|
output a neural network the output of the neural network will be
|
||||||
|
considered an Adaptation for the purpose of this license.
|
||||||
|
|
||||||
|
Bodily Harm is any physical hurt or injury to a person that interferes
|
||||||
|
with the health or comfort of the person and that is more than merely
|
||||||
|
transient or trifling in nature.
|
||||||
|
|
||||||
|
Distribute is to make available to the public the original and copies of
|
||||||
|
the Work or Adaptation, as appropriate, through sale, gift or any other
|
||||||
|
transfer of possession or ownership.
|
||||||
|
|
||||||
|
Incarceration is Confinement in a jail, prison, or any other place where
|
||||||
|
individuals of any kind are held against either their will or (if their
|
||||||
|
will cannot be determined) the will of their legal guardian or
|
||||||
|
guardians. In the case of a conflict between the will of the individual
|
||||||
|
and the will of their legal guardian or guardians, the will of the
|
||||||
|
individual will take precedence.
|
||||||
|
|
||||||
|
Licensor is The individual, individuals, entity, or entities that
|
||||||
|
offer(s) the Work under the terms of this License
|
||||||
|
|
||||||
|
Original Author is in the case of a literary or artistic work, the
|
||||||
|
individual, individuals, entity or entities who created the Work or if
|
||||||
|
no individual or entity can be identified, the publisher; and in
|
||||||
|
addition
|
||||||
|
|
||||||
|
- in the case of a performance the actors, singers, musicians,
|
||||||
|
dancers, and other persons who act, sing, deliver, declaim, play in,
|
||||||
|
interpret or otherwise perform literary or artistic works or
|
||||||
|
expressions of folklore;
|
||||||
|
|
||||||
|
- in the case of a phonogram the producer being the person or legal
|
||||||
|
entity who first fixes the sounds of a performance or other sounds;
|
||||||
|
and,
|
||||||
|
|
||||||
|
- in the case of broadcasts, the organization that transmits the
|
||||||
|
broadcast.
|
||||||
|
|
||||||
|
Work is the literary and/or artistic work offered under the terms of
|
||||||
|
this License including without limitation any production in the
|
||||||
|
literary, scientific and artistic domain, whatever may be the mode or
|
||||||
|
form of its expression including digital form, such as a book, pamphlet
|
||||||
|
and other writing; a lecture, address, sermon or other work of the same
|
||||||
|
nature; a dramatic or dramatico-musical work; a choreographic work or
|
||||||
|
entertainment in dumb show; a musical composition with or without words;
|
||||||
|
a cinematographic work to which are assimilated works expressed by a
|
||||||
|
process analogous to cinematography; a work of drawing, painting,
|
||||||
|
architecture, sculpture, engraving or lithography; a photographic work
|
||||||
|
to which are assimilated works expressed by a process analogous to
|
||||||
|
photography; a work of applied art; an illustration, map, plan, sketch
|
||||||
|
or three-dimensional work relative to geography, topography,
|
||||||
|
architecture or science; a performance; a broadcast; a phonogram; a
|
||||||
|
compilation of data to the extent it is protected as a copyrightable
|
||||||
|
work; or a work performed by a variety or circus performer to the extent
|
||||||
|
it is not otherwise considered a literary or artistic work.
|
||||||
|
|
||||||
|
You means an individual or entity exercising rights under this License
|
||||||
|
who has not previously violated the terms of this License with respect
|
||||||
|
to the Work, or who has received express permission from the Licensor to
|
||||||
|
exercise rights under this License despite a previous violation.
|
||||||
|
|
||||||
|
Publicly Perform means to perform public recitations of the Work and to
|
||||||
|
communicate to the public those public recitations, by any means or
|
||||||
|
process, including by wire or wireless means or public digital
|
||||||
|
performances; to make available to the public Works in such a way that
|
||||||
|
members of the public may access these Works from a place and at a place
|
||||||
|
individually chosen by them; to perform the Work to the public by any
|
||||||
|
means or process and the communication to the public of the performances
|
||||||
|
of the Work, including by public digital performance; to broadcast and
|
||||||
|
rebroadcast the Work by any means including signs, sounds or images.
|
||||||
|
|
||||||
|
Reproduce is to make copies of the Work by any means including without
|
||||||
|
limitation by sound or visual recordings and the right of fixation and
|
||||||
|
reproducing fixations of the Work, including storage of a protected
|
||||||
|
performance or phonogram in digital form or other electronic medium.
|
||||||
|
|
||||||
|
Software is any digital Work which, through use of a third-party piece
|
||||||
|
of Software or through the direct usage of itself on a computer system,
|
||||||
|
the memory of the computer is modified dynamically or semi-dynamically.
|
||||||
|
\"Software\", secondly, processes or interprets information.
|
||||||
|
|
||||||
|
Source Code is Any digital Work which, through use of a third-party
|
||||||
|
piece of Software or through the direct usage of itself on a computer
|
||||||
|
system, the memory of the computer is modified dynamically or
|
||||||
|
semi-dynamically. \"Software\", secondly, processes or interprets
|
||||||
|
information.
|
||||||
|
|
||||||
|
Surveilling is the use of the Work to either overtly or covertly observe
|
||||||
|
and record persons and or their activities.
|
||||||
|
|
||||||
|
A Network Service is the use of a piece of Software to interpret or
|
||||||
|
modify information that is subsequently and directly served to users
|
||||||
|
over the Internet.
|
||||||
|
|
||||||
|
To Discriminate is use of a work to differentiate between humans in a
|
||||||
|
such a way which prioritizes some above others on the basis of percieved
|
||||||
|
membership within certain groups.
|
||||||
|
|
||||||
|
Hate Speech is Communication or any form of expression which is solely
|
||||||
|
for the purpose of expressing hatred for some group or advocating a form
|
||||||
|
of Discrimination between humans.
|
||||||
|
|
||||||
|
Coercion is leveraging of the threat of force or use of force to
|
||||||
|
intimidate a person in order to gain compliance, or to offer large
|
||||||
|
incentives which aim to entice a person to act against their will.
|
||||||
|
|
||||||
|
# Fair Dealing Rights
|
||||||
|
|
||||||
|
Nothing in this License is intended to reduce, limit, or restrict any
|
||||||
|
uses free from copyright or rights arising from limitations or
|
||||||
|
exceptions that are provided for in connection with the copyright
|
||||||
|
protection under copyright law or other applicable laws.
|
||||||
|
|
||||||
|
# License Grant
|
||||||
|
|
||||||
|
Subject to the terms and conditions of this License, Licensor hereby
|
||||||
|
grants You a worldwide, royalty-free, non-exclusive, perpetual (for the
|
||||||
|
duration of the applicable copyright) license to exercise the rights in
|
||||||
|
the Work as stated below:
|
||||||
|
|
||||||
|
To Reproduce the Work, to incorporate the Work into one or more
|
||||||
|
Collections, and to Reproduce the Work as incorporated in the
|
||||||
|
Collections
|
||||||
|
|
||||||
|
To create and Reproduce Adaptations provided that any such Adaptation,
|
||||||
|
including any translation in any medium, takes reasonable steps to
|
||||||
|
clearly label, demarcate or otherwise identify that changes were made to
|
||||||
|
the original Work. For example, a translation could be marked \"The
|
||||||
|
original work was translated from English to Spanish,\" or a
|
||||||
|
modification could indicate \"The original work has been modified.\"
|
||||||
|
|
||||||
|
To Distribute and Publicly Perform the Work including as incorporated in
|
||||||
|
Collections.
|
||||||
|
|
||||||
|
To Distribute and Publicly Perform Adaptations. The above rights may be
|
||||||
|
exercised in all media and formats whether now known or hereafter
|
||||||
|
devised. The above rights include the right to make such modifications
|
||||||
|
as are technically necessary to exercise the rights in other media and
|
||||||
|
formats. This License constitutes the entire agreement between the
|
||||||
|
parties with respect to the Work licensed here. There are no
|
||||||
|
understandings, agreements or representations with respect to the Work
|
||||||
|
not specified here. Licensor shall not be bound by any additional
|
||||||
|
provisions that may appear in any communication from You. This License
|
||||||
|
may not be modified without the mutual written agreement of the Licensor
|
||||||
|
and You. All rights not expressly granted by Licensor are hereby
|
||||||
|
reserved, including but not limited to the rights set forth in
|
||||||
|
Non-waivable Compulsory License Schemes, Waivable Compulsory License
|
||||||
|
Schemes, and Voluntary License Schemes in the restrictions.
|
||||||
|
|
||||||
|
# Restrictions
|
||||||
|
|
||||||
|
The license granted in the license grant above is expressly made subject
|
||||||
|
to and limited by the following restrictions:
|
||||||
|
|
||||||
|
You may Distribute or Publicly Perform the Work only under the terms of
|
||||||
|
this License. You must include a copy of, or the Uniform Resource
|
||||||
|
Identifier (URI) for, this License with every copy of the Work You
|
||||||
|
Distribute or Publicly Perform. You may not offer or impose any terms on
|
||||||
|
the Work that restrict the terms of this License or the ability of the
|
||||||
|
recipient of the Work to exercise the rights granted to that recipient
|
||||||
|
under the terms of the License. You may not sublicense the Work. You
|
||||||
|
must keep intact all notices that refer to this License and to the
|
||||||
|
disclaimer of warranties with every copy of the Work You Distribute or
|
||||||
|
Publicly Perform. When You Distribute or Publicly Perform the Work, You
|
||||||
|
may not impose any effective technological measures on the Work that
|
||||||
|
restrict the ability of a recipient of the Work from You to exercise the
|
||||||
|
rights granted to that recipient under the terms of the License. This
|
||||||
|
Section applies to the Work as incorporated in a Collection, but this
|
||||||
|
does not require the Collection apart from the Work itself to be made
|
||||||
|
subject to the terms of this License. If You create a Collection, upon
|
||||||
|
notice from any Licensor You must, to the extent practicable, remove
|
||||||
|
from the Collection any credit as requested. If You create an
|
||||||
|
Adaptation, upon notice from any Licensor You must, to the extent
|
||||||
|
practicable, remove from the Adaptation any credit as requested.
|
||||||
|
|
||||||
|
## Commercial Restrictions
|
||||||
|
|
||||||
|
You may not exercise any of the rights granted to You in the above
|
||||||
|
section in any manner that is primarily intended for or directed toward
|
||||||
|
commercial advantage or private monetary compensation unless you meet
|
||||||
|
the following requirements.
|
||||||
|
|
||||||
|
i. You are a worker-owned business or worker-owned collective.
|
||||||
|
|
||||||
|
ii. after tax, all financial gain, surplus, profits and benefits
|
||||||
|
produced by the business or collective are distributed among the
|
||||||
|
worker-owners unless a set amount is to be allocated towards
|
||||||
|
community projects as decided by a previously-established consensus
|
||||||
|
agreement between the worker-owners where all worker-owners agreed.
|
||||||
|
|
||||||
|
iii. You are not using such rights on behalf of a business other than
|
||||||
|
those specified in (i) or (ii) above, nor are using such rights as
|
||||||
|
a proxy on behalf of a business with the intent to circumvent the
|
||||||
|
aforementioned restrictions on such a business.
|
||||||
|
|
||||||
|
The exchange of the Work for other copyrighted works by means of digital
|
||||||
|
file-sharing or otherwise shall not be considered to be intended for or
|
||||||
|
directed toward commercial advantage or private monetary compensation,
|
||||||
|
provided there is no payment of any monetary compensation in connection
|
||||||
|
with the exchange of copyrighted works.
|
||||||
|
|
||||||
|
If the Work meets the definition of Software, You may exercise the
|
||||||
|
rights granted in the license grant only if You provide a copy of the
|
||||||
|
corresponding Source Code from which the Work was derived in digital
|
||||||
|
form, or You provide a URI for the corresponding Source Code of the
|
||||||
|
Work, to any recipients upon request.
|
||||||
|
|
||||||
|
If the Work is used as or for a Network Service, You may exercise the
|
||||||
|
rights granted in the license grant only if You provide a copy of the
|
||||||
|
corresponding Source Code from which the Work was derived in digital
|
||||||
|
form, or You provide a URI for the corresponding Source Code to the
|
||||||
|
Work, to any recipients of the data served or modified by the Web
|
||||||
|
Service.
|
||||||
|
|
||||||
|
Any use by a business that is privately owned and managed, and that
|
||||||
|
seeks to generate profit from the labor of employees paid by salary or
|
||||||
|
other wages, is not permitted under this license.
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
You may exercise the rights granted in the license grant for any
|
||||||
|
purposes only if:
|
||||||
|
|
||||||
|
i. You do not use the Work for the purpose of inflicting Bodily Harm on
|
||||||
|
human beings (subject to criminal prosecution or otherwise) outside
|
||||||
|
of providing medical aid or undergoing a voluntary procedure under
|
||||||
|
no form of Coercion.
|
||||||
|
|
||||||
|
ii. You do not use the Work for the purpose of Surveilling or tracking
|
||||||
|
individuals for financial gain.
|
||||||
|
|
||||||
|
iii. You do not use the Work in an Act of War.
|
||||||
|
|
||||||
|
iv. You do not use the Work for the purpose of supporting or profiting
|
||||||
|
from an Act of War.
|
||||||
|
|
||||||
|
v. You do not use the Work for the purpose of Incarceration.
|
||||||
|
|
||||||
|
vi. You do not use the Work for the purpose of extracting, processing,
|
||||||
|
or refining, oil, gas, or coal. Or to in any other way to
|
||||||
|
deliberately pollute the environment as a byproduct of manufacturing
|
||||||
|
or irresponsible disposal of hazardous materials.
|
||||||
|
|
||||||
|
vii. You do not use the Work for the purpose of expediting,
|
||||||
|
coordinating, or facilitating paid work undertaken by individuals
|
||||||
|
under the age of 12 years.
|
||||||
|
|
||||||
|
viii. You do not use the Work to either Discriminate or spread Hate
|
||||||
|
Speech on the basis of sex, sexual orientation, gender identity,
|
||||||
|
race, age, disability, color, national origin, religion, caste, or
|
||||||
|
lower economic status.
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
If You Distribute, or Publicly Perform the Work or any Adaptations or
|
||||||
|
Collections, You must, unless a request has been made by any Licensor to
|
||||||
|
remove credit from a Collection or Adaptation, keep intact all copyright
|
||||||
|
notices for the Work and provide, reasonable to the medium or means You
|
||||||
|
are utilizing:
|
||||||
|
|
||||||
|
i. the name of the Original Author (or pseudonym, if applicable) if
|
||||||
|
supplied, and/or if the Original Author and/or Licensor designate
|
||||||
|
another party or parties (e.g., a sponsor institute, publishing
|
||||||
|
entity, journal) for attribution (\"Attribution Parties\") in
|
||||||
|
Licensor\'s copyright notice, terms of service or by other
|
||||||
|
reasonable means, the name of such party or parties;
|
||||||
|
|
||||||
|
ii. the title of the Work if supplied;
|
||||||
|
|
||||||
|
iii. to the extent reasonably practicable, the URI, if any, that
|
||||||
|
Licensor to be associated with the Work, unless such URI does not
|
||||||
|
refer to the copyright notice or licensing information for the
|
||||||
|
Work; and,
|
||||||
|
|
||||||
|
iv. in the case of an Adaptation, a credit identifying the use of the
|
||||||
|
Work in the Adaptation (e.g., \"French translation of the Work by
|
||||||
|
Original Author,\" or \"Screenplay based on original Work by
|
||||||
|
Original Author\").
|
||||||
|
|
||||||
|
If any Licensor has sent notice to request removing credit, You must, to
|
||||||
|
the extent practicable, remove any credit as requested. The credit
|
||||||
|
required by this Section may be implemented in any reasonable manner;
|
||||||
|
provided, however, that in the case of an Adaptation or Collection, at a
|
||||||
|
minimum such credit will appear, if a credit for all contributing
|
||||||
|
authors of the Adaptation or Collection appears, then as part of these
|
||||||
|
credits and in a manner at least as prominent as the credits for the
|
||||||
|
other contributing authors. For the avoidance of doubt, You may only use
|
||||||
|
the credit required by this Section for the purpose of attribution in
|
||||||
|
the manner set out above and, by exercising Your rights under this
|
||||||
|
License, You may not implicitly or explicitly assert or imply any
|
||||||
|
connection with, sponsorship or endorsement by the Original Author,
|
||||||
|
Licensor and/or Attribution Parties, as appropriate, of You or Your use
|
||||||
|
of the Work, without the separate, express prior written permission of
|
||||||
|
the Original Author, Licensor and/or Attribution Parties.
|
||||||
|
|
||||||
|
Non-waivable Compulsory License Schemes. In those jurisdictions in which
|
||||||
|
the right to collect royalties through any statutory or compulsory
|
||||||
|
licensing scheme cannot be waived, the Licensor reserves the exclusive
|
||||||
|
right to collect such royalties for any exercise by You of the rights
|
||||||
|
granted under this License
|
||||||
|
|
||||||
|
Waivable Compulsory License Schemes. In those jurisdictions in which the
|
||||||
|
right to collect royalties through any statutory or compulsory licensing
|
||||||
|
scheme can be waived, the Licensor reserves the exclusive right to
|
||||||
|
collect such royalties for any exercise by You of the rights granted
|
||||||
|
under this License if Your exercise of such rights is for a purpose or
|
||||||
|
use which is otherwise than noncommercial as permitted under Commercial
|
||||||
|
Restrictions and otherwise waives the right to collect royalties through
|
||||||
|
any statutory or compulsory licensing scheme.
|
||||||
|
|
||||||
|
Voluntary License Schemes. The Licensor reserves the right to collect
|
||||||
|
royalties, whether individually or, in the event that the Licensor is a
|
||||||
|
member of a collecting society that administers voluntary licensing
|
||||||
|
schemes, via that society, from any exercise by You of the rights
|
||||||
|
granted under this License that is for a purpose or use which is
|
||||||
|
otherwise than noncommercial as permitted under the license grant.
|
||||||
|
|
||||||
|
Except as otherwise agreed in writing by the Licensor or as may be
|
||||||
|
otherwise permitted by applicable law, if You Reproduce, Distribute or
|
||||||
|
Publicly Perform the Work either by itself or as part of any Adaptations
|
||||||
|
or Collections, You must not distort, mutilate, modify or take other
|
||||||
|
derogatory action in relation to the Work which would be prejudicial to
|
||||||
|
the Original Author\'s honor or reputation. Licensor agrees that in
|
||||||
|
those jurisdictions (e.g. Japan), in which any exercise of the right
|
||||||
|
granted in the license grant of this License (the right to make
|
||||||
|
Adaptations) would be deemed to be a distortion, mutilation,
|
||||||
|
modification or other derogatory action prejudicial to the Original
|
||||||
|
Author\'s honor and reputation, the Licensor will waive or not assert,
|
||||||
|
as appropriate, this Section, to the fullest extent permitted by the
|
||||||
|
applicable national law, to enable You to reasonably exercise Your right
|
||||||
|
under the license grant of this License (right to make Adaptations) but
|
||||||
|
not otherwise.
|
||||||
|
|
||||||
|
Do not make any legal claim against anyone accusing the Work, with or
|
||||||
|
without changes, alone or with other works, of infringing any patent
|
||||||
|
claim.
|
||||||
|
|
||||||
|
# Representations Warranties and Disclaimer
|
||||||
|
|
||||||
|
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
|
||||||
|
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
|
||||||
|
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
|
||||||
|
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
|
||||||
|
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
|
||||||
|
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE
|
||||||
|
EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
# Limitation on Liability
|
||||||
|
|
||||||
|
EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL
|
||||||
|
LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF
|
||||||
|
THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED
|
||||||
|
OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
# Termination
|
||||||
|
|
||||||
|
This License and the rights granted hereunder will terminate
|
||||||
|
automatically upon any breach by You of the terms of this License.
|
||||||
|
Individuals or entities who have received Adaptations or Collections
|
||||||
|
from You under this License, however, will not have their licenses
|
||||||
|
terminated provided such individuals or entities remain in full
|
||||||
|
compliance with those licenses. The Sections on definitions, fair
|
||||||
|
dealing rights, representations, warranties, and disclaimer, limitation
|
||||||
|
on liability, termination, and revised license versions will survive any
|
||||||
|
termination of this License.
|
||||||
|
|
||||||
|
Subject to the above terms and conditions, the license granted here is
|
||||||
|
perpetual (for the duration of the applicable copyright in the Work).
|
||||||
|
Notwithstanding the above, Licensor reserves the right to release the
|
||||||
|
Work under different license terms or to stop distributing the Work at
|
||||||
|
any time; provided, however that any such election will not serve to
|
||||||
|
withdraw this License (or any other license that has been, or is
|
||||||
|
required to be, granted under the terms of this License), and this
|
||||||
|
License will continue in full force and effect unless terminated as
|
||||||
|
stated above.
|
||||||
|
|
||||||
|
# Revised License Versions
|
||||||
|
|
||||||
|
This License may receive future revisions in the original spirit of the
|
||||||
|
license intended to strengthen This License. Each version of This
|
||||||
|
License has an incrementing version number.
|
||||||
|
|
||||||
|
Unless otherwise specified like in the below subsection The Licensor has
|
||||||
|
only granted this current version of This License for The Work. In this
|
||||||
|
case future revisions do not apply.
|
||||||
|
|
||||||
|
The Licensor may specify that the latest available revision of This
|
||||||
|
License be used for The Work by either explicitly writing so or by
|
||||||
|
suffixing the License URI with a \"+\" symbol.
|
||||||
|
|
||||||
|
The Licensor may specify that The Work is also available under the terms
|
||||||
|
of This License\'s current revision as well as specific future
|
||||||
|
revisions. The Licensor may do this by writing it explicitly or
|
||||||
|
suffixing the License URI with any additional version numbers each
|
||||||
|
separated by a comma.
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
|
||||||
|
Each time You Distribute or Publicly Perform the Work or a Collection,
|
||||||
|
the Licensor offers to the recipient a license to the Work on the same
|
||||||
|
terms and conditions as the license granted to You under this License.
|
||||||
|
|
||||||
|
Each time You Distribute or Publicly Perform an Adaptation, Licensor
|
||||||
|
offers to the recipient a license to the original Work on the same terms
|
||||||
|
and conditions as the license granted to You under this License.
|
||||||
|
|
||||||
|
If the Work is classified as Software, each time You Distribute or
|
||||||
|
Publicly Perform an Adaptation, Licensor offers to the recipient a copy
|
||||||
|
and/or URI of the corresponding Source Code on the same terms and
|
||||||
|
conditions as the license granted to You under this License.
|
||||||
|
|
||||||
|
If the Work is used as a Network Service, each time You Distribute or
|
||||||
|
Publicly Perform an Adaptation, or serve data derived from the Software,
|
||||||
|
the Licensor offers to any recipients of the data a copy and/or URI of
|
||||||
|
the corresponding Source Code on the same terms and conditions as the
|
||||||
|
license granted to You under this License.
|
||||||
|
|
||||||
|
If any provision of this License is invalid or unenforceable under
|
||||||
|
applicable law, it shall not affect the validity or enforceability of
|
||||||
|
the remainder of the terms of this License, and without further action
|
||||||
|
by the parties to this agreement, such provision shall be reformed to
|
||||||
|
the minimum extent necessary to make such provision valid and
|
||||||
|
enforceable.
|
||||||
|
|
||||||
|
No term or provision of this License shall be deemed waived and no
|
||||||
|
breach consented to unless such waiver or consent shall be in writing
|
||||||
|
and signed by the party to be charged with such waiver or consent.
|
||||||
|
|
||||||
|
This License constitutes the entire agreement between the parties with
|
||||||
|
respect to the Work licensed here. There are no understandings,
|
||||||
|
agreements or representations with respect to the Work not specified
|
||||||
|
here. Licensor shall not be bound by any additional provisions that may
|
||||||
|
appear in any communication from You. This License may not be modified
|
||||||
|
without the mutual written agreement of the Licensor and You.
|
||||||
|
|
||||||
|
The rights granted under, and the subject matter referenced, in this
|
||||||
|
License were drafted utilizing the terminology of the Berne Convention
|
||||||
|
for the Protection of Literary and Artistic Works (as amended on
|
||||||
|
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
|
||||||
|
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and
|
||||||
|
the Universal Copyright Convention (as revised on July 24, 1971). These
|
||||||
|
rights and subject matter take effect in the relevant jurisdiction in
|
||||||
|
which the License terms are sought to be enforced according to the
|
||||||
|
corresponding provisions of the implementation of those treaty
|
||||||
|
provisions in the applicable national law. If the standard suite of
|
||||||
|
rights granted under applicable copyright law includes additional rights
|
||||||
|
not granted under this License, such additional rights are deemed to be
|
||||||
|
included in the License; this License is not intended to restrict the
|
||||||
|
license of any rights under applicable law.
|
61
README.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Porom
|
||||||
|
porous forum
|
||||||
|
# Note
|
||||||
|
Development has moved over to [pyrom](https://git.poto.cafe/yagich/pyrom).
|
||||||
|
|
||||||
|
# License
|
||||||
|
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||||
|
Please read the [full terms](./LICENSE.md) for proper wording.
|
||||||
|
|
||||||
|
# installing & first time setup
|
||||||
|
## docker
|
||||||
|
```bash
|
||||||
|
$ docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
- opens port 8080
|
||||||
|
- exposes `data/db` and `data/avatars` as volumes for data backup and persistence
|
||||||
|
- exposes `secrets/` as a volume so that the script won't try to perform first time setup again
|
||||||
|
|
||||||
|
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
|
||||||
|
|
||||||
|
## manual
|
||||||
|
1. install:
|
||||||
|
- OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html)
|
||||||
|
- LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
|
||||||
|
- openssl (-dev)
|
||||||
|
- sqlite (-dev)
|
||||||
|
- libsodium (-dev)
|
||||||
|
- imagemagick (-dev)
|
||||||
|
- [LuaRocks](https://luarocks.org) (either through the guide's instructions or your package manager, whichever is newer)
|
||||||
|
2. add luarocks search dirs to path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in .bashrc (or other shell equivalent)
|
||||||
|
eval "$(luarocks --lua-version 5.1 path)"
|
||||||
|
```
|
||||||
|
3. clone repo
|
||||||
|
4. install the lua dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ luarocks --local --lua-version 5.1 build --only-deps
|
||||||
|
```
|
||||||
|
5. run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ start.sh production # or 'development' or empty string
|
||||||
|
```
|
||||||
|
the script will perform some necessary first time setup (and create a hidden file in the folder to ensure it won't do so again). it will create an administrator account and print the credentials to the console; **this will only happen once**. make sure you save them somewhere. the administrator account is the only one that can promote other users to moderator.
|
||||||
|
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database and shows more debug information.)
|
||||||
|
|
||||||
|
this app is made with the assumption that it is being reverse-proxied. as such, you may want to change the port to something other than the default `8080`. you can do that in [`config.lua`]([./config.lua]).
|
||||||
|
|
||||||
|
after the first time setup is complete, everything is ready to go. put the app behind your reverse proxy and serve it on the web. the app does not run in https by itself, but the reverse proxy can be set up to do that.
|
||||||
|
|
||||||
|
once you are able to navigate to the forum, you can log in as the administrator account. other people may also sign up, but they are not able to post until manually verified by an administrator or a moderator. the administrator can promote regular users to moderator.
|
||||||
|
|
||||||
|
# icons
|
||||||
|
the icons in the `icons/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)
|
||||||
|
|
||||||
|
# credits & acknowledgements
|
||||||
|
see [THIRDPARTY.md](./THIRDPARTY.md)
|
40
THIRDPARTY.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Acknowledgements
|
||||||
|
|
||||||
|
## Lapis
|
||||||
|
|
||||||
|
URL: https://leafo.net/lapis/
|
||||||
|
Copyright: `(c) 2023 Leaf Corcoran`
|
||||||
|
License: MIT
|
||||||
|
Repo: https://github.com/leafo/lapis
|
||||||
|
|
||||||
|
## ChicagoFLF
|
||||||
|
|
||||||
|
Affected files: [`fonts/ChicagoFLF.woff2`](./fonts/ChicagoFLF.woff2)
|
||||||
|
No canonical URL that I could find.
|
||||||
|
Obtained from: https://usemodify.com/fonts/chicago/
|
||||||
|
License: Public Domain
|
||||||
|
Designers: Susan Kare, Robin Casady
|
||||||
|
|
||||||
|
## Cadman
|
||||||
|
|
||||||
|
Affected files: [`fonts/Cadman_Bold.woff2`](./fonts/Cadman_Bold.woff2) [`fonts/Cadman_BoldItalic.woff2`](./fonts/Cadman_BoldItalic.woff2) [`fonts/Cadman_Italic.woff2`](./fonts/Cadman_Italic.woff2) [`fonts/Cadman_Roman.woff2`](./fonts/Cadman_Roman.woff2)
|
||||||
|
URL: https://localfonts.eu/shop/cyrillic-script/serbian/serbian-cyrillic-sans-serif/cadman/
|
||||||
|
Copyright: `© 2017-2020 by Paul James Miller. All rights reserved.`
|
||||||
|
License: SIL Open Font License 1.1
|
||||||
|
Designers: Paul James Miller
|
||||||
|
|
||||||
|
## ICONCINO
|
||||||
|
|
||||||
|
Affected files: [`svg-icons/error.etlua`](./svg-icons/error.etlua) [`svg-icons/image.etlua`](./svg-icons/image.etlua) [`svg-icons/info.etlua`](./svg-icons/info.etlua) [`svg-icons/lock.etlua`](./svg-icons/lock.etlua) [`svg-icons/sticky.etlua`](./svg-icons/sticky.etlua) [`svg-icons/warn.etlua`](./svg-icons/warn.etlua)
|
||||||
|
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||||
|
Copyright: Gabriele Malaspina
|
||||||
|
Designers: Gabriele Malaspina
|
||||||
|
License: CC0 1.0/CC BY 4.0
|
||||||
|
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
|
||||||
|
|
||||||
|
## Forumoji
|
||||||
|
|
||||||
|
Affected files: everything in [`data/static/emoji`](./data/static/emoji)
|
||||||
|
URL: https://gh.vercte.net/forumoji/
|
||||||
|
License: CC0 1.0
|
||||||
|
Designers: lolecksdeehaha; Scratch137; 64lu; stickfiregames; mybearworld (the project has many more contributors, but these are the people whose designs were reproduced here)
|
79
app.lua
@ -1,8 +1,83 @@
|
|||||||
local lapis = require("lapis")
|
local lapis = require("lapis")
|
||||||
|
local date = require("date")
|
||||||
|
local models = require("models")
|
||||||
local app = lapis.Application()
|
local app = lapis.Application()
|
||||||
|
local constants = require("constants")
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local config = require("lapis.config").get()
|
||||||
|
|
||||||
app:get("/", function()
|
local db = require("lapis.db")
|
||||||
return "Welcome to Lapis " .. require("lapis.version")
|
-- sqlite starts without foreign key enforcement
|
||||||
|
db.query("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
app:enable("etlua")
|
||||||
|
app.layout = require "views.base"
|
||||||
|
|
||||||
|
app.cookie_attributes = function (self, name, value)
|
||||||
|
if name == config.session_name then
|
||||||
|
if not self.session.queue_delete then
|
||||||
|
local expires = date(true):adddays(30):fmt("${http}")
|
||||||
|
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
|
||||||
|
else
|
||||||
|
local expires = date(true):addseconds(-30):fmt("${http}")
|
||||||
|
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_constants(req)
|
||||||
|
req.constants = constants
|
||||||
|
math.randomseed(os.time())
|
||||||
|
req.__cachebust = math.random(99999)
|
||||||
|
req.__commit = config.commit
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_methods(req)
|
||||||
|
req.avatar_url = util.get_user_avatar_url
|
||||||
|
req.ntob = function(_, v)
|
||||||
|
return util.ntob(v)
|
||||||
|
end
|
||||||
|
req.PermissionLevelString = constants.PermissionLevelString
|
||||||
|
req.infobox_message = function (_, s)
|
||||||
|
return util.infobox_message(s)
|
||||||
|
end
|
||||||
|
|
||||||
|
req.babycode_to_html = function (_, bb)
|
||||||
|
return babycode.to_html(bb, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
|
req.get_thread_by_id = function(_, id)
|
||||||
|
return models.Threads:find({id = id})
|
||||||
|
end
|
||||||
|
|
||||||
|
req.get_post_url = function(_, id)
|
||||||
|
return util.get_post_url(_, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
util.pop_infobox(req)
|
||||||
|
end
|
||||||
|
|
||||||
|
app:before_filter(inject_constants)
|
||||||
|
app:before_filter(inject_methods)
|
||||||
|
|
||||||
|
app:include("apps.users", {path = "/user"})
|
||||||
|
app:include("apps.topics", {path = "/topics"})
|
||||||
|
app:include("apps.threads", {path = "/threads"})
|
||||||
|
app:include("apps.mod", {path = "/mod"})
|
||||||
|
app:include("apps.post", {path = "/post"})
|
||||||
|
app:include("apps.api", {path = "/api"})
|
||||||
|
|
||||||
|
app:get("/", function(self)
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("babycode_guide", "/babycode", function(self)
|
||||||
|
self.me = util.get_logged_in_user_or_transient(self)
|
||||||
|
self.page_title = "babycode guide"
|
||||||
|
return {render = "babycode"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
48
apps/api.lua
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
local json_params = require("lapis.application").json_params
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
app:post("api_get_thread_updates", "/thread-updates/:thread_id", json_params(function(self)
|
||||||
|
local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id)
|
||||||
|
if #thread == 0 then
|
||||||
|
return {json = {error = "no such thread"}, status = 404}
|
||||||
|
end
|
||||||
|
local target_time = self.params.since
|
||||||
|
if not target_time then
|
||||||
|
return {json = {error = "missing parameter 'since'"}, status = 400}
|
||||||
|
end
|
||||||
|
if not tonumber(target_time) then
|
||||||
|
return {json = {error = "parameter 'since' is not a number"}, status = 400}
|
||||||
|
end
|
||||||
|
local new_posts_query = "SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1"
|
||||||
|
local new_post = db.query(new_posts_query, self.params.thread_id, target_time)
|
||||||
|
if #new_post == 0 then
|
||||||
|
return {json = {status = "none"}, status = 200}
|
||||||
|
end
|
||||||
|
local url = util.get_post_url(self, new_post[1].id)
|
||||||
|
return {json = {status = "new_post", url = url}}
|
||||||
|
end))
|
||||||
|
|
||||||
|
app:post("babycode_preview", "/babycode-preview", json_params(function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {json = {error = "not authorized"}, status = 401}
|
||||||
|
end
|
||||||
|
if not util.rate_limit_allowed(user.id, "babycode_preview", 5) then
|
||||||
|
return {json = {error = "too many requests"}, status = 429}
|
||||||
|
end
|
||||||
|
local markup = self.params.markup
|
||||||
|
if not markup or type(markup) ~= "string" then
|
||||||
|
return {json = {error = "markup field missing or invalid type"}, status = 400}
|
||||||
|
end
|
||||||
|
local rendered = babycode.to_html(markup, html_escape)
|
||||||
|
return {json = {html = rendered}}
|
||||||
|
end))
|
||||||
|
|
||||||
|
return app
|
46
apps/mod.lua
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Users = models.Users
|
||||||
|
|
||||||
|
-- everything here requires a logged in moderator
|
||||||
|
app:before_filter(function(self)
|
||||||
|
self.me = util.get_logged_in_user(self)
|
||||||
|
if not self.me then
|
||||||
|
self:write{redirect_to = self:url_for("all_topics")}
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not self.me:is_mod() then
|
||||||
|
self:write{redirect_to = self:url_for("all_topics")}
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_list", "/list", function(self)
|
||||||
|
self.users = Users:select("")
|
||||||
|
|
||||||
|
return {render = "mod.user-list"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("sort_topics", "/sort-topics", function(self)
|
||||||
|
self.topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
|
||||||
|
self.page_title = "sorting topics"
|
||||||
|
return {render = "mod.sort-topics"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("sort_topics", "/sort-topics", function(self)
|
||||||
|
local updates = self.params
|
||||||
|
db.query("BEGIN")
|
||||||
|
for topic_id, new_order in pairs(updates) do
|
||||||
|
db.update("topics", {sort_order = new_order}, {id = topic_id})
|
||||||
|
end
|
||||||
|
db.query("COMMIT")
|
||||||
|
return {redirect_to = self:url_for("sort_topics")}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
104
apps/post.lua
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Posts = models.Posts
|
||||||
|
local Threads = models.Threads
|
||||||
|
local PostHistory = models.PostHistory
|
||||||
|
|
||||||
|
app:get("single_post", "/:post_id", function(self)
|
||||||
|
local query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
|
||||||
|
local p = db.query(query, self.params.post_id)
|
||||||
|
if p then
|
||||||
|
self.post = p[1]
|
||||||
|
self.thread = Threads:find({id = self.post.thread_id})
|
||||||
|
self.page_title = self.post.username .. "'s post in " .. self.thread.title
|
||||||
|
end
|
||||||
|
|
||||||
|
return {render = "post.single-post"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("delete_post", "/:post_id/delete", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
print("id is " .. self.params.post_id)
|
||||||
|
local post = Posts:find({id = self.params.post_id})
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
if user:is_mod() then
|
||||||
|
post:delete()
|
||||||
|
util.inject_infobox(self, "Post deleted.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if post.user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
post:delete()
|
||||||
|
util.inject_infobox(self, "Post deleted.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("edit_post", "/:post_id/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local editing_query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
|
||||||
|
local p = db.query(editing_query, self.params.post_id)
|
||||||
|
if not p then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
if p[1].user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
self.me = user
|
||||||
|
self.editing_post = p[1]
|
||||||
|
self.thread = Threads:find({id = self.editing_post.thread_id})
|
||||||
|
|
||||||
|
local thread_predicate = constants.FULL_POSTS_QUERY .. "WHERE posts.thread_id = ?\n"
|
||||||
|
|
||||||
|
local context_prev_query = thread_predicate .. "AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2"
|
||||||
|
local context_next_query = thread_predicate .. "AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2"
|
||||||
|
|
||||||
|
self.prev_context = db.query(context_prev_query, self.thread.id, self.editing_post.created_at)
|
||||||
|
self.next_context = db.query(context_next_query, self.thread.id, self.editing_post.created_at)
|
||||||
|
|
||||||
|
self.page_title = "editing a post"
|
||||||
|
|
||||||
|
return {render = "post.edit-post"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("edit_post", "/:post_id/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
local post = Posts:find({id = self.params.post_id})
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if post.user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
util.update_post(post, self.params.new_content)
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
local link = self:url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id
|
||||||
|
return {redirect_to = link}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
275
apps/threads.lua
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
local lapis_util = require("lapis.util")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Topics = models.Topics
|
||||||
|
local Threads = models.Threads
|
||||||
|
local Posts = models.Posts
|
||||||
|
local Subscriptions = models.Subscriptions
|
||||||
|
|
||||||
|
local POSTS_PER_PAGE = 10
|
||||||
|
|
||||||
|
app:get("thread_create", "/create", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local all_topics = db.query("select * from topics limit 25;")
|
||||||
|
if #all_topics == 0 then
|
||||||
|
return "how did you get here?"
|
||||||
|
end
|
||||||
|
self.all_topics = all_topics
|
||||||
|
self.page_title = "drafting a thread"
|
||||||
|
self.me = user
|
||||||
|
return {render = "threads.create"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_create", "/create", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local topic = Topics:find(self.params.topic_id)
|
||||||
|
if not topic then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
if util.is_topic_locked(topic) and not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
local title = lapis_util.trim(self.params.title)
|
||||||
|
local time = os.time()
|
||||||
|
local slug = lapis_util.slugify(title) .. "-" .. time
|
||||||
|
|
||||||
|
local post_content = self.params.initial_post
|
||||||
|
|
||||||
|
local thread = Threads:create({
|
||||||
|
topic_id = topic.id,
|
||||||
|
user_id = user.id,
|
||||||
|
title = title,
|
||||||
|
slug = slug,
|
||||||
|
created_at = time,
|
||||||
|
})
|
||||||
|
|
||||||
|
local post = util.create_post(thread.id, user.id, post_content)
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("thread", "/:slug", function(self)
|
||||||
|
local thread = Threads:find({
|
||||||
|
slug = self.params.slug
|
||||||
|
})
|
||||||
|
if not thread then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
self.thread = thread
|
||||||
|
|
||||||
|
local post_count = Posts:count(db.clause({
|
||||||
|
thread_id = thread.id
|
||||||
|
}))
|
||||||
|
self.pages = math.max(math.ceil(post_count / POSTS_PER_PAGE), 1)
|
||||||
|
|
||||||
|
if self.params.after then
|
||||||
|
local after_id = tonumber(self.params.after)
|
||||||
|
local post_position = Posts:count(db.clause({
|
||||||
|
thread_id = thread.id,
|
||||||
|
{"id <= ?", after_id},
|
||||||
|
}))
|
||||||
|
self.page = math.floor((post_position - 1) / POSTS_PER_PAGE) + 1
|
||||||
|
else
|
||||||
|
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- self.page = math.max(1, math.min(self.page, self.pages))
|
||||||
|
local query = (constants.FULL_POSTS_QUERY ..
|
||||||
|
"WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?")
|
||||||
|
local posts = db.query(query, thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
|
||||||
|
self.topic = Topics:find(thread.topic_id)
|
||||||
|
self.other_topics = db.query("SELECT topics.id, topics.name FROM topics")
|
||||||
|
self.me = util.get_logged_in_user_or_transient(self)
|
||||||
|
self.posts = posts
|
||||||
|
|
||||||
|
if self.me:is_logged_in() then
|
||||||
|
self.is_subscribed = false
|
||||||
|
local subscription = Subscriptions:find({user_id = self.me.id, thread_id = thread.id})
|
||||||
|
if subscription then
|
||||||
|
self.is_subscribed = true
|
||||||
|
if posts[#posts].created_at > subscription.last_seen then
|
||||||
|
subscription:update({last_seen = os.time()})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.page_title = thread.title
|
||||||
|
|
||||||
|
return {render = "threads.thread"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread", "/:slug", function(self)
|
||||||
|
local thread = Threads:find({
|
||||||
|
slug = self.params.slug
|
||||||
|
})
|
||||||
|
if not thread then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if user:is_guest() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if util.is_thread_locked(thread) and not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local post_content = self.params.post_content
|
||||||
|
local post = util.create_post(thread.id, user.id, post_content)
|
||||||
|
local post_count = Posts:count(db.clause({
|
||||||
|
thread_id = thread.id
|
||||||
|
}))
|
||||||
|
local last_page = math.ceil(post_count / POSTS_PER_PAGE)
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
|
||||||
|
if subscription then
|
||||||
|
subscription:update({last_seen = os.time()})
|
||||||
|
end
|
||||||
|
if self.params.subscribe == "on" and not subscription then
|
||||||
|
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = os.time()})
|
||||||
|
end
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_lock", "/:slug/lock", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
if not ((thread.user_id == user.id) or user:is_mod()) then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local target_op = util.form_bool_to_sqlite(self.params.target_op)
|
||||||
|
thread:update({
|
||||||
|
is_locked = target_op,
|
||||||
|
})
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_sticky", "/:slug/sticky", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
local target_op = util.form_bool_to_sqlite(self.params.target_op)
|
||||||
|
thread:update({
|
||||||
|
is_stickied = target_op,
|
||||||
|
})
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_move", "/:slug/move", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not self.params.new_topic_id then
|
||||||
|
util.inject_err_infobox(self, "Thread already in this topic.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_topic = Topics:find({id = self.params.new_topic_id})
|
||||||
|
if not new_topic then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
if not thread then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
if new_topic.id == thread.topic_id then
|
||||||
|
util.inject_err_infobox(self, "Thread already in this topic.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local old_topic = Topics:find({id = thread.topic_id})
|
||||||
|
|
||||||
|
thread:update({topic_id = new_topic.id})
|
||||||
|
util.inject_infobox(self, ("Thread moved from \"%s\" to \"%s\"."):format(old_topic.name, new_topic.name))
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_subscribe", "/:slug/subscribe", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
if not thread then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
|
||||||
|
if self.params.subscribe == "subscribe" then
|
||||||
|
local now = os.time()
|
||||||
|
if subscription then
|
||||||
|
subscription:delete()
|
||||||
|
end
|
||||||
|
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = now})
|
||||||
|
if self.params.last_visible_post then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||||
|
else
|
||||||
|
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||||
|
end
|
||||||
|
elseif self.params.subscribe == "unsubscribe" then
|
||||||
|
if not subscription then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
subscription:delete()
|
||||||
|
if self.params.last_visible_post then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||||
|
else
|
||||||
|
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||||
|
end
|
||||||
|
elseif self.params.subscribe == "read" then
|
||||||
|
if not subscription then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
subscription:update({last_seen = os.time()})
|
||||||
|
if self.params.last_visible_post then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||||
|
else
|
||||||
|
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {status = 400}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
255
apps/topics.lua
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
local lapis_util = require("lapis.util")
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Users = models.Users
|
||||||
|
local Avatars = models.Avatars
|
||||||
|
local Topics = models.Topics
|
||||||
|
local Threads = models.Threads
|
||||||
|
|
||||||
|
local THREADS_PER_PAGE = 10
|
||||||
|
|
||||||
|
local ThreadCreateError = {
|
||||||
|
OK = 0,
|
||||||
|
GUEST = 1,
|
||||||
|
LOGGED_OUT = 2,
|
||||||
|
TOPIC_LOCKED = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
app:get("all_topics", "", function(self)
|
||||||
|
self.topic_list = db.query([[
|
||||||
|
SELECT
|
||||||
|
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||||
|
users.username AS latest_thread_username,
|
||||||
|
threads.title AS latest_thread_title,
|
||||||
|
threads.slug AS latest_thread_slug,
|
||||||
|
threads.created_at AS latest_thread_created_at
|
||||||
|
FROM
|
||||||
|
topics
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
row_number() OVER (PARTITION BY threads.topic_id ORDER BY threads.created_at DESC) as rn
|
||||||
|
FROM
|
||||||
|
threads
|
||||||
|
) threads ON threads.topic_id = topics.id AND threads.rn = 1
|
||||||
|
LEFT JOIN
|
||||||
|
users on users.id = threads.user_id
|
||||||
|
ORDER BY
|
||||||
|
topics.sort_order ASC
|
||||||
|
]])
|
||||||
|
|
||||||
|
local active_threads_raw = db.query([[
|
||||||
|
WITH ranked_threads AS (
|
||||||
|
SELECT
|
||||||
|
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
|
||||||
|
posts.id AS post_id, posts.created_at AS post_created_at,
|
||||||
|
users.username,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
|
||||||
|
FROM
|
||||||
|
threads
|
||||||
|
JOIN
|
||||||
|
posts ON threads.id = posts.thread_id
|
||||||
|
LEFT JOIN
|
||||||
|
users ON posts.user_id = users.id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
topic_id,
|
||||||
|
thread_id, thread_title, thread_slug,
|
||||||
|
post_id, post_created_at,
|
||||||
|
username
|
||||||
|
FROM
|
||||||
|
ranked_threads
|
||||||
|
WHERE
|
||||||
|
rn = 1
|
||||||
|
ORDER BY
|
||||||
|
topic_id
|
||||||
|
]])
|
||||||
|
self.active_threads = {}
|
||||||
|
for _, thread in ipairs(active_threads_raw) do
|
||||||
|
self.active_threads[tonumber(thread.topic_id)] = {
|
||||||
|
thread_title = thread.thread_title,
|
||||||
|
thread_slug = thread.thread_slug,
|
||||||
|
post_id = thread.post_id,
|
||||||
|
username = thread.username,
|
||||||
|
post_created_at = thread.post_created_at,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
self.me = util.get_logged_in_user_or_transient(self)
|
||||||
|
return {render = "topics.topics"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("topic_create", "/create", function(self)
|
||||||
|
local user = util.get_logged_in_user(self) or util.TransientUser
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
|
||||||
|
self.page_title = "creating a topic"
|
||||||
|
self.me = user
|
||||||
|
|
||||||
|
return {render = "topics.create"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("topic_create", "/create", function(self)
|
||||||
|
local user = util.get_logged_in_user(self) or util.TransientUser
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = "all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local topic_name = lapis_util.trim(self.params.name)
|
||||||
|
local topic_description = self.params.description
|
||||||
|
local time = os.time()
|
||||||
|
local slug = lapis_util.slugify(topic_name) .. "-" .. time
|
||||||
|
|
||||||
|
local topic_count = Topics:count()
|
||||||
|
local topic = Topics:create({
|
||||||
|
name = topic_name,
|
||||||
|
description = topic_description,
|
||||||
|
slug = slug,
|
||||||
|
sort_order = topic_count + 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
util.inject_infobox(self, "Topic created.")
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = topic.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("topic", "/:slug", function(self)
|
||||||
|
local topic = Topics:find({
|
||||||
|
slug = self.params.slug
|
||||||
|
})
|
||||||
|
if not topic then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
local threads_count = Threads:count(db.clause({
|
||||||
|
topic_id = topic.id
|
||||||
|
}))
|
||||||
|
self.topic = topic
|
||||||
|
local sort_by = self.session.sort_by or "activity"
|
||||||
|
local order_clause = ""
|
||||||
|
if sort_by == "thread" then
|
||||||
|
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
|
||||||
|
else
|
||||||
|
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
|
||||||
|
end
|
||||||
|
|
||||||
|
local query = [[
|
||||||
|
SELECT
|
||||||
|
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
||||||
|
users.username AS started_by,
|
||||||
|
u.username AS latest_post_username,
|
||||||
|
ph.content AS latest_post_content,
|
||||||
|
posts.created_at AS latest_post_created_at,
|
||||||
|
posts.id AS latest_post_id
|
||||||
|
FROM
|
||||||
|
threads
|
||||||
|
JOIN users ON users.id = threads.user_id
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
posts.thread_id,
|
||||||
|
posts.id,
|
||||||
|
posts.user_id,
|
||||||
|
posts.created_at,
|
||||||
|
posts.current_revision_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY posts.thread_id ORDER BY posts.created_at DESC) AS rn
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
) posts ON posts.thread_id = threads.id AND posts.rn = 1
|
||||||
|
JOIN
|
||||||
|
post_history ph ON ph.id = posts.current_revision_id
|
||||||
|
JOIN
|
||||||
|
users u ON u.id = posts.user_id
|
||||||
|
WHERE
|
||||||
|
threads.topic_id = ?
|
||||||
|
]] .. order_clause .. " LIMIT ? OFFSET ?"
|
||||||
|
|
||||||
|
self.pages = math.max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
|
||||||
|
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
|
||||||
|
self.threads_list = db.query(query, topic.id, THREADS_PER_PAGE, (self.page - 1) * THREADS_PER_PAGE)
|
||||||
|
|
||||||
|
local user = util.get_logged_in_user_or_transient(self)
|
||||||
|
self.me = user
|
||||||
|
|
||||||
|
self.ThreadCreateError = ThreadCreateError
|
||||||
|
self.thread_create_error = ThreadCreateError.OK
|
||||||
|
if user:is_logged_in_guest() then
|
||||||
|
self.thread_create_error = ThreadCreateError.GUEST
|
||||||
|
elseif user:is_guest() then
|
||||||
|
self.thread_create_error = ThreadCreateError.LOGGED_OUT
|
||||||
|
elseif util.ntob(topic.is_locked) and not user:is_mod() then
|
||||||
|
self.thread_create_error = ThreadCreateError.TOPIC_LOCKED
|
||||||
|
end
|
||||||
|
|
||||||
|
self.page_title = "browsing topic " .. topic.name
|
||||||
|
|
||||||
|
return {render = "topics.topic"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("topic_edit", "/:slug/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user_or_transient(self)
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local topic = Topics:find({
|
||||||
|
slug = self.params.slug
|
||||||
|
})
|
||||||
|
if not topic then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
self.topic = topic
|
||||||
|
self.me = user
|
||||||
|
self.page_title = "editing topic " .. topic.name
|
||||||
|
|
||||||
|
return {render = "topics.edit"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("topic_edit", "/:slug/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user_or_transient(self)
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local topic = Topics:find({
|
||||||
|
slug = self.params.slug
|
||||||
|
})
|
||||||
|
if not topic then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
local name = self.params.name or topic.name
|
||||||
|
local description = self.params.description or topic.description
|
||||||
|
local is_locked = topic.is_locked
|
||||||
|
if self.params.is_locked ~= nil then
|
||||||
|
is_locked = util.form_bool_to_sqlite(self.params.is_locked)
|
||||||
|
end
|
||||||
|
|
||||||
|
topic:update({
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
is_locked = is_locked,
|
||||||
|
})
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("topic_delete", "/:slug/delete", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local topic = Topics:find({slug = self.params.slug})
|
||||||
|
topic:delete()
|
||||||
|
util.inject_infobox(self, "Topic deleted.")
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
593
apps/users.lua
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local auth = require("lib.auth")
|
||||||
|
local rand = require("openssl.rand")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Users = models.Users
|
||||||
|
local Sessions = models.Sessions
|
||||||
|
local Avatars = models.Avatars
|
||||||
|
local Subscriptions = models.Subscriptions
|
||||||
|
|
||||||
|
local function authenticate_user(user, password)
|
||||||
|
return auth.verify(password, user.password_hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function create_session_key()
|
||||||
|
return rand.bytes(16):gsub(".", function(c) return string.format("%02x", string.byte(c)) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function create_session(user_id)
|
||||||
|
local days = 30
|
||||||
|
local expires_at = os.time() + (days * 24 * 60 * 60)
|
||||||
|
|
||||||
|
return Sessions:create({
|
||||||
|
key = create_session_key(),
|
||||||
|
user_id = user_id,
|
||||||
|
expires_at = expires_at,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validate_password(password)
|
||||||
|
if #password < 10 or password:match("%s") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if #password > 255 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local r = password:match("%u+") and
|
||||||
|
password:match("%l+") and
|
||||||
|
password:match("%d+") and
|
||||||
|
password:match("%p+")
|
||||||
|
return r ~= nil and true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validate_username(username)
|
||||||
|
if #username < 3 or #username > 20 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return username:match("^[%w_-]+$") and true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validate_url(url)
|
||||||
|
return url:match('^https?://.+$') and true
|
||||||
|
end
|
||||||
|
|
||||||
|
app:get("user", "/:username", function(self)
|
||||||
|
local user = Users:find({username = self.params.username})
|
||||||
|
if not user then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
|
||||||
|
local me = util.get_logged_in_user_or_transient(self)
|
||||||
|
self.user = user
|
||||||
|
self.me = me
|
||||||
|
|
||||||
|
self.user_is_me = me.id == user.id
|
||||||
|
|
||||||
|
if user.permission == constants.PermissionLevel.GUEST then
|
||||||
|
if not (self.user_is_me or me:is_mod()) then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.stats = db.query([[
|
||||||
|
SELECT
|
||||||
|
COUNT(posts.id) AS post_count,
|
||||||
|
COUNT(DISTINCT threads.id) AS thread_count,
|
||||||
|
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
|
||||||
|
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN posts ON posts.user_id = users.id
|
||||||
|
LEFT JOIN threads ON threads.user_id = users.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, MAX(created_at) AS created_at
|
||||||
|
FROM threads
|
||||||
|
GROUP BY user_id
|
||||||
|
) latest ON latest.user_id = users.id
|
||||||
|
WHERE users.id = ?
|
||||||
|
]], user.id)[1]
|
||||||
|
|
||||||
|
self.latest_posts = db.query([[
|
||||||
|
SELECT
|
||||||
|
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
JOIN
|
||||||
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
|
JOIN
|
||||||
|
threads ON posts.thread_id = threads.id
|
||||||
|
JOIN
|
||||||
|
topics ON threads.topic_id = topics.id
|
||||||
|
WHERE
|
||||||
|
posts.user_id = ?
|
||||||
|
ORDER BY posts.created_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
]], user.id)
|
||||||
|
|
||||||
|
self.page_title = user.username .. "'s profile"
|
||||||
|
|
||||||
|
return {render = "user.user"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_delete", "/:username/delete", function(self)
|
||||||
|
-- this route explicitly does not handle admins deleting other users
|
||||||
|
-- i might make a separate route for it later, but guesting users is possible
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if me:is_admin() then
|
||||||
|
util.inject_err_infobox("You can not delete the admin account!")
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not authenticate_user(target_user, self.params.password) then
|
||||||
|
util.inject_err_infobox(self, "The password you entered is incorrect.")
|
||||||
|
return {redirect_to = self:url_for("user_delete_confirm", {username = me.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local session = Sessions:find({key = self.session.session_key})
|
||||||
|
session:delete()
|
||||||
|
self.session.queue_delete = true
|
||||||
|
util.transfer_and_delete_user(target_user)
|
||||||
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_delete_confirm", "/:username/delete_confirm", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
-- util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
self.me = target_user
|
||||||
|
self.page_title = "confirm deletion"
|
||||||
|
|
||||||
|
return {render = "user.delete_confirm"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_clear_avatar", "/:username/clear_avatar", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local old_avatar_id = target_user.avatar_id
|
||||||
|
target_user:update({
|
||||||
|
avatar_id = 1,
|
||||||
|
})
|
||||||
|
util.destroy_avatar(old_avatar_id)
|
||||||
|
util.inject_infobox(self, "Avatar cleared.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_set_avatar", "/:username/set_avatar", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local file = self.params.avatar
|
||||||
|
if not file then
|
||||||
|
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local time = os.time()
|
||||||
|
local filename = "u" .. target_user.id .. "d" .. time .. ".webp"
|
||||||
|
local proxied_filename = "/avatars/" .. filename
|
||||||
|
local save_path = "data/static" .. proxied_filename
|
||||||
|
local res = util.validate_and_create_image(file.content, save_path)
|
||||||
|
if not res then
|
||||||
|
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
util.inject_infobox(self, "Avatar updated.")
|
||||||
|
local avatar = Avatars:create({
|
||||||
|
file_path = proxied_filename,
|
||||||
|
uploaded_at = time,
|
||||||
|
})
|
||||||
|
|
||||||
|
target_user:update({
|
||||||
|
avatar_id = avatar.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_change_password", "/:username/new_password", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if not me then
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local password = self.params.new_password
|
||||||
|
local password2 = self.params.new_password2
|
||||||
|
if not validate_password(password) then
|
||||||
|
util.inject_err_infobox(self, "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if password ~= password2 then
|
||||||
|
util.inject_err_infobox(self, "Passwords do not match.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
me:update({
|
||||||
|
password_hash = auth.digest(password)
|
||||||
|
})
|
||||||
|
util.extend_session_cookie(self)
|
||||||
|
util.inject_infobox(self, "Password updated.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_settings", "/:username/settings", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
self.me = target_user
|
||||||
|
self.page_title = "settings"
|
||||||
|
|
||||||
|
return {render = "user.settings"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_settings", "/:username/settings", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
if self.params.topic_sort_by == "activity" or self.params.topic_sort_by == "thread" then
|
||||||
|
self.session.sort_by = self.params.topic_sort_by
|
||||||
|
end
|
||||||
|
local status = self.params.status:sub(1, 100)
|
||||||
|
local original_sig = self.params.signature or ""
|
||||||
|
local rendered_sig = babycode.to_html(original_sig, html_escape)
|
||||||
|
if self.params.subscribe_by_default == "on" then
|
||||||
|
self.session.subscribe_by_default = true
|
||||||
|
else
|
||||||
|
self.session.subscribe_by_default = false
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user:update({
|
||||||
|
status = status,
|
||||||
|
signature_original_markup = original_sig,
|
||||||
|
signature_rendered = rendered_sig,
|
||||||
|
})
|
||||||
|
util.inject_infobox(self, "Settings updated.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_inbox", "/:username/inbox", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if me == nil then
|
||||||
|
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user_inbox", {username = me.username})}
|
||||||
|
end
|
||||||
|
self.me = target_user
|
||||||
|
self.page_title = "inbox"
|
||||||
|
self.new_posts = {}
|
||||||
|
local subscription = Subscriptions:find({user_id = me.id})
|
||||||
|
if subscription then
|
||||||
|
self.all_subscriptions = db.query([[
|
||||||
|
SELECT threads.title AS thread_title, threads.slug AS thread_slug
|
||||||
|
FROM
|
||||||
|
threads
|
||||||
|
JOIN
|
||||||
|
subscriptions ON subscriptions.thread_id = threads.id
|
||||||
|
WHERE
|
||||||
|
subscriptions.user_id = ?
|
||||||
|
]], me.id)
|
||||||
|
local q = [[
|
||||||
|
WITH thread_metadata AS (
|
||||||
|
SELECT
|
||||||
|
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
LEFT JOIN
|
||||||
|
threads ON threads.id = posts.thread_id
|
||||||
|
LEFT JOIN
|
||||||
|
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||||
|
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||||
|
GROUP BY posts.thread_id
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
|
||||||
|
|
||||||
|
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
|
||||||
|
FROM
|
||||||
|
thread_metadata tm
|
||||||
|
JOIN
|
||||||
|
posts ON posts.thread_id = tm.thread_id
|
||||||
|
JOIN
|
||||||
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
|
JOIN
|
||||||
|
users ON posts.user_id = users.id
|
||||||
|
LEFT JOIN
|
||||||
|
threads ON threads.id = posts.thread_id
|
||||||
|
LEFT JOIN
|
||||||
|
avatars ON users.avatar_id = avatars.id
|
||||||
|
LEFT JOIN
|
||||||
|
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||||
|
WHERE
|
||||||
|
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||||
|
ORDER BY
|
||||||
|
tm.newest_post_time DESC, posts.created_at ASC]]
|
||||||
|
local new_posts_raw = db.query(q, me.id, me.id)
|
||||||
|
local threads = {}
|
||||||
|
local current_thread_id = nil
|
||||||
|
local current_thread_group = nil
|
||||||
|
self.total_unreads_count = 0
|
||||||
|
for _, row in ipairs(new_posts_raw) do
|
||||||
|
if row.thread_id ~= current_thread_id then
|
||||||
|
current_thread_group = {
|
||||||
|
thread_id = row.thread_id,
|
||||||
|
thread_title = row.thread_title,
|
||||||
|
unread_count = row.unread_count,
|
||||||
|
thread_slug = row.thread_slug,
|
||||||
|
newest_post_time = row.newest_post_time,
|
||||||
|
posts = {}
|
||||||
|
}
|
||||||
|
self.total_unreads_count = self.total_unreads_count + row.unread_count
|
||||||
|
table.insert(threads, current_thread_group)
|
||||||
|
current_thread_id = row.thread_id
|
||||||
|
end
|
||||||
|
---@diagnostic disable-next-line: need-check-nil
|
||||||
|
table.insert(current_thread_group.posts, {
|
||||||
|
id = row.id,
|
||||||
|
created_at = row.created_at,
|
||||||
|
content = row.content,
|
||||||
|
edited_at = row.edited_at,
|
||||||
|
username = row.username,
|
||||||
|
status = row.status,
|
||||||
|
avatar_path = row.avatar_path,
|
||||||
|
thread_id = row.thread_id,
|
||||||
|
user_id = row.user_id,
|
||||||
|
original_markup = row.original_markup,
|
||||||
|
signature_rendered = row.signature_rendered,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
self.new_posts = threads
|
||||||
|
end
|
||||||
|
return {render = "user.inbox"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_login", "/login", function(self)
|
||||||
|
if self.session.session_key then
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if user ~= nil then
|
||||||
|
return {redirect_to = self:url_for("user", {username = user.username})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.page_title = "log in"
|
||||||
|
|
||||||
|
return {render = "user.login"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_login", "/login", function(self)
|
||||||
|
if self.session.session_key then
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if user ~= nil then
|
||||||
|
return {redirect_to = self:url_for("user", {username = user.username})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local username = self.params.username
|
||||||
|
local password = self.params.password
|
||||||
|
local user = Users:find({username = username})
|
||||||
|
if not user then
|
||||||
|
util.inject_err_infobox(self, "Invalid username or password")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
if user.permission == constants.PermissionLevel.SYSTEM then
|
||||||
|
util.inject_err_infobox(self, "Invalid username or password")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
if not authenticate_user(user, password) then
|
||||||
|
util.inject_err_infobox(self, "Invalid username or password")
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
local session = create_session(user.id)
|
||||||
|
util.inject_infobox(self, "Logged in successfully.")
|
||||||
|
self.session.session_key = session.key
|
||||||
|
return {redirect_to = self:url_for("user", {username = username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_signup", "/signup", function(self)
|
||||||
|
if self.session.session_key then
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if user ~= nil then
|
||||||
|
return {redirect_to = self:url_for("user", {username = user.username})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.page_title = "sign up"
|
||||||
|
|
||||||
|
return {render = "user.signup"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_signup", "/signup", function(self)
|
||||||
|
if self.session.session_key then
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if user ~= nil then
|
||||||
|
return {redirect_to = self:url_for("user", {username = user.username})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local username = self.params.username
|
||||||
|
local password = self.params.password
|
||||||
|
local password2 = self.params.password2
|
||||||
|
local user = Users:find({username = username})
|
||||||
|
if user then
|
||||||
|
util.inject_err_infobox(self, "Username '" .. username .. "' is already taken.")
|
||||||
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not validate_username(username) then
|
||||||
|
util.inject_err_infobox(self, "Username must be 3-20 characters with only upper and lowercase letters, hyphens, and underscores.")
|
||||||
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not validate_password(password) then
|
||||||
|
util.inject_err_infobox(self, "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces.")
|
||||||
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if password ~= password2 then
|
||||||
|
util.inject_err_infobox(self, "Passwords do not match.")
|
||||||
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_user = Users:create({
|
||||||
|
username = username,
|
||||||
|
password_hash = auth.digest(password),
|
||||||
|
permission = constants.PermissionLevel.GUEST,
|
||||||
|
})
|
||||||
|
|
||||||
|
local session = create_session(new_user.id)
|
||||||
|
util.inject_infobox(self, "Siged up successfully.")
|
||||||
|
self.session.session_key = session.key
|
||||||
|
return {redirect_to = self:url_for("user", {username = username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("user_logout", "/logout", function (self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end
|
||||||
|
|
||||||
|
local session = Sessions:find({key = self.session.session_key})
|
||||||
|
session:delete()
|
||||||
|
self.session.queue_delete = true
|
||||||
|
return {redirect_to = self:url_for("user_login")}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("confirm_user", "/confirm_user/:user_id", function (self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
local target_user = Users:find(self.params.user_id)
|
||||||
|
if not target_user then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
if target_user.permission > constants.PermissionLevel.GUEST then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user:update({permission = constants.PermissionLevel.USER, confirmed_on = os.time()})
|
||||||
|
return {redirect_to = self:url_for("user", {username = target_user.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("mod_user", "/mod_user/:user_id", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
if not user:is_admin() then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
local target_user = Users:find(self.params.user_id)
|
||||||
|
if not target_user then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
if target_user:is_mod() then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user:update({permission = constants.PermissionLevel.MODERATOR})
|
||||||
|
return {redirect_to = self:url_for("user", {username = target_user.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("demod_user", "/demod_user/:user_id", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
if not user:is_admin() then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
local target_user = Users:find(self.params.user_id)
|
||||||
|
if not target_user then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
if not target_user:is_mod() then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user:update({permission = constants.PermissionLevel.USER})
|
||||||
|
return {redirect_to = self:url_for("user", {username = target_user.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("guest_user", "/guest_user/:user_id", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {status = 403}
|
||||||
|
end
|
||||||
|
local target_user = Users:find(self.params.user_id)
|
||||||
|
if not target_user then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
if target_user:is_mod() then
|
||||||
|
return {status = 404}
|
||||||
|
end
|
||||||
|
|
||||||
|
target_user:update({permission = constants.PermissionLevel.GUEST})
|
||||||
|
return {redirect_to = self:url_for("user", {username = target_user.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
30
config.lua
@ -1,7 +1,33 @@
|
|||||||
local config = require("lapis.config")
|
local config = require("lapis.config")
|
||||||
|
local secrets = require("secrets.secrets")
|
||||||
|
|
||||||
config("development", {
|
local commit = nil
|
||||||
|
local f = io.open(".git/refs/heads/main", "r")
|
||||||
|
if f then
|
||||||
|
commit = f:read(8)
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
config({"development", "production"}, {
|
||||||
|
port = 8080,
|
||||||
server = "nginx",
|
server = "nginx",
|
||||||
code_cache = "off",
|
code_cache = "off",
|
||||||
num_workers = "1"
|
num_workers = "1",
|
||||||
|
sqlite = {
|
||||||
|
database = "data/db/db.dev.sqlite"
|
||||||
|
},
|
||||||
|
secret = "SUPER SECRET",
|
||||||
|
session_name = "porom_session",
|
||||||
|
commit = commit,
|
||||||
|
})
|
||||||
|
|
||||||
|
config("production", {
|
||||||
|
code_cache = "on",
|
||||||
|
logging = {
|
||||||
|
queries = false,
|
||||||
|
},
|
||||||
|
secret = secrets.key,
|
||||||
|
sqlite = {
|
||||||
|
database = "data/db/db.prod.sqlite"
|
||||||
|
},
|
||||||
|
session_name = "porom_session_s"
|
||||||
})
|
})
|
||||||
|
54
constants.lua
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
local Constants = {}
|
||||||
|
|
||||||
|
Constants.PermissionLevel = {
|
||||||
|
GUEST = 0,
|
||||||
|
USER = 1,
|
||||||
|
MODERATOR = 2,
|
||||||
|
SYSTEM = 3,
|
||||||
|
ADMIN = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
Constants.FULL_POSTS_QUERY = [[
|
||||||
|
SELECT
|
||||||
|
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
JOIN
|
||||||
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
|
JOIN
|
||||||
|
users ON posts.user_id = users.id
|
||||||
|
LEFT JOIN
|
||||||
|
avatars ON users.avatar_id = avatars.id
|
||||||
|
]]
|
||||||
|
|
||||||
|
Constants.PermissionLevelString = {
|
||||||
|
[Constants.PermissionLevel.GUEST] = "Guest",
|
||||||
|
[Constants.PermissionLevel.USER] = "User",
|
||||||
|
[Constants.PermissionLevel.MODERATOR] = "Moderator",
|
||||||
|
[Constants.PermissionLevel.SYSTEM] = "System",
|
||||||
|
[Constants.PermissionLevel.ADMIN] = "Administrator",
|
||||||
|
}
|
||||||
|
|
||||||
|
Constants.InfoboxKind = {
|
||||||
|
INFO = 0,
|
||||||
|
LOCK = 1,
|
||||||
|
WARN = 2,
|
||||||
|
ERROR = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
Constants.InfoboxIcons = {
|
||||||
|
[Constants.InfoboxKind.INFO] = "svg-icons.info",
|
||||||
|
[Constants.InfoboxKind.LOCK] = "svg-icons.lock",
|
||||||
|
[Constants.InfoboxKind.WARN] = "svg-icons.warn",
|
||||||
|
[Constants.InfoboxKind.ERROR] = "svg-icons.error",
|
||||||
|
}
|
||||||
|
Constants.InfoboxHTMLClass = {
|
||||||
|
[Constants.InfoboxKind.INFO] = "",
|
||||||
|
[Constants.InfoboxKind.LOCK] = "warn",
|
||||||
|
[Constants.InfoboxKind.WARN] = "warn",
|
||||||
|
[Constants.InfoboxKind.ERROR] = "critical",
|
||||||
|
}
|
||||||
|
|
||||||
|
Constants.BCRYPT_ROUNDS = 10
|
||||||
|
|
||||||
|
return Constants
|
61
create_default_accounts.lua
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
local auth = require("lib.auth")
|
||||||
|
local models = require("models")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local alphabet = "-_@0123456789abcdefghijklmnopqrstuvwABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
|
||||||
|
local function create_default_avatar()
|
||||||
|
if models.Avatars:count() > 0 then
|
||||||
|
print("default avatar must exist")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
models.Avatars:create({
|
||||||
|
file_path = "/avatars/default.webp",
|
||||||
|
uploaded_at = os.time(),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function create_admin()
|
||||||
|
local username = "admin"
|
||||||
|
local root_count = models.Users:count("username = ?", username)
|
||||||
|
if root_count ~= 0 then
|
||||||
|
print("admin account already exists.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
math.randomseed(os.time())
|
||||||
|
local password = ""
|
||||||
|
for _ = 1, 16 do
|
||||||
|
local randi = math.random(#alphabet)
|
||||||
|
password = password .. alphabet:sub(randi, randi)
|
||||||
|
end
|
||||||
|
|
||||||
|
local hash = auth.digest(password)
|
||||||
|
|
||||||
|
models.Users:create({
|
||||||
|
username = username,
|
||||||
|
password_hash = hash,
|
||||||
|
permission = constants.PermissionLevel.ADMIN,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("Admin account created, use \"admin\" as the login and \"" .. password .. "\" as the password. This will only be shown once.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function create_deleted_user()
|
||||||
|
local username = "DeletedUser"
|
||||||
|
local root_count = models.Users:count("username = ?", username)
|
||||||
|
if root_count ~= 0 then
|
||||||
|
print("deleted user already exists")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
models.Users:create({
|
||||||
|
username = username,
|
||||||
|
password_hash = "",
|
||||||
|
permission = constants.PermissionLevel.SYSTEM,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
create_default_avatar()
|
||||||
|
create_admin()
|
||||||
|
create_deleted_user()
|
BIN
data/static/avatars/default.webp
Normal file
After Width: | Height: | Size: 8.3 KiB |
BIN
data/static/emoji/angry.png
Normal file
After Width: | Height: | Size: 458 B |
BIN
data/static/emoji/frown.png
Normal file
After Width: | Height: | Size: 533 B |
BIN
data/static/emoji/grin.png
Normal file
After Width: | Height: | Size: 535 B |
BIN
data/static/emoji/imp.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
data/static/emoji/impangry.png
Normal file
After Width: | Height: | Size: 534 B |
BIN
data/static/emoji/lobster.png
Normal file
After Width: | Height: | Size: 339 B |
BIN
data/static/emoji/neutral.png
Normal file
After Width: | Height: | Size: 527 B |
BIN
data/static/emoji/pensive.png
Normal file
After Width: | Height: | Size: 489 B |
BIN
data/static/emoji/smile.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
data/static/emoji/smiletear.png
Normal file
After Width: | Height: | Size: 549 B |
BIN
data/static/emoji/sob.png
Normal file
After Width: | Height: | Size: 479 B |
BIN
data/static/emoji/surprised.png
Normal file
After Width: | Height: | Size: 522 B |
BIN
data/static/emoji/think.png
Normal file
After Width: | Height: | Size: 523 B |
BIN
data/static/emoji/tongue.png
Normal file
After Width: | Height: | Size: 551 B |
BIN
data/static/emoji/weary.png
Normal file
After Width: | Height: | Size: 517 B |
BIN
data/static/emoji/wink.png
Normal file
After Width: | Height: | Size: 536 B |
735
data/static/style.css
Normal file
@ -0,0 +1,735 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Roman.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Bold.woff2");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Italic.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_BoldItalic.woff2");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||||
|
cursor: default;
|
||||||
|
color: black;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: "Cadman";
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Cadman";
|
||||||
|
margin: 20px 100px;
|
||||||
|
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topnav {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bottomnav {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||||
|
}
|
||||||
|
|
||||||
|
.darkbg {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title {
|
||||||
|
font-family: "site-title";
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas: "usercard post-content-container";
|
||||||
|
border: 2px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard {
|
||||||
|
grid-area: usercard;
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||||
|
border-right: solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
top: 10px;
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 70px 2.5fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas: "post-info" "post-content";
|
||||||
|
grid-area: post-content-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
grid-area: post-info;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 20px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
grid-area: post-content;
|
||||||
|
padding: 20px;
|
||||||
|
margin-right: 25%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content.wider {
|
||||||
|
margin-right: 12.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-inner {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||||
|
padding: 20px;
|
||||||
|
overflow: scroll;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-dialog, .lightbox-dialog {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid black;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-dialog-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 400px;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-image {
|
||||||
|
max-width: 70vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-container {
|
||||||
|
position: sticky;
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: last baseline;
|
||||||
|
font-family: "Cadman";
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
border-left: 2px solid black;
|
||||||
|
border-right: 2px solid black;
|
||||||
|
border-top: 2px solid black;
|
||||||
|
}
|
||||||
|
.copy-code-container::before {
|
||||||
|
content: "code block";
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||||
|
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-areas: "user-page-usercard user-page-stats";
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-usercard {
|
||||||
|
grid-area: user-page-usercard;
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||||
|
border-right: solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-stats {
|
||||||
|
grid-area: user-page-stats;
|
||||||
|
padding: 20px 30px;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-posts {
|
||||||
|
border-left: solid 1px black;
|
||||||
|
border-right: solid 1px black;
|
||||||
|
border-bottom: solid 1px black;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-post-preview {
|
||||||
|
max-height: 200px;
|
||||||
|
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-link {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input[type=submit], .linkbutton {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
}
|
||||||
|
button:hover, input[type=submit]:hover, .linkbutton:hover {
|
||||||
|
background-color: rgb(192.6, 215.8, 214.6);
|
||||||
|
}
|
||||||
|
button:active, input[type=submit]:active, .linkbutton:active {
|
||||||
|
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||||
|
}
|
||||||
|
button:disabled, input[type=submit]:disabled, .linkbutton:disabled {
|
||||||
|
background-color: rgb(209.535, 211.565, 211.46);
|
||||||
|
}
|
||||||
|
button.critical, input[type=submit].critical, .linkbutton.critical {
|
||||||
|
color: white;
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
button.critical:hover, input[type=submit].critical:hover, .linkbutton.critical:hover {
|
||||||
|
background-color: #ff3333;
|
||||||
|
}
|
||||||
|
button.critical:active, input[type=submit].critical:active, .linkbutton.critical:active {
|
||||||
|
background-color: rgb(149.175, 80.325, 80.325);
|
||||||
|
}
|
||||||
|
button.critical:disabled, input[type=submit].critical:disabled, .linkbutton.critical:disabled {
|
||||||
|
background-color: rgb(174.675, 156.825, 156.825);
|
||||||
|
}
|
||||||
|
button.warn, input[type=submit].warn, .linkbutton.warn {
|
||||||
|
background-color: #fbfb8d;
|
||||||
|
}
|
||||||
|
button.warn:hover, input[type=submit].warn:hover, .linkbutton.warn:hover {
|
||||||
|
background-color: rgb(251.8, 251.8, 163.8);
|
||||||
|
}
|
||||||
|
button.warn:active, input[type=submit].warn:active, .linkbutton.warn:active {
|
||||||
|
background-color: rgb(198.3813559322, 198.3813559322, 154.4186440678);
|
||||||
|
}
|
||||||
|
button.warn:disabled, input[type=submit].warn:disabled, .linkbutton.warn:disabled {
|
||||||
|
background-color: rgb(217.55, 217.55, 209.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=file]::file-selector-button {
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
margin: 10px 10px;
|
||||||
|
}
|
||||||
|
input[type=file]::file-selector-button:hover {
|
||||||
|
background-color: rgb(192.6, 215.8, 214.6);
|
||||||
|
}
|
||||||
|
input[type=file]::file-selector-button:active {
|
||||||
|
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||||
|
}
|
||||||
|
input[type=file]::file-selector-button:disabled {
|
||||||
|
background-color: rgb(209.535, 211.565, 211.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagebutton {
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
padding: 5px 5px;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pagebutton:hover {
|
||||||
|
background-color: rgb(192.6, 215.8, 214.6);
|
||||||
|
}
|
||||||
|
.pagebutton:active {
|
||||||
|
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||||
|
}
|
||||||
|
.pagebutton:disabled {
|
||||||
|
background-color: rgb(209.535, 211.565, 211.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentpage {
|
||||||
|
border: none;
|
||||||
|
padding: 5px 5px;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modform {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container > * {
|
||||||
|
width: 25%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container > * {
|
||||||
|
width: 40%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text], input[type=password], textarea, select {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
background-color: rgb(217.8, 225.6, 208.2);
|
||||||
|
}
|
||||||
|
input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus {
|
||||||
|
background-color: rgb(230.2, 235.4, 223.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox {
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
}
|
||||||
|
.infobox.critical {
|
||||||
|
background-color: rgb(237, 129, 129);
|
||||||
|
}
|
||||||
|
.infobox.warn {
|
||||||
|
background-color: #fbfb8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox-icon-container {
|
||||||
|
min-width: 60px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1.6fr 96px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
min-height: 96px;
|
||||||
|
grid-template-areas: "thread-sticky-container thread-info-container thread-locked-container";
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-sticky-container {
|
||||||
|
grid-area: thread-sticky-container;
|
||||||
|
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-locked-container {
|
||||||
|
grid-area: thread-locked-container;
|
||||||
|
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contain-svg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.contain-svg:not(.full) > svg {
|
||||||
|
height: 50%;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-img {
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-info-container {
|
||||||
|
grid-area: thread-info-container;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 110px;
|
||||||
|
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-info-post-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: inline;
|
||||||
|
margin-right: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-guide-section {
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding-right: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-guide-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 300px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas: "guide-topics guide-toc";
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-topics {
|
||||||
|
grid-area: guide-topics;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-toc {
|
||||||
|
grid-area: guide-toc;
|
||||||
|
position: sticky;
|
||||||
|
top: 100px;
|
||||||
|
align-self: start;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
border-right: 1px solid black;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table tr td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table tr th {
|
||||||
|
padding-left: 50px;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table, th, td {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 64px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas: "topic-info-container topic-locked-container";
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-info-container {
|
||||||
|
grid-area: topic-info-container;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border: 1px solid black;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-locked-container {
|
||||||
|
grid-area: topic-locked-container;
|
||||||
|
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-topic {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-top: 6px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||||
|
}
|
||||||
|
.draggable-topic.dragged {
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing {
|
||||||
|
background-color: rgb(217.26, 220.38, 213.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-explain {
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: baseline;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
height: 150px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-preview-errors-container {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
border-bottom: none;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.tab-button:hover {
|
||||||
|
background-color: rgb(192.6, 215.8, 214.6);
|
||||||
|
}
|
||||||
|
.tab-button:active {
|
||||||
|
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||||
|
}
|
||||||
|
.tab-button:disabled {
|
||||||
|
background-color: rgb(209.535, 211.565, 211.46);
|
||||||
|
}
|
||||||
|
.tab-button.active {
|
||||||
|
background-color: #beb1ce;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-content.active {
|
||||||
|
min-height: 250px;
|
||||||
|
display: block;
|
||||||
|
background-color: rgb(191.3137931034, 189.7, 193.3);
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 10px;
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 10px 0 10px 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid black;
|
||||||
|
margin: 10px 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.hidden {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgb(159.0271653543, 162.0727712915, 172.9728346457);
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-toggle {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-title {
|
||||||
|
margin-right: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-button-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
.babycode-button > * {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
12
docker-compose.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
porom:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data/static:/app/data/static
|
||||||
|
- ./data/db:/app/data/db
|
||||||
|
- ./secrets:/app/secrets
|
||||||
|
restart: unless-stopped
|
BIN
fonts/Cadman_Bold.woff2
Normal file
BIN
fonts/Cadman_BoldItalic.woff2
Normal file
BIN
fonts/Cadman_Italic.woff2
Normal file
BIN
fonts/Cadman_Roman.woff2
Normal file
BIN
fonts/ChicagoFLF.woff2
Normal file
159
js/babycode-editor.js
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
let ta = document.getElementById("babycode-content");
|
||||||
|
|
||||||
|
ta.addEventListener("keydown", (e) => {
|
||||||
|
if(e.key === "Enter" && e.ctrlKey) {
|
||||||
|
// console.log(e.target.form)
|
||||||
|
e.target.form?.submit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const inThread = () => {
|
||||||
|
const scheme = window.location.pathname.split("/");
|
||||||
|
return scheme[1] === "threads" && scheme[2] !== "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener("input", () => {
|
||||||
|
if (!inThread()) return;
|
||||||
|
|
||||||
|
localStorage.setItem(window.location.pathname, ta.value);
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
if (!inThread()) return;
|
||||||
|
const prevContent = localStorage.getItem(window.location.pathname);
|
||||||
|
if (!prevContent) return;
|
||||||
|
ta.value = prevContent;
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonBold = document.getElementById("post-editor-bold");
|
||||||
|
const buttonItalics = document.getElementById("post-editor-italics");
|
||||||
|
const buttonStrike = document.getElementById("post-editor-strike");
|
||||||
|
const buttonUrl = document.getElementById("post-editor-url");
|
||||||
|
const buttonCode = document.getElementById("post-editor-code");
|
||||||
|
const buttonImg = document.getElementById("post-editor-img");
|
||||||
|
const buttonOl = document.getElementById("post-editor-ol");
|
||||||
|
const buttonUl = document.getElementById("post-editor-ul");
|
||||||
|
|
||||||
|
function insertTag(tagStart, newline = false, prefill = "") {
|
||||||
|
const hasAttr = tagStart[tagStart.length - 1] === "=";
|
||||||
|
let tagEnd = tagStart;
|
||||||
|
let tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
|
||||||
|
if (hasAttr) {
|
||||||
|
tagEnd = tagEnd.slice(0, -1);
|
||||||
|
}
|
||||||
|
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
|
||||||
|
const hasSelection = ta.selectionStart !== ta.selectionEnd;
|
||||||
|
const text = ta.value;
|
||||||
|
if (hasSelection) {
|
||||||
|
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
|
||||||
|
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
|
||||||
|
const selectionLength = realEnd - realStart;
|
||||||
|
|
||||||
|
const strStart = text.slice(0, realStart);
|
||||||
|
const strEnd = text.substring(realEnd);
|
||||||
|
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
|
||||||
|
const reconst = `${strStart}${frag}${strEnd}`;
|
||||||
|
ta.value = reconst;
|
||||||
|
if (!hasAttr){
|
||||||
|
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
|
||||||
|
} else {
|
||||||
|
ta.setSelectionRange(realStart + tagInsertEnd.length - 1, realStart + tagInsertEnd.length - 1); // cursor on attr
|
||||||
|
}
|
||||||
|
ta.focus()
|
||||||
|
} else {
|
||||||
|
if (hasAttr) {
|
||||||
|
tagInsertStart += prefill;
|
||||||
|
}
|
||||||
|
const cursor = ta.selectionStart;
|
||||||
|
const strStart = text.slice(0, cursor);
|
||||||
|
const strEnd = text.substr(cursor);
|
||||||
|
|
||||||
|
let newCursor = strStart.length + tagInsertStart.length;
|
||||||
|
if (hasAttr) {
|
||||||
|
newCursor = cursor + tagInsertStart.length - prefill.length - 1;
|
||||||
|
}
|
||||||
|
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
||||||
|
ta.value = reconst;
|
||||||
|
ta.setSelectionRange(newCursor, newCursor);
|
||||||
|
ta.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonBold.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("b")
|
||||||
|
})
|
||||||
|
buttonItalics.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("i")
|
||||||
|
})
|
||||||
|
buttonStrike.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("s")
|
||||||
|
})
|
||||||
|
buttonUrl.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("url=", false, "link label");
|
||||||
|
})
|
||||||
|
buttonCode.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("code", true)
|
||||||
|
})
|
||||||
|
buttonImg.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("img=", false, "alt text");
|
||||||
|
})
|
||||||
|
buttonOl.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("ol", true);
|
||||||
|
})
|
||||||
|
buttonUl.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("ul", true);
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewEndpoint = "/api/babycode-preview";
|
||||||
|
let previousMarkup = "";
|
||||||
|
const previewTab = document.getElementById("tab-preview");
|
||||||
|
previewTab.addEventListener("tab-activated", async () => {
|
||||||
|
const previewContainer = document.getElementById("babycode-preview-container");
|
||||||
|
const previewErrorsContainer = document.getElementById("babycode-preview-errors-container");
|
||||||
|
// previewErrorsContainer.textContent = "";
|
||||||
|
const markup = ta.value.trim();
|
||||||
|
if (markup === "" || markup === previousMarkup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousMarkup = markup;
|
||||||
|
const req = await fetch(previewEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({markup: markup})
|
||||||
|
})
|
||||||
|
if (!req.ok) {
|
||||||
|
switch (req.status) {
|
||||||
|
case 429:
|
||||||
|
previewErrorsContainer.textContent = "(Old preview, try again in a few seconds.)"
|
||||||
|
previousMarkup = "";
|
||||||
|
break;
|
||||||
|
case 400:
|
||||||
|
previewErrorsContainer.textContent = "(Request got malformed.)"
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
previewErrorsContainer.textContent = "(You are not logged in.)"
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
previewErrorsContainer.textContent = "(Error. Check console.)"
|
||||||
|
console.error(req.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json_resp = await req.json();
|
||||||
|
previewContainer.innerHTML = json_resp.html;
|
||||||
|
previewErrorsContainer.textContent = "";
|
||||||
|
});
|
||||||
|
}
|
7
js/copy-code.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
for (let button of document.querySelectorAll(".copy-code")) {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await navigator.clipboard.writeText(button.value)
|
||||||
|
button.textContent = "Copied!"
|
||||||
|
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
|
||||||
|
})
|
||||||
|
}
|
10
js/date-fmt.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const timestampSpans = document.getElementsByClassName("timestamp");
|
||||||
|
for (let timestampSpan of timestampSpans) {
|
||||||
|
const timestamp = parseInt(timestampSpan.dataset.utc);
|
||||||
|
if (!isNaN(timestamp)) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
timestampSpan.textContent = date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
45
js/sort-topics.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
|
||||||
|
let selected = null;
|
||||||
|
let container = document.getElementById("topics-container")
|
||||||
|
|
||||||
|
function isBefore(el1, el2) {
|
||||||
|
let cur
|
||||||
|
if (el2.parentNode === el1.parentNode) {
|
||||||
|
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||||
|
if (cur === el2) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(e) {
|
||||||
|
let target = e.target.closest(".draggable-topic")
|
||||||
|
|
||||||
|
if (!target || target === selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBefore(selected, target)) {
|
||||||
|
container.insertBefore(selected, target)
|
||||||
|
} else {
|
||||||
|
container.insertBefore(selected, target.nextSibling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd() {
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
selected.classList.remove("dragged")
|
||||||
|
selected = null;
|
||||||
|
for (let i = 0; i < container.childElementCount - 1; i++) {
|
||||||
|
let input = container.children[i].querySelector(".topic-input");
|
||||||
|
input.value = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragStart(e) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', null)
|
||||||
|
selected = e.target
|
||||||
|
selected.classList.add("dragged")
|
||||||
|
}
|
80
js/thread.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
const ta = document.getElementById("babycode-content");
|
||||||
|
|
||||||
|
for (let button of document.querySelectorAll(".reply-button")) {
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
ta.value += button.value;
|
||||||
|
ta.scrollIntoView()
|
||||||
|
ta.focus();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDialog = document.getElementById("delete-dialog");
|
||||||
|
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
|
||||||
|
let deletionTargetPostContainer;
|
||||||
|
|
||||||
|
function closeDeleteDialog() {
|
||||||
|
deletionTargetPostContainer.style.removeProperty("background-color");
|
||||||
|
deleteDialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||||
|
closeDeleteDialog();
|
||||||
|
})
|
||||||
|
deleteDialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === deleteDialog) {
|
||||||
|
closeDeleteDialog();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (let button of document.querySelectorAll(".post-delete-button")) {
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.showModal();
|
||||||
|
const postId = button.value;
|
||||||
|
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
|
||||||
|
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
|
||||||
|
const form = document.getElementById("post-delete-form");
|
||||||
|
form.action = `/post/${postId}/delete`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
|
||||||
|
let now = Math.floor(new Date() / 1000);
|
||||||
|
function hideNotification() {
|
||||||
|
const notification = document.getElementById('new-post-notification');
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewPostNotification(url) {
|
||||||
|
const notification = document.getElementById("new-post-notification");
|
||||||
|
|
||||||
|
notification.classList.remove("hidden");
|
||||||
|
|
||||||
|
document.getElementById("dismiss-new-post-button").onclick = () => {
|
||||||
|
now = Math.floor(new Date() / 1000);
|
||||||
|
hideNotification();
|
||||||
|
tryFetchUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("go-to-new-post-button").href = url;
|
||||||
|
|
||||||
|
document.getElementById("unsub-new-post-button").onclick = () => {
|
||||||
|
hideNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFetchUpdate() {
|
||||||
|
if (!threadEndpoint) return;
|
||||||
|
const body = JSON.stringify({since: now});
|
||||||
|
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(json => {
|
||||||
|
if (json.status === "none") {
|
||||||
|
setTimeout(tryFetchUpdate, 5000);
|
||||||
|
} else if (json.status === "new_post") {
|
||||||
|
showNewPostNotification(json.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.log(error))
|
||||||
|
}
|
||||||
|
tryFetchUpdate();
|
||||||
|
}
|
16
js/topic.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
const deleteDialog = document.getElementById("delete-dialog");
|
||||||
|
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
|
||||||
|
deleteDialogOpenButton.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.showModal();
|
||||||
|
});
|
||||||
|
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
|
||||||
|
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.close();
|
||||||
|
})
|
||||||
|
deleteDialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === deleteDialog) {
|
||||||
|
deleteDialog.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
147
js/ui.js
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
function activateSelfDeactivateSibs(button) {
|
||||||
|
if (button.classList.contains("active")) return;
|
||||||
|
|
||||||
|
Array.from(button.parentNode.children).forEach(s => {
|
||||||
|
if (s === button){
|
||||||
|
button.classList.add('active');
|
||||||
|
} else {
|
||||||
|
s.classList.remove('active');
|
||||||
|
}
|
||||||
|
const targetId = s.dataset.targetId;
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
if (s.classList.contains('active')) {
|
||||||
|
target.classList.add('active');
|
||||||
|
target.dispatchEvent(new CustomEvent("tab-activated", {bubbles: false}))
|
||||||
|
} else {
|
||||||
|
target.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLightbox(post, idx) {
|
||||||
|
lightboxCurrentPost = post;
|
||||||
|
lightboxCurrentIdx = idx;
|
||||||
|
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
|
||||||
|
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
|
||||||
|
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
|
||||||
|
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
|
||||||
|
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
|
||||||
|
|
||||||
|
if (!lightboxObj.dialog.open) {
|
||||||
|
lightboxObj.dialog.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modulo = (n, m) => ((n % m) + m) % m
|
||||||
|
|
||||||
|
function lightboxNext() {
|
||||||
|
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||||
|
const target = modulo(lightboxCurrentIdx + 1, l);
|
||||||
|
openLightbox(lightboxCurrentPost, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightboxPrev() {
|
||||||
|
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||||
|
const target = modulo(lightboxCurrentIdx - 1, l);
|
||||||
|
openLightbox(lightboxCurrentPost, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructLightbox() {
|
||||||
|
const dialog = document.createElement("dialog");
|
||||||
|
dialog.classList.add("lightbox-dialog");
|
||||||
|
dialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === dialog) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dialogInner = document.createElement("div");
|
||||||
|
dialogInner.classList.add("lightbox-inner");
|
||||||
|
dialog.appendChild(dialogInner);
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.classList.add("lightbox-image")
|
||||||
|
dialogInner.appendChild(img);
|
||||||
|
const openOriginalAnchor = document.createElement("a")
|
||||||
|
openOriginalAnchor.text = "Open original in new window"
|
||||||
|
openOriginalAnchor.target = "_blank"
|
||||||
|
openOriginalAnchor.rel = "noopener noreferrer nofollow"
|
||||||
|
dialogInner.appendChild(openOriginalAnchor);
|
||||||
|
|
||||||
|
const navSpan = document.createElement("span");
|
||||||
|
navSpan.classList.add("lightbox-nav");
|
||||||
|
const prevButton = document.createElement("button");
|
||||||
|
prevButton.type = "button";
|
||||||
|
prevButton.textContent = "Previous";
|
||||||
|
prevButton.addEventListener("click", lightboxPrev);
|
||||||
|
const nextButton = document.createElement("button");
|
||||||
|
nextButton.type = "button";
|
||||||
|
nextButton.textContent = "Next";
|
||||||
|
nextButton.addEventListener("click", lightboxNext);
|
||||||
|
const imageCount = document.createElement("span");
|
||||||
|
imageCount.textContent = "Image of ";
|
||||||
|
navSpan.appendChild(prevButton);
|
||||||
|
navSpan.appendChild(imageCount);
|
||||||
|
navSpan.appendChild(nextButton);
|
||||||
|
|
||||||
|
dialogInner.appendChild(navSpan);
|
||||||
|
return {
|
||||||
|
img: img,
|
||||||
|
dialog: dialog,
|
||||||
|
openOriginalAnchor: openOriginalAnchor,
|
||||||
|
prevButton: prevButton,
|
||||||
|
nextButton: nextButton,
|
||||||
|
imageCount: imageCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lightboxImages = new Map(); //.post-inner : Array<Object>
|
||||||
|
let lightboxObj = null;
|
||||||
|
let lightboxCurrentPost = null;
|
||||||
|
let lightboxCurrentIdx = -1;
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// tabs
|
||||||
|
document.querySelectorAll(".tab-button").forEach(button => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
activateSelfDeactivateSibs(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// accordions
|
||||||
|
const accordions = document.querySelectorAll(".accordion");
|
||||||
|
accordions.forEach(accordion => {
|
||||||
|
const header = accordion.querySelector(".accordion-header");
|
||||||
|
const toggleButton = header.querySelector(".accordion-toggle");
|
||||||
|
const content = accordion.querySelector(".accordion-content");
|
||||||
|
|
||||||
|
const toggle = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
accordion.classList.toggle("hidden");
|
||||||
|
content.classList.toggle("hidden");
|
||||||
|
toggleButton.textContent = content.classList.contains("hidden") ? "►" : "▼"
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleButton.addEventListener("click", toggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
//lightboxes
|
||||||
|
lightboxObj = constructLightbox();
|
||||||
|
document.body.appendChild(lightboxObj.dialog);
|
||||||
|
const postImages = document.querySelectorAll(".post-inner img.block-img");
|
||||||
|
postImages.forEach(postImage => {
|
||||||
|
const belongingTo = postImage.closest(".post-inner");
|
||||||
|
const images = lightboxImages.get(belongingTo) ?? [];
|
||||||
|
images.push({
|
||||||
|
src: postImage.src,
|
||||||
|
alt: postImage.alt,
|
||||||
|
});
|
||||||
|
const idx = images.length - 1;
|
||||||
|
lightboxImages.set(belongingTo, images);
|
||||||
|
postImage.style.cursor = "pointer";
|
||||||
|
postImage.addEventListener("click", () => {
|
||||||
|
openLightbox(belongingTo, idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
16
lib/auth.lua
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
local auth = {}
|
||||||
|
|
||||||
|
local ls = require "luasodium"
|
||||||
|
|
||||||
|
function auth.digest(password)
|
||||||
|
return ls.crypto_pwhash_str(
|
||||||
|
password,
|
||||||
|
ls.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
ls.crypto_pwhash_MEMLIMIT_INTERACTIVE)
|
||||||
|
end
|
||||||
|
|
||||||
|
function auth.verify(password, hash)
|
||||||
|
return ls.crypto_pwhash_str_verify(hash, password)
|
||||||
|
end
|
||||||
|
|
||||||
|
return auth
|
47
lib/babycode-emoji.lua
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
local emoji_template = " <img class=emoji src=\"/emoji/$NAME.png\" alt=\"$NAME\" title=\":$CODE:\"> "
|
||||||
|
local emoji_pat = "%$NAME"
|
||||||
|
local name_pat = "%$CODE"
|
||||||
|
|
||||||
|
return {
|
||||||
|
["angry"] = emoji_template:gsub(emoji_pat, "angry"):gsub(name_pat, "angry"),
|
||||||
|
|
||||||
|
["("] = emoji_template:gsub(emoji_pat, "frown"):gsub(name_pat, "("),
|
||||||
|
|
||||||
|
["D"] = emoji_template:gsub(emoji_pat, "grin"):gsub(name_pat, "D"),
|
||||||
|
|
||||||
|
["imp"] = emoji_template:gsub(emoji_pat, "imp"):gsub(name_pat, "imp"),
|
||||||
|
|
||||||
|
["angryimp"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "angryimp"),
|
||||||
|
["impangry"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "impangry"),
|
||||||
|
|
||||||
|
["lobster"] = emoji_template:gsub(emoji_pat, "lobster"):gsub(name_pat, "lobster"),
|
||||||
|
|
||||||
|
["|"] = emoji_template:gsub(emoji_pat, "neutral"):gsub(name_pat, "|"),
|
||||||
|
|
||||||
|
["pensive"] = emoji_template:gsub(emoji_pat, "pensive"):gsub(name_pat, "pensive"),
|
||||||
|
|
||||||
|
[")"] = emoji_template:gsub(emoji_pat, "smile"):gsub(name_pat, ")"),
|
||||||
|
|
||||||
|
["smiletear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "smiletear"),
|
||||||
|
["crytear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "crytear"),
|
||||||
|
|
||||||
|
[","] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, ","),
|
||||||
|
["T"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "T"),
|
||||||
|
["cry"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "cry"),
|
||||||
|
["sob"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "sob"),
|
||||||
|
|
||||||
|
["o"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "o"),
|
||||||
|
["O"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "O"),
|
||||||
|
|
||||||
|
["hmm"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "hmm"),
|
||||||
|
["think"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "think"),
|
||||||
|
["thinking"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "thinking"),
|
||||||
|
|
||||||
|
["P"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "P"),
|
||||||
|
["p"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "p"),
|
||||||
|
|
||||||
|
["weary"] = emoji_template:gsub(emoji_pat, "weary"):gsub(name_pat, "weary"),
|
||||||
|
|
||||||
|
[";"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_pat, ";"),
|
||||||
|
["wink"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_pat, "wink"),
|
||||||
|
}
|
416
lib/babycode-parser.lua
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
-- contributed by kaesa
|
||||||
|
|
||||||
|
--- Pattern used for emote names (applied for every char).
|
||||||
|
local PAT_EMOTE = "[^%s:]"
|
||||||
|
--- Pattern used for bbcode tags (applied for every char).
|
||||||
|
local PAT_BBCODE_TAG = "%w"
|
||||||
|
--- Pattern used for bbcode tag attribute (applied for every char).
|
||||||
|
local PAT_BBCODE_ATTR = "[^%s%]]"
|
||||||
|
--- Pattern used to detect loose links.
|
||||||
|
local PAT_LINK = "https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/]"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--- @class Parser
|
||||||
|
--- @field valid_bbcode_tags table Table of valid BBCode tags.
|
||||||
|
--- @field valid_emotes table Table of valid emotes.
|
||||||
|
--- @field bbcode_tags_only_text_children table Table of tags that might only containt text.
|
||||||
|
--- @field source string Source to parse.
|
||||||
|
--- @field position integer Current position of the parser.
|
||||||
|
--- @field position_stack integer[] Position stack used for rewind parsing.
|
||||||
|
---
|
||||||
|
--- Parser class.
|
||||||
|
local Parser = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--- Creates a new parser.
|
||||||
|
---
|
||||||
|
--- @param src string
|
||||||
|
--- @return Parser
|
||||||
|
function Parser.new(src)
|
||||||
|
local inst = {
|
||||||
|
valid_bbcode_tags = {},
|
||||||
|
valid_emotes = {},
|
||||||
|
bbcode_tags_only_text_children = {},
|
||||||
|
source = src,
|
||||||
|
position = 1,
|
||||||
|
elements = {},
|
||||||
|
position_stack = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(inst, { __index = Parser })
|
||||||
|
|
||||||
|
return inst
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Advances the parser by COUNT characters.
|
||||||
|
--- @param count integer? Set to 1 if nil.
|
||||||
|
function Parser:advance(count)
|
||||||
|
count = count or 1
|
||||||
|
self.position = self.position + count
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the position is out of bounds of the source.
|
||||||
|
--- @param offset integer? Set to 0 if nil.
|
||||||
|
function Parser:is_end_of_source(offset)
|
||||||
|
offset = offset or 0
|
||||||
|
return self.position + offset > #self.source
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Saves the current position to the position stack.
|
||||||
|
function Parser:save_position()
|
||||||
|
table.insert(self.position_stack, self.position)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Restores the current position to the top of the position stack, and remove
|
||||||
|
--- that position from the stack.
|
||||||
|
function Parser:restore_position()
|
||||||
|
self.position = table.remove(self.position_stack)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Forgets the top position in the position stack.
|
||||||
|
function Parser:forget_position()
|
||||||
|
table.remove(self.position_stack)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Retreives the character at the current position (plus optional offset).
|
||||||
|
---
|
||||||
|
--- @param offset integer? Set to 0 if nil.
|
||||||
|
--- @return string
|
||||||
|
function Parser:peek_char(offset)
|
||||||
|
offset = offset or 0
|
||||||
|
|
||||||
|
-- if the offset is out of bound
|
||||||
|
if self:is_end_of_source(offset) then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
return self.source:sub(self.position + offset, self.position + offset)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Retreives the character at the current position and advance the position.
|
||||||
|
---
|
||||||
|
--- @return string
|
||||||
|
function Parser:get_char()
|
||||||
|
local char = self:peek_char()
|
||||||
|
self:advance()
|
||||||
|
return char
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the character at the current current position is WANTED. If so,
|
||||||
|
--- advance the position, and returns true. Do nothing otherwise and returns
|
||||||
|
--- false.
|
||||||
|
---
|
||||||
|
--- @param wanted string The character to check with.
|
||||||
|
--- @return boolean
|
||||||
|
function Parser:check_char(wanted)
|
||||||
|
local char = self:peek_char()
|
||||||
|
|
||||||
|
if char == wanted then
|
||||||
|
self:advance()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if WANTED is present at the current position in the source. If so,
|
||||||
|
--- advance the position and returns true. Do nothing otherwise and returns
|
||||||
|
--- false.
|
||||||
|
---
|
||||||
|
--- @param wanted string
|
||||||
|
--- @return boolean
|
||||||
|
---
|
||||||
|
function Parser:check_str(wanted)
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- For each character in WANTED
|
||||||
|
for i = 1, #wanted do
|
||||||
|
-- Checks if the character is present
|
||||||
|
if not self:check_char(wanted:sub(i, i)) then
|
||||||
|
self:restore_position()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the string at the current position matches the given pattern.
|
||||||
|
--- The pattern is matched for each character in a sequence. Returns the matched
|
||||||
|
--- string. Advances the position of the parser.
|
||||||
|
---
|
||||||
|
--- @param pattern string
|
||||||
|
--- @return string
|
||||||
|
---
|
||||||
|
function Parser:match_pattern(pattern)
|
||||||
|
local buffer = ""
|
||||||
|
|
||||||
|
while not self:is_end_of_source() do
|
||||||
|
local ch = self:peek_char()
|
||||||
|
|
||||||
|
if not ch:match(pattern) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
self:advance()
|
||||||
|
buffer = buffer .. ch
|
||||||
|
end
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse an emote. Only recognizes emotes present in the `valid_emotes`
|
||||||
|
--- field of the parser.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "emote",
|
||||||
|
--- name = string }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_emote()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- if there is no beginning ":"
|
||||||
|
if not self:check_char(":") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- extract the emote name
|
||||||
|
local name = self:match_pattern(PAT_EMOTE)
|
||||||
|
|
||||||
|
-- if there is no ending ":"
|
||||||
|
if not self:check_char(":") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if the emote name isnt valid
|
||||||
|
if not self.valid_emotes[name] then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "emote",
|
||||||
|
name = name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a bbcode openning tag. Only recognizes tags present in
|
||||||
|
--- `valid_bbcode_tags` field of the parser.
|
||||||
|
---
|
||||||
|
--- Returns the name of the tag, and its attribute (if any present).
|
||||||
|
---
|
||||||
|
--- @return string?, string?
|
||||||
|
function Parser:parse_bbcode_open()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- if there is no beginning "["
|
||||||
|
if not self:check_char("[") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- extract the tag name
|
||||||
|
local name = self:match_pattern(PAT_BBCODE_TAG)
|
||||||
|
|
||||||
|
-- if there is no tag name
|
||||||
|
if name == "" then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local attribute = nil
|
||||||
|
|
||||||
|
-- if there is an attribute given
|
||||||
|
if self:check_char("=") then
|
||||||
|
-- extract it
|
||||||
|
attribute = self:match_pattern(PAT_BBCODE_ATTR)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if there is no closing "]"
|
||||||
|
if not self:check_char("]") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if the tag isnt valid
|
||||||
|
if not self.valid_bbcode_tags[name] then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return name, attribute
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a bbcode tag. Only recognizes tags present in `valid_bbcode_tags`
|
||||||
|
--- field of the parser.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "bbcode",
|
||||||
|
--- name = string,
|
||||||
|
--- attribute = string?,
|
||||||
|
--- children = (string|table)[] }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_bbcode()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
local name, attribute = self:parse_bbcode_open()
|
||||||
|
|
||||||
|
-- if there isnt a open bbcode tag here
|
||||||
|
if name == nil then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local children = {}
|
||||||
|
|
||||||
|
-- parse children elements of that tag
|
||||||
|
while not self:is_end_of_source() do
|
||||||
|
-- if there is a close tag here
|
||||||
|
if self:check_str("[/" .. name .. "]") then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if that tag only accept text children
|
||||||
|
if self.bbcode_tags_only_text_children[name] then
|
||||||
|
local ch = self:get_char()
|
||||||
|
|
||||||
|
if #children == 0 then
|
||||||
|
table.insert(children, ch)
|
||||||
|
else
|
||||||
|
children[1] = children[1] .. ch
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local element = self:parse_element(children)
|
||||||
|
|
||||||
|
-- if the end of the source has been reached
|
||||||
|
if element == nil then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(children, element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "bbcode",
|
||||||
|
name = name,
|
||||||
|
attribute = attribute,
|
||||||
|
children = children
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a ruler element.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "ruler" }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_ruler()
|
||||||
|
if not self:check_str("---") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = "ruler",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a loose link.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "link",
|
||||||
|
--- url = string }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_link()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- we extract a "word" (bunch of printable characters without spaces).
|
||||||
|
local word = self:match_pattern("%g")
|
||||||
|
|
||||||
|
-- if that "word" matches the link pattern
|
||||||
|
if not word:match(PAT_LINK) then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "link",
|
||||||
|
url = word,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse an element.
|
||||||
|
---
|
||||||
|
--- Returns either a table or a string.
|
||||||
|
--- A string represent simple text.
|
||||||
|
--- A table represent different kind of element that can be differienciated
|
||||||
|
--- by its `type` field.
|
||||||
|
---
|
||||||
|
--- Valid types : emote, bbcode, link, ruler.
|
||||||
|
--- Each type has different fields. See `Parser:parse_*` functions for more
|
||||||
|
--- info.
|
||||||
|
---
|
||||||
|
--- Returns nil when the end of the source has been reached.
|
||||||
|
---
|
||||||
|
--- @param sibblings (string|table)[]
|
||||||
|
--- @return (table|string)?
|
||||||
|
function Parser:parse_element(sibblings)
|
||||||
|
if self:is_end_of_source() then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local element = self:parse_emote()
|
||||||
|
or self:parse_bbcode()
|
||||||
|
or self:parse_ruler()
|
||||||
|
or self:parse_link()
|
||||||
|
|
||||||
|
if element == nil then
|
||||||
|
if #sibblings > 0 then
|
||||||
|
local last = sibblings[#sibblings]
|
||||||
|
|
||||||
|
if type(last) == "string" then
|
||||||
|
table.remove(sibblings)
|
||||||
|
return last .. self:get_char()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return self:get_char()
|
||||||
|
end
|
||||||
|
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parses the whole source at once, returning all parsed elements.
|
||||||
|
--- See `Parser:parse_element` for more information about the return value.
|
||||||
|
---
|
||||||
|
--- @return (string|table)[]
|
||||||
|
function Parser:parse()
|
||||||
|
local elements = {}
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local element = self:parse_element(elements)
|
||||||
|
if element == nil then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(elements, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
return elements
|
||||||
|
end
|
||||||
|
|
||||||
|
return Parser
|
150
lib/babycode.lua
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
local babycode = {}
|
||||||
|
|
||||||
|
local string_trim = require("lapis.util").trim
|
||||||
|
local emoji = require("lib.babycode-emoji")
|
||||||
|
|
||||||
|
local Parser = require("lib.babycode-parser")
|
||||||
|
|
||||||
|
local function s_split(s, delimiter, max_matches, trim, allow_empty)
|
||||||
|
local result = {}
|
||||||
|
if s == "" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
trim = trim == nil and true or trim
|
||||||
|
local tr = function(subj)
|
||||||
|
if trim then return string_trim(subj) else return subj end
|
||||||
|
end
|
||||||
|
max_matches = max_matches or -1
|
||||||
|
allow_empty = allow_empty == nil and true or allow_empty
|
||||||
|
|
||||||
|
if delimiter == "" then
|
||||||
|
for i=1, #s do
|
||||||
|
local c = s:sub(i, 1)
|
||||||
|
if allow_empty or c ~= "" then
|
||||||
|
table.insert(result, c)
|
||||||
|
if max_matches > 0 and #result == max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_pos = 1
|
||||||
|
local delim_len = #delimiter
|
||||||
|
|
||||||
|
while true do
|
||||||
|
if max_matches > 0 and #result >= max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
---@diagnostic disable-next-line: param-type-mismatch
|
||||||
|
local start_pos, end_pos = s:find(delimiter, current_pos, true)
|
||||||
|
if not start_pos then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local substr = s:sub(current_pos, start_pos - 1)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
current_pos = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local substr = s:sub(current_pos)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function list(tag, children)
|
||||||
|
local list_body = children:gsub(" +\n", "<br>"):gsub("\n\n+", "\1")
|
||||||
|
local list_items = s_split(list_body, "\1")
|
||||||
|
local lis = ""
|
||||||
|
for _, li in ipairs(list_items) do
|
||||||
|
lis = lis .. "<li>" .. li .. "</li>"
|
||||||
|
end
|
||||||
|
return "<" .. tag .. ">" .. lis .. "</" .. tag .. ">"
|
||||||
|
end
|
||||||
|
|
||||||
|
local tags = {
|
||||||
|
b = "<strong>$S</strong>",
|
||||||
|
i = "<em>$S</em>",
|
||||||
|
s = "<del>$S</del>",
|
||||||
|
img = "<div class=\"post-img-container\"><img class=\"block-img\" src=$A alt=$S></div>",
|
||||||
|
url = "<a href=\"$A\">$S</a>",
|
||||||
|
quote = "<blockquote>$S</blockquote>",
|
||||||
|
code = function(children)
|
||||||
|
local is_inline = children:match("\n") == nil
|
||||||
|
if is_inline then
|
||||||
|
return "<code class=\"inline-code\">" .. children .. "</code>"
|
||||||
|
else
|
||||||
|
local t = string_trim(children)
|
||||||
|
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(t)
|
||||||
|
return "<pre><span class=\"copy-code-container\">"..button.."</span><code>"..t.."</code></pre>"
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
ul = function(children)
|
||||||
|
return list("ul", children)
|
||||||
|
end,
|
||||||
|
ol = function(children)
|
||||||
|
return list("ol", children)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local text_only = {
|
||||||
|
code = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
---renders babycode to html
|
||||||
|
---@param s string input babycode
|
||||||
|
---@param html_escape fun(s: string): string function to escape html
|
||||||
|
function babycode.to_html(s, html_escape)
|
||||||
|
-- normalize line ending chars
|
||||||
|
local subj = string_trim(html_escape(s)):gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||||
|
local parser = Parser.new(subj)
|
||||||
|
parser.valid_bbcode_tags = tags
|
||||||
|
parser.valid_emotes = emoji
|
||||||
|
parser.bbcode_tags_only_text_children = text_only
|
||||||
|
|
||||||
|
local elements = parser:parse()
|
||||||
|
local out = ""
|
||||||
|
local function fold(element, nobr)
|
||||||
|
if type(element) == "string" then
|
||||||
|
if nobr then
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
return element:gsub(" +\n", "<br>"):gsub("\n\n+", "<br><br>")
|
||||||
|
end
|
||||||
|
if element.type == "bbcode" then
|
||||||
|
local c = ""
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
local _nobr = element.name == "code" or element.name == "ul" or element.name == "ol"
|
||||||
|
c = c .. fold(child, _nobr)
|
||||||
|
end
|
||||||
|
local res = ""
|
||||||
|
if type(tags[element.name]) == "string" then
|
||||||
|
res = (tags[element.name]):gsub("%$S", c)
|
||||||
|
if element.attribute then
|
||||||
|
res = res:gsub("%$A", element.attribute)
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
elseif type(tags[element.name]) == "function" then
|
||||||
|
res = tags[element.name](c, element.attribute)
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
elseif element.type == "link" then
|
||||||
|
return "<a href=\""..element.url.."\">"..element.url.."</a>"
|
||||||
|
elseif element.type == "emote" then
|
||||||
|
return emoji[element.name]
|
||||||
|
elseif element.type == "ruler" then
|
||||||
|
return "<hr>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, e in ipairs(elements) do
|
||||||
|
out = out .. fold(e, false)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
return babycode
|
59
lib/sse.lua
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---@class SSE
|
||||||
|
---@field active boolean if the stream is not active, you should stop the loop.
|
||||||
|
---@field private _queue table
|
||||||
|
local sse = {}
|
||||||
|
|
||||||
|
---Construct a new SSE object
|
||||||
|
---@return SSE
|
||||||
|
function sse:new()
|
||||||
|
ngx.header.content_type = "text/event-stream"
|
||||||
|
ngx.header.cache_control = "no-cache"
|
||||||
|
ngx.header.connection = "keep-alive"
|
||||||
|
ngx.status = ngx.HTTP_OK
|
||||||
|
ngx.flush(true)
|
||||||
|
|
||||||
|
local obj = {
|
||||||
|
active = true,
|
||||||
|
_queue = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
ngx.on_abort(function()
|
||||||
|
obj.active = false
|
||||||
|
end)
|
||||||
|
|
||||||
|
return setmetatable(obj, {__index = sse})
|
||||||
|
end
|
||||||
|
|
||||||
|
---add data to the stream, writing on the next dispatch.
|
||||||
|
---if `event` is given, it will be the key.
|
||||||
|
---@param data string
|
||||||
|
---@param event? string
|
||||||
|
---@return boolean status
|
||||||
|
function sse:enqueue(data, event)
|
||||||
|
if not self.active then return false end
|
||||||
|
table.insert(self._queue, {
|
||||||
|
data = data,
|
||||||
|
event = event,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---send all events since the last dispatch and flush the queue.
|
||||||
|
---call this every iteration of the loop.
|
||||||
|
function sse:dispatch()
|
||||||
|
while #self._queue > 0 do
|
||||||
|
local msg = table.remove(self._queue, 1)
|
||||||
|
if msg.event then
|
||||||
|
ngx.print("event: " .. msg.event .. "\n")
|
||||||
|
end
|
||||||
|
ngx.print("data: " .. msg.data .. "\n\n")
|
||||||
|
end
|
||||||
|
ngx.flush(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
---close the stream.
|
||||||
|
function sse:close()
|
||||||
|
self.active = false
|
||||||
|
end
|
||||||
|
|
||||||
|
return sse
|
116
migrations.lua
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
local db = require("lapis.db")
|
||||||
|
local schema = require("lapis.db.schema")
|
||||||
|
local types = schema.types
|
||||||
|
|
||||||
|
return {
|
||||||
|
[1] = function ()
|
||||||
|
schema.create_table("sessions", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"key", types.text{unique = true}},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||||
|
{"expires_at", types.integer},
|
||||||
|
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
})
|
||||||
|
|
||||||
|
db.query("CREATE INDEX sessions_user_id ON sessions(user_id)")
|
||||||
|
db.query("CREATE INDEX session_keys ON sessions(key)")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[2] = function ()
|
||||||
|
schema.add_column("users", "confirmed_on", types.integer{null = true})
|
||||||
|
end,
|
||||||
|
|
||||||
|
[3] = function ()
|
||||||
|
schema.add_column("users", "status", types.text{null = true, default=""})
|
||||||
|
schema.create_table("avatars", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"file_path", types.text{unique = true}},
|
||||||
|
{"uploaded_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
})
|
||||||
|
schema.add_column("users", "avatar_id", "REFERENCES avatars(id) ON DELETE SET NULL")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[4] = function ()
|
||||||
|
schema.add_column("topics", "description", types.text{default=""})
|
||||||
|
|
||||||
|
-- topic locked = no new threads can be created in the topic, but posts can be made in threads
|
||||||
|
-- thread locked = no new posts can be created in the thread, existing posts can not be edited
|
||||||
|
-- admins bypass both restrictions
|
||||||
|
schema.add_column("topics", "is_locked", "BOOLEAN DEFAULT FALSE")
|
||||||
|
schema.add_column("threads", "is_locked", "BOOLEAN DEFAULT FALSE")
|
||||||
|
-- will appear on top of non-stickied threads in topic view
|
||||||
|
schema.add_column("threads", "is_stickied", "BOOLEAN DEFAULT FALSE")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[5] = function ()
|
||||||
|
db.query("CREATE INDEX idx_posts_thread ON posts(thread_id, created_at, id)")
|
||||||
|
db.query("CREATE INDEX idx_users_avatar ON users(avatar_id)")
|
||||||
|
db.query("CREATE INDEX idx_topics_slug ON topics(slug)")
|
||||||
|
db.query("CREATE INDEX idx_threads_slug ON threads(slug)")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[6] = function ()
|
||||||
|
schema.drop_column("post_history", "user_id")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[7] = function ()
|
||||||
|
db.query('DROP INDEX "idx_users_avatar"')
|
||||||
|
schema.drop_column("users", "avatar_id")
|
||||||
|
schema.add_column("users", "avatar_id", "REFERENCES avatars(id) DEFAULT 1")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[8] = function ()
|
||||||
|
schema.add_column("topics", "sort_order", types.integer{default = 0})
|
||||||
|
db.query("UPDATE topics SET sort_order = (SELECT COUNT(*) FROM topics t2 WHERE t2.ROWID <= topics.ROWID)")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[9] = function ()
|
||||||
|
schema.add_column("post_history", "original_markup", types.text{null = false})
|
||||||
|
schema.add_column("post_history", "markup_language", types.text{default = "babycode"})
|
||||||
|
end,
|
||||||
|
|
||||||
|
[10] = function ()
|
||||||
|
schema.add_column("users", "signature_original_markup", types.text{default = ""})
|
||||||
|
schema.add_column("users", "signature_rendered", types.text{default = ""})
|
||||||
|
end,
|
||||||
|
|
||||||
|
[11] = function ()
|
||||||
|
local render = require("lib.babycode").to_html
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local phs = db.query("SELECT * from post_history")
|
||||||
|
local users = db.query("SELECT * from users")
|
||||||
|
db.query("BEGIN")
|
||||||
|
|
||||||
|
for _, post_history in ipairs(phs) do
|
||||||
|
db.query("UPDATE post_history SET content = ? WHERE id = ?", render(post_history.original_markup, html_escape), post_history.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, user in ipairs(users) do
|
||||||
|
db.query("UPDATE users SET signature_rendered = ? WHERE id = ?", render(user.signature_original_markup, html_escape), user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
db.query("COMMIT")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[12] = function ()
|
||||||
|
schema.create_table("api_rate_limits", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"method", types.text{null = false}},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||||
|
{"logged_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
})
|
||||||
|
|
||||||
|
db.query("CREATE INDEX idx_rate_limit_user_method ON api_rate_limits (user_id, method)")
|
||||||
|
end,
|
||||||
|
|
||||||
|
[13] = function ()
|
||||||
|
schema.create_table("subscriptions", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||||
|
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
|
||||||
|
{"last_seen", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)) NOT NULL"},
|
||||||
|
})
|
||||||
|
|
||||||
|
db.query("CREATE INDEX idx_subscription_user_thread ON subscriptions (user_id, thread_id)")
|
||||||
|
end,
|
||||||
|
}
|
49
models.lua
@ -1,2 +1,47 @@
|
|||||||
local autoload = require("lapis.util").autoload
|
local Model = require("lapis.db.model").Model
|
||||||
return autoload("models")
|
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local Users, Users_mt = Model:extend("users")
|
||||||
|
|
||||||
|
function Users_mt:is_guest()
|
||||||
|
return self.permission == constants.PermissionLevel.GUEST
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_admin()
|
||||||
|
return self.permission == constants.PermissionLevel.ADMIN
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_mod()
|
||||||
|
return self.permission >= constants.PermissionLevel.MODERATOR
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_system()
|
||||||
|
return self.permission == constants.PermissionLevel.SYSTEM
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_logged_in_guest()
|
||||||
|
return self:is_guest() and true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_default_avatar()
|
||||||
|
return self.avatar_id == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function Users_mt:is_logged_in()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local ret = {
|
||||||
|
Users = Users,
|
||||||
|
Topics = Model:extend("topics"),
|
||||||
|
Threads = Model:extend("threads"),
|
||||||
|
Posts = Model:extend("posts"),
|
||||||
|
PostHistory = Model:extend("post_history"),
|
||||||
|
Sessions = Model:extend("sessions"),
|
||||||
|
Avatars = Model:extend("avatars"),
|
||||||
|
Subscriptions = Model:extend("subscriptions"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
23
nginx.conf
@ -19,6 +19,7 @@ http {
|
|||||||
lua_code_cache ${{CODE_CACHE}};
|
lua_code_cache ${{CODE_CACHE}};
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
lua_check_client_abort on;
|
||||||
default_type text/html;
|
default_type text/html;
|
||||||
content_by_lua_block {
|
content_by_lua_block {
|
||||||
require("lapis").serve("app")
|
require("lapis").serve("app")
|
||||||
@ -26,11 +27,29 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias static/;
|
alias data/static/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /favicon.ico {
|
location /favicon.ico {
|
||||||
alias static/favicon.ico;
|
alias data/static/favicon.ico;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /avatars {
|
||||||
|
alias data/static/avatars;
|
||||||
|
expires 1y;
|
||||||
|
}
|
||||||
|
location /emoji {
|
||||||
|
alias data/static/emoji;
|
||||||
|
expires 1y;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location /static/js/ {
|
||||||
|
alias js/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/fonts/ {
|
||||||
|
alias fonts/;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
porom-dev-1.rockspec
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package = "porom"
|
||||||
|
version = "dev-1"
|
||||||
|
|
||||||
|
source = {
|
||||||
|
url = "ssh://gitea@git.poto.cafe:222/yagich/porom.git"
|
||||||
|
}
|
||||||
|
|
||||||
|
description = {
|
||||||
|
summary = "Homegrown forum software",
|
||||||
|
homepage = "",
|
||||||
|
license = "CNPLv7+"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies = {
|
||||||
|
"lua ~> 5.1",
|
||||||
|
"lapis == 1.16.0",
|
||||||
|
"lsqlite3",
|
||||||
|
"magick",
|
||||||
|
"luasodium",
|
||||||
|
"luaossl",
|
||||||
|
}
|
||||||
|
|
||||||
|
build = {
|
||||||
|
type = "none"
|
||||||
|
}
|
744
sass/style.scss
Normal file
@ -0,0 +1,744 @@
|
|||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin cadman($var) {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_#{$var}.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Roman");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Bold");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Italic");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("BoldItalic");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accent_color: #c1ceb1;
|
||||||
|
|
||||||
|
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
|
||||||
|
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
|
||||||
|
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
|
||||||
|
|
||||||
|
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
|
||||||
|
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
||||||
|
|
||||||
|
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
|
||||||
|
$button_color: color.adjust($accent_color, $hue: 90);
|
||||||
|
$button_color2: color.adjust($accent_color, $hue: 180);
|
||||||
|
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
|
||||||
|
|
||||||
|
%button-base {
|
||||||
|
cursor: default;
|
||||||
|
color: black;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: "Cadman";
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin button($color) {
|
||||||
|
@extend %button-base;
|
||||||
|
background-color: $color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color.scale($color, $lightness: 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: color.scale($color, $lightness: -10%, $saturation: -70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: color.scale($color, $lightness: 30%, $saturation: -90%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin navbar($color) {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
background-color: $color;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Cadman";
|
||||||
|
// font-size: 18px;
|
||||||
|
margin: 20px 100px;
|
||||||
|
background-color: $main_bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topnav {
|
||||||
|
@include navbar($accent_color);
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bottomnav {
|
||||||
|
@include navbar($dark_bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.darkbg {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
background-color: $dark_bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title {
|
||||||
|
font-family: "site-title";
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas:
|
||||||
|
"usercard post-content-container";
|
||||||
|
border: 2px outset $dark2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard {
|
||||||
|
grid-area: usercard;
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: 4px outset $light;
|
||||||
|
background-color: $dark_bg;
|
||||||
|
border-right: solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usercard-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
top: 10px;
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 70px 2.5fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas:
|
||||||
|
"post-info"
|
||||||
|
"post-content";
|
||||||
|
grid-area: post-content-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
grid-area: post-info;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 20px;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
grid-area: post-content;
|
||||||
|
padding: 20px;
|
||||||
|
margin-right: 25%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content.wider {
|
||||||
|
margin-right: 12.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-inner {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: $verydark;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-left: 10px solid $lighter;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: scroll;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background-color: $verydark;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-dialog, .lightbox-dialog {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid black;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-dialog-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 400px;
|
||||||
|
background-color: $accent_color;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-image {
|
||||||
|
max-width: 70vw;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-container {
|
||||||
|
position: sticky;
|
||||||
|
// width: 100%;
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: last baseline;
|
||||||
|
font-family: "Cadman";
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
background-color: $accent_color;
|
||||||
|
border-left: 2px solid black;
|
||||||
|
border-right: 2px solid black;
|
||||||
|
border-top: 2px solid black;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "code block";
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 10px solid $lighter;
|
||||||
|
background-color: $dark2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
grid-template-areas:
|
||||||
|
"user-page-usercard user-page-stats";
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-usercard {
|
||||||
|
grid-area: user-page-usercard;
|
||||||
|
padding: 20px 10px;
|
||||||
|
border: 4px outset $light;
|
||||||
|
background-color: $dark_bg;
|
||||||
|
border-right: solid 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-stats {
|
||||||
|
grid-area: user-page-stats;
|
||||||
|
padding: 20px 30px;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-posts {
|
||||||
|
border-left: solid 1px black;
|
||||||
|
border-right: solid 1px black;
|
||||||
|
border-bottom: solid 1px black;
|
||||||
|
background-color: $accent_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-page-post-preview {
|
||||||
|
max-height: 200px;
|
||||||
|
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-link {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input[type="submit"], .linkbutton {
|
||||||
|
display: inline-block;
|
||||||
|
@include button($button_color);
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
color: white;
|
||||||
|
@include button(red);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
@include button(#fbfb8d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sure why this one has to be separate, but if it's included in the rule above everything breaks
|
||||||
|
input[type="file"]::file-selector-button {
|
||||||
|
@include button($button_color);
|
||||||
|
margin: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagebutton {
|
||||||
|
@include button($button_color);
|
||||||
|
padding: 5px 5px;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentpage {
|
||||||
|
@extend %button-base;
|
||||||
|
border: none;
|
||||||
|
padding: 5px 5px;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modform {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container > * {
|
||||||
|
width: 25%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container > * {
|
||||||
|
width: 40%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="password"], textarea, select {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
background-color: color.scale($accent_color, $lightness: 40%);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: color.scale($accent_color, $lightness: 60%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox {
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
|
||||||
|
&.critical {
|
||||||
|
background-color: rgb(237, 129, 129);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background-color: #fbfb8d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobox-icon-container {
|
||||||
|
min-width: 60px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1.6fr 96px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
min-height: 96px;
|
||||||
|
grid-template-areas:
|
||||||
|
"thread-sticky-container thread-info-container thread-locked-container";
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-sticky-container {
|
||||||
|
grid-area: thread-sticky-container;
|
||||||
|
border: 2px outset $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-locked-container {
|
||||||
|
grid-area: thread-locked-container;
|
||||||
|
border: 2px outset $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contain-svg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
&:not(.full) > svg {
|
||||||
|
height: 50%;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-img {
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-info-container {
|
||||||
|
grid-area: thread-info-container;
|
||||||
|
background-color: $accent_color;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 110px;
|
||||||
|
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-info-post-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: inline;
|
||||||
|
margin-right: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-guide-section {
|
||||||
|
background-color: $accent_color;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding-right: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-guide-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 300px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas:
|
||||||
|
"guide-topics guide-toc";
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-topics {
|
||||||
|
grid-area: guide-topics;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-toc {
|
||||||
|
grid-area: guide-toc;
|
||||||
|
position: sticky;
|
||||||
|
top: 100px;
|
||||||
|
align-self: start;
|
||||||
|
padding: 10px;
|
||||||
|
// border-top-right-radius: 16px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
background-color: $button_color;
|
||||||
|
border-right: 1px solid black;
|
||||||
|
border-top: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table tr td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table tr th {
|
||||||
|
padding-left: 50px;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-table, th, td {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 64px;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
gap: 0px 0px;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-areas:
|
||||||
|
"topic-info-container topic-locked-container";
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-info-container {
|
||||||
|
grid-area: topic-info-container;
|
||||||
|
background-color: $accent_color;
|
||||||
|
padding: 5px 20px;
|
||||||
|
border: 1px solid black;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-locked-container {
|
||||||
|
grid-area: topic-locked-container;
|
||||||
|
border: 2px outset $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.draggable-topic {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background-color: $accent_color;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-top: 6px outset $light;
|
||||||
|
border-bottom: 6px outset $dark2;
|
||||||
|
|
||||||
|
&.dragged {
|
||||||
|
background-color: $button_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing {
|
||||||
|
background-color: $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-explain {
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: baseline;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
height: 150px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-preview-errors-container {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
@include button($button_color);
|
||||||
|
border-bottom: none;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: $button_color2;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
min-height: 250px;
|
||||||
|
display: block;
|
||||||
|
background-color: color.adjust($button_color2, $saturation: -20%);
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 10px;
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-bottom-right-radius: 3px;
|
||||||
|
border-bottom-left-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 10px 0 10px 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid black;
|
||||||
|
margin: 10px 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.hidden {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: $accordion_color;
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-toggle {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-title {
|
||||||
|
margin-right: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content {
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-button-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
min-width: 36px;
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
48
schema.lua
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
local schema = require("lapis.db.schema")
|
||||||
|
local db = require("lapis.db")
|
||||||
|
|
||||||
|
local types = schema.types
|
||||||
|
|
||||||
|
schema.create_table("users", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"username", types.text{unique = true, null = false}},
|
||||||
|
{"password_hash", types.text{null = false}},
|
||||||
|
{"permission", types.integer{default = 0}},
|
||||||
|
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"}
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.create_table("topics", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"name", types.text{null = false}},
|
||||||
|
{"slug", types.text{null = false, unique = true}}
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.create_table("threads", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"topic_id", "INTEGER REFERENCES topics(id) ON DELETE CASCADE"},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE SET NULL"},
|
||||||
|
{"title", types.text{null = false}},
|
||||||
|
{"slug", types.text{null = false, unique = true}},
|
||||||
|
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.create_table("posts", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE SET NULL"},
|
||||||
|
{"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
{"current_revision_id", "INTEGER REFERENCES post_history(id)"},
|
||||||
|
})
|
||||||
|
|
||||||
|
schema.create_table("post_history", {
|
||||||
|
{"id", types.integer{primary_key = true}},
|
||||||
|
{"post_id", "INTEGER REFERENCES posts(id) ON DELETE CASCADE"},
|
||||||
|
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||||
|
{"content", types.text{null = false}},
|
||||||
|
{"edited_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||||
|
{"is_initial_revision", "BOOLEAN DEFAULT FALSE"}
|
||||||
|
})
|
||||||
|
|
||||||
|
db.query("CREATE INDEX idx_threads_topic_id ON threads(topic_id)")
|
||||||
|
db.query("CREATE INDEX idx_posts_thread_id ON posts(thread_id)")
|
||||||
|
db.query("CREATE INDEX idx_post_history_post_id ON post_history(post_id)")
|
3
secrets/secrets.lua.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
return {
|
||||||
|
key = "PROD_SECRET_KEY_HERE",
|
||||||
|
}
|
37
start.sh
Executable file
@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
start() {
|
||||||
|
lapis migrate
|
||||||
|
lapis serve
|
||||||
|
}
|
||||||
|
|
||||||
|
first_launch() {
|
||||||
|
echo "Setting up for the first time"
|
||||||
|
mkdir -p secrets
|
||||||
|
local SECRET
|
||||||
|
SECRET="$(openssl rand -hex 32)"
|
||||||
|
echo "return { key = \"${SECRET}\",}" > secrets/secrets.lua
|
||||||
|
touch "secrets/.touched.$LAPIS_ENVIRONMENT"
|
||||||
|
mkdir -p data/db
|
||||||
|
luajit schema.lua
|
||||||
|
chmod -R a+rw data
|
||||||
|
lapis migrate
|
||||||
|
luajit create_default_accounts.lua
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
export LAPIS_ENVIRONMENT="development"
|
||||||
|
echo "WARN: no environment passed, assuming default (development)"
|
||||||
|
else
|
||||||
|
export LAPIS_ENVIRONMENT="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting in $LAPIS_ENVIRONMENT"
|
||||||
|
|
||||||
|
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
|
||||||
|
first_launch
|
||||||
|
fi
|
||||||
|
|
||||||
|
start
|
5
svg-icons/error.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
5
svg-icons/image.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
5
svg-icons/info.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
5
svg-icons/lock.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
5
svg-icons/sticky.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
5
svg-icons/warn.etlua
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
343
util.lua
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
local util = {}
|
||||||
|
local magick = require("magick")
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local constants = require("constants")
|
||||||
|
local string_trim = require("lapis.util").trim
|
||||||
|
|
||||||
|
local Avatars = require("models").Avatars
|
||||||
|
local Users = require("models").Users
|
||||||
|
local Posts = require("models").Posts
|
||||||
|
local PostHistory = require("models").PostHistory
|
||||||
|
local Threads = require("models").Threads
|
||||||
|
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
|
||||||
|
util.TransientUser = {
|
||||||
|
is_admin = function (self)
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_mod = function (self)
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_guest = function (self)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
is_system = function (self)
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_logged_in_guest = function (self)
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
is_logged_in = function (self)
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
username = "Deleted User",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PURE API
|
||||||
|
|
||||||
|
function util.get_user_avatar_url(req, user)
|
||||||
|
return Avatars:find(user.avatar_id).file_path
|
||||||
|
end
|
||||||
|
|
||||||
|
---split a string
|
||||||
|
---@param s string subject
|
||||||
|
---@param delimiter string? string to split by, can be empty to split by character
|
||||||
|
---@param max_matches integer? the maximum number of returned elements
|
||||||
|
---@param trim boolean? whether to trim whitespace off matches
|
||||||
|
---@param allow_empty boolean? should empty matches be in the resulting table
|
||||||
|
---@return string[]
|
||||||
|
function util.s_split(s, delimiter, max_matches, trim, allow_empty)
|
||||||
|
local result = {}
|
||||||
|
if s == "" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
trim = trim == nil and true or trim
|
||||||
|
local tr = function(subj)
|
||||||
|
if trim then return string_trim(subj) else return subj end
|
||||||
|
end
|
||||||
|
max_matches = max_matches or -1
|
||||||
|
allow_empty = allow_empty == nil and true or allow_empty
|
||||||
|
|
||||||
|
if delimiter == "" then
|
||||||
|
for i=1, #s do
|
||||||
|
local c = s:sub(i, 1)
|
||||||
|
if allow_empty or c ~= "" then
|
||||||
|
table.insert(result, c)
|
||||||
|
if max_matches > 0 and #result == max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_pos = 1
|
||||||
|
local delim_len = #delimiter
|
||||||
|
|
||||||
|
while true do
|
||||||
|
if max_matches > 0 and #result >= max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
---@diagnostic disable-next-line: param-type-mismatch
|
||||||
|
local start_pos, end_pos = s:find(delimiter, current_pos, true)
|
||||||
|
if not start_pos then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local substr = s:sub(current_pos, start_pos - 1)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
current_pos = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local substr = s:sub(current_pos)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.split_sentences(sentences, max_sentences)
|
||||||
|
return util.s_split(sentences, ".", max_sentences or 2, true, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
function util.get_post_url(req, post_id, hash)
|
||||||
|
hash = hash ~= false
|
||||||
|
local post = Posts:find({id = post_id})
|
||||||
|
if not post then return "" end
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
if not thread then return "" end
|
||||||
|
|
||||||
|
local url = req:url_for("thread", {slug = thread.slug}, {after = post_id})
|
||||||
|
if not hash then return url end
|
||||||
|
return url .. "#post-" .. post_id
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.infobox_message(msg)
|
||||||
|
local sentences = util.split_sentences(msg)
|
||||||
|
if #sentences == 1 then
|
||||||
|
return "<b>" .. sentences[1] .. ". " .. "</b>"
|
||||||
|
end
|
||||||
|
return "<span><b>" .. sentences[1] .. ". " .. "</b> " .. sentences[2] .. ".</span>"
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.get_logged_in_user(req)
|
||||||
|
if req.session.session_key == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
|
||||||
|
if #session > 0 then
|
||||||
|
return Users:find({id = session[1].user_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.get_logged_in_user_or_transient(req)
|
||||||
|
return util.get_logged_in_user(req) or util.TransientUser
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.ntob(v)
|
||||||
|
return v ~= 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.bton(b)
|
||||||
|
return 1 and b or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.stob(s)
|
||||||
|
return s == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.form_bool_to_sqlite(s)
|
||||||
|
return util.bton(util.stob(s))
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.is_thread_locked(thread)
|
||||||
|
return util.ntob(thread.is_locked)
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.is_topic_locked(topic)
|
||||||
|
return util.ntob(topic.is_locked)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- OTHER API
|
||||||
|
|
||||||
|
function util.extend_session_cookie(req)
|
||||||
|
req.session.last_activity = os.time()
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.validate_and_create_image(input_image, filename)
|
||||||
|
local img = magick.load_image_from_blob(input_image)
|
||||||
|
|
||||||
|
if not img then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
img:strip()
|
||||||
|
img:set_gravity("CenterGravity")
|
||||||
|
|
||||||
|
local width, height = img:get_width(), img:get_height()
|
||||||
|
local min_dim = math.min(width, height)
|
||||||
|
if min_dim > 256 then
|
||||||
|
local ratio = 256.0 / min_dim
|
||||||
|
local new_w, new_h = width * ratio, height * ratio
|
||||||
|
img:resize(new_w, new_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
width, height = img:get_width(), img:get_height()
|
||||||
|
local crop_size = math.min(width, height)
|
||||||
|
local x_offset = (width - crop_size) / 2
|
||||||
|
local y_offset = (height - crop_size) / 2
|
||||||
|
img:crop(crop_size, crop_size, x_offset, y_offset)
|
||||||
|
|
||||||
|
img:set_format("webp")
|
||||||
|
img:set_quality(85)
|
||||||
|
|
||||||
|
img:write(filename)
|
||||||
|
img:destroy()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.destroy_avatar(avatar_id)
|
||||||
|
if avatar_id == 1 then
|
||||||
|
print("won't delete default avatar")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local avatar = Avatars:find(avatar_id)
|
||||||
|
|
||||||
|
if not avatar then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local file_path = "data/static" .. avatar.file_path
|
||||||
|
local f = io.open(file_path, "r")
|
||||||
|
if not f then
|
||||||
|
print("can't open avatar file")
|
||||||
|
else
|
||||||
|
f:close()
|
||||||
|
os.remove(file_path)
|
||||||
|
avatar:delete()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.create_post(thread_id, user_id, content, markup_language)
|
||||||
|
markup_language = markup_language or "babycode"
|
||||||
|
db.query("BEGIN")
|
||||||
|
local post = Posts:create({
|
||||||
|
thread_id = thread_id,
|
||||||
|
user_id = user_id,
|
||||||
|
current_revision_id = db.NULL,
|
||||||
|
})
|
||||||
|
|
||||||
|
local parsed_content = ""
|
||||||
|
if markup_language == "babycode" then
|
||||||
|
parsed_content = babycode.to_html(content, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
|
local revision = PostHistory:create({
|
||||||
|
post_id = post.id,
|
||||||
|
content = parsed_content,
|
||||||
|
is_initial_revision = true,
|
||||||
|
original_markup = content,
|
||||||
|
markup_language = "babycode",
|
||||||
|
})
|
||||||
|
|
||||||
|
post:update({current_revision_id = revision.id})
|
||||||
|
|
||||||
|
db.query("COMMIT")
|
||||||
|
return post
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.update_post(post, new_content, markup_language)
|
||||||
|
markup_language = markup_language or "babycode"
|
||||||
|
db.query("BEGIN")
|
||||||
|
|
||||||
|
local parsed_content = ""
|
||||||
|
if markup_language == "babycode" then
|
||||||
|
parsed_content = babycode.to_html(new_content, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
|
local revision = PostHistory:create({
|
||||||
|
post_id = post.id,
|
||||||
|
content = parsed_content,
|
||||||
|
is_initial_revision = false,
|
||||||
|
original_markup = new_content,
|
||||||
|
markup_language = markup_language
|
||||||
|
})
|
||||||
|
|
||||||
|
post:update({current_revision_id = revision.id})
|
||||||
|
db.query("COMMIT")
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.transfer_and_delete_user(user)
|
||||||
|
local deleted_user = Users:find({
|
||||||
|
username = "DeletedUser",
|
||||||
|
})
|
||||||
|
-- this needs to be atomic
|
||||||
|
db.query("BEGIN")
|
||||||
|
db.query('UPDATE "threads" SET "user_id" = ? WHERE "user_id" = ?', deleted_user.id, user.id)
|
||||||
|
db.query('UPDATE "posts" SET "user_id" = ? WHERE "user_id" = ?', deleted_user.id, user.id)
|
||||||
|
user:delete() -- uncomment later
|
||||||
|
db.query("COMMIT")
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.pop_infobox(req)
|
||||||
|
if not req.session.infobox then return end
|
||||||
|
req.infobox = req.session.infobox
|
||||||
|
req.session.infobox = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.inject_infobox(req, message, kind)
|
||||||
|
kind = kind or constants.InfoboxKind.INFO
|
||||||
|
local ib = {
|
||||||
|
msg = message,
|
||||||
|
kind = kind,
|
||||||
|
}
|
||||||
|
req.session.infobox = ib
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.inject_err_infobox(req, message)
|
||||||
|
local ib = {
|
||||||
|
msg = message,
|
||||||
|
kind = constants.InfoboxKind.ERROR,
|
||||||
|
}
|
||||||
|
req.session.infobox = ib
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.inject_warn_infobox(req, message)
|
||||||
|
local ib = {
|
||||||
|
msg = message,
|
||||||
|
kind = constants.InfoboxKind.WARN,
|
||||||
|
}
|
||||||
|
req.session.infobox = ib
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.rate_limit_allowed(user_id, method, seconds)
|
||||||
|
local last_call = db.query([[
|
||||||
|
SELECT logged_at FROM api_rate_limits
|
||||||
|
WHERE user_id = ? AND method = ?
|
||||||
|
ORDER BY logged_at DESC LIMIT 1
|
||||||
|
]], user_id, method)
|
||||||
|
if #last_call == 0 or (os.time() - last_call[1].logged_at) >= seconds then
|
||||||
|
db.query(
|
||||||
|
"DELETE FROM api_rate_limits WHERE user_id = ? AND method = ?",
|
||||||
|
user_id, method
|
||||||
|
)
|
||||||
|
db.query(
|
||||||
|
"INSERT INTO api_rate_limits (user_id, method) VALUES (?, ?)",
|
||||||
|
user_id, method
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return util
|
192
views/babycode.etlua
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<div class=darkbg>
|
||||||
|
<h1 class="thread-title">Babycode guide</h1>
|
||||||
|
</div>
|
||||||
|
<% local tocs = {} %>
|
||||||
|
<div class="babycode-guide-container">
|
||||||
|
<div class="guide-topics">
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="what-is-babycode">What is babycode?</h2>
|
||||||
|
<% table.insert(tocs, {"What is babycode?", "what-is-babycode"}) %>
|
||||||
|
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="text-formatting-tags">Text formatting tags</h2>
|
||||||
|
<% table.insert(tocs, {"Text formatting tags", "text-formatting-tags"}) %>
|
||||||
|
<ul>
|
||||||
|
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
|
||||||
|
[b]Hello World[/b]<br>
|
||||||
|
Will become<br>
|
||||||
|
<strong>Hello World</strong>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
|
||||||
|
[i]Hello World[/i]<br>
|
||||||
|
Will become<br>
|
||||||
|
<em>Hello World</em>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
|
||||||
|
[s]Hello World[/s]<br>
|
||||||
|
Will become<br>
|
||||||
|
<del>Hello World</del>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="emoji">Emotes</h2>
|
||||||
|
<% table.insert(tocs, {"Emotes", "emoji"}) %>
|
||||||
|
<p>There are a few emoji in the style of old forum emotes:</p>
|
||||||
|
<% --[[ we'll pretend like i will totally refactor emojis and generate this table dynamically in the future. clown emoji ]]%>
|
||||||
|
<table class="emoji-table">
|
||||||
|
<tr>
|
||||||
|
<th>Short code(s)</th>
|
||||||
|
<th>Emoji result</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:angry:</td>
|
||||||
|
<td><img class=emoji src="/emoji/angry.png" alt="angry" title=":angry:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:(:</td>
|
||||||
|
<td><img class=emoji src="/emoji/frown.png" alt="frown" title=":(:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:D:</td>
|
||||||
|
<td><img class=emoji src="/emoji/grin.png" alt="grin" title=":D:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:imp:</td>
|
||||||
|
<td><img class=emoji src="/emoji/imp.png" alt="imp" title=":imp:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:impangry: :angryimp:</td>
|
||||||
|
<td><img class=emoji src="/emoji/impangry.png" alt="impangry" title=":impangry:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:lobster:</td>
|
||||||
|
<td><img class=emoji src="/emoji/lobster.png" alt="lobster" title=":lobster:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:|:</td>
|
||||||
|
<td><img class=emoji src="/emoji/neutral.png" alt="neutral" title=":|:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:pensive:</td>
|
||||||
|
<td><img class=emoji src="/emoji/pensive.png" alt="pensive" title=":pensive:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:):</td>
|
||||||
|
<td><img class=emoji src="/emoji/smile.png" alt="smile" title=":):"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:smiletear: :crytear:</td>
|
||||||
|
<td><img class=emoji src="/emoji/smiletear.png" alt="smiletear" title=":smiletear:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:,: :T: :cry: :sob:</td>
|
||||||
|
<td><img class=emoji src="/emoji/sob.png" alt="sob" title=":sob:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:o: :O:</td>
|
||||||
|
<td><img class=emoji src="/emoji/surprised.png" alt="surprised" title=":o:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:hmm: :think: :thinking:</td>
|
||||||
|
<td><img class=emoji src="/emoji/think.png" alt="think" title=":think:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:P: :p:</td>
|
||||||
|
<td><img class=emoji src="/emoji/tongue.png" alt="tongue" title=":p:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:weary:</td>
|
||||||
|
<td><img class=emoji src="/emoji/weary.png" alt="weary" title=":weary:"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>:;: :wink:</td>
|
||||||
|
<td><img class=emoji src="/emoji/wink.png" alt="wink" title=":wink:"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="paragraph-rules">Paragraph rules</h2>
|
||||||
|
<% table.insert(tocs, {"Paragraph rules", "paragraph-rules"}) %>
|
||||||
|
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
|
||||||
|
|
||||||
|
paragraph 2">Copy</button></span><code>paragraph 1
|
||||||
|
|
||||||
|
paragraph 2</code></pre>
|
||||||
|
Will produce:<br>
|
||||||
|
paragraph 1<br><br>paragraph 2
|
||||||
|
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
|
||||||
|
still paragraph 1">Copy</button></span><code>paragraph 1
|
||||||
|
still paragraph 1</code></pre>
|
||||||
|
That will produce:<br>
|
||||||
|
paragraph 1<br>still paragraph 1
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="links">Links</h2>
|
||||||
|
<% table.insert(tocs, {"Links", "links"}) %>
|
||||||
|
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
|
||||||
|
<a href="https://example.com">Link label</a></p>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="attaching-an-image">Attaching an image</h2>
|
||||||
|
<% table.insert(tocs, {"Attaching an image", "attaching-an-image"}) %>
|
||||||
|
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
|
||||||
|
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Lua logo with a cowboy hat[/img]</code>
|
||||||
|
<div class="post-img-container"><img class="block-img" src="/avatars/default.webp" alt="the Lua logo with a cowboy hat"></div></p>
|
||||||
|
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="adding-code-blocks">Adding code blocks</h2>
|
||||||
|
<% table.insert(tocs, {"Adding code blocks", "adding-code-blocks"}) %>
|
||||||
|
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
|
||||||
|
<% local code = 'func _ready() -> void:\n\tprint("hello world!")' %>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="<%= code %>">Copy</button></span><code><%= code %></code></pre>
|
||||||
|
<p>Babycodes are not parsed inside code blocks.</p>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="quoting">Quoting</h2>
|
||||||
|
<% table.insert(tocs, {"Quoting", "quoting"}) %>
|
||||||
|
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
|
||||||
|
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
|
||||||
|
</section>
|
||||||
|
<section class="babycode-guide-section">
|
||||||
|
<h2 id="lists">Lists</h2>
|
||||||
|
<% table.insert(tocs, {"Lists", "lists"}) %>
|
||||||
|
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="[ul]
|
||||||
|
item 1
|
||||||
|
|
||||||
|
item 2
|
||||||
|
|
||||||
|
item 3
|
||||||
|
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
|
||||||
|
[/ul]">Copy</button></span><code>[ul]
|
||||||
|
item 1
|
||||||
|
|
||||||
|
item 2
|
||||||
|
|
||||||
|
item 3
|
||||||
|
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
|
||||||
|
[/ul]</code></pre><br>
|
||||||
|
Will produce the following list:
|
||||||
|
<ul>
|
||||||
|
<li>item 1</li>
|
||||||
|
<li>item 2</li>
|
||||||
|
<li>item 3<br>still item 3 (break line without inserting a new item by using two spaces at the end of a line)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="guide-toc">
|
||||||
|
<h2>Table of contents</h2>
|
||||||
|
<ul>
|
||||||
|
<% for _, t in ipairs(tocs) do %>
|
||||||
|
<li><a href="#<%= t[2] %>"><%= t[1] %></a></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
views/base.etlua
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<% if page_title then %>
|
||||||
|
<title>Porom - <%= page_title %></title>
|
||||||
|
<% else %>
|
||||||
|
<title>Porom</title>
|
||||||
|
<% end %>
|
||||||
|
<link rel="stylesheet" href="<%= "/static/style.css?v=" .. __cachebust %>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% render("views.common.topnav") -%>
|
||||||
|
<% content_for("inner") %>
|
||||||
|
<footer class="darkbg">
|
||||||
|
<span>Porom commit <a href="<%= "https://git.poto.cafe/yagich/porom/commit/" .. __commit %>"><%= __commit %></a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
<script src="/static/js/copy-code.js"></script>
|
||||||
|
<script src="/static/js/date-fmt.js"></script>
|
||||||
|
<script src="/static/js/ui.js?v=2"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
25
views/common/babycode-editor-component.etlua
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<div class="babycode-editor-container">
|
||||||
|
<div class="tab-buttons">
|
||||||
|
<button type=button class="tab-button active" data-target-id="tab-edit">Write</button>
|
||||||
|
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content active" id="tab-edit">
|
||||||
|
<span class="babycode-button-container">
|
||||||
|
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code></></code></button>
|
||||||
|
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><% render("svg-icons.image") %></button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
|
||||||
|
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">•</button>
|
||||||
|
</span>
|
||||||
|
<textarea class="babycode-editor" name="<%= ta_name %>" id="babycode-content" placeholder="<%= ta_placeholder or "Post body"%>" <%= not optional and "required" or "" %>><%- prefill or "" %></textarea>
|
||||||
|
<a href="<%= url_for("babycode_guide") %>" target="_blank">babycode guide</a>
|
||||||
|
</div>
|
||||||
|
<div class="tab-content" id="tab-preview">
|
||||||
|
<div id="babycode-preview-errors-container">Type something!</div>
|
||||||
|
<div id="babycode-preview-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/babycode-editor.js?v=1"></script>
|
21
views/common/babycode-editor.etlua
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<%
|
||||||
|
local save_button_text = "Post reply"
|
||||||
|
if cancel_url then
|
||||||
|
save_button_text = "Save"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<form class="post-edit-form" method="post" action="<%= url or "" %>">
|
||||||
|
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
|
||||||
|
<% if not cancel_url then %>
|
||||||
|
<span>
|
||||||
|
<input type="checkbox" id="subscribe" name="subscribe" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||||
|
<label for="subscribe">Subscribe to thread</label>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<span>
|
||||||
|
<input type=submit value="<%= save_button_text %>">
|
||||||
|
<% if cancel_url then %>
|
||||||
|
<a class="linkbutton warn" href="<%= cancel_url %>">Cancel</a>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</form>
|
14
views/common/infobox.etlua
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<%
|
||||||
|
local class = "infobox " .. constants.InfoboxHTMLClass[kind]
|
||||||
|
local icon = constants.InfoboxIcons[kind]
|
||||||
|
local sentences = infobox_message(msg)
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="<%= class %>">
|
||||||
|
<span>
|
||||||
|
<div class="infobox-icon-container">
|
||||||
|
<% render(icon) %>
|
||||||
|
</div>
|
||||||
|
<%- sentences %>
|
||||||
|
</span>
|
||||||
|
</div>
|
27
views/common/pagination.etlua
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<% local left_start = math.max(1, current_page - 5) %>
|
||||||
|
<% local right_end = math.min(page_count, current_page + 5) %>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<span>Page:</span>
|
||||||
|
<% if current_page > 5 then %>
|
||||||
|
<a href="?page=1" class="pagebutton">1</a>
|
||||||
|
<% if left_start > 2 then %>
|
||||||
|
<span class="currentpage">…</span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% for i = left_start, current_page - 1 do%>
|
||||||
|
<a href="?page=<%= i %>" class="pagebutton"><%= i %></a>
|
||||||
|
<% end %>
|
||||||
|
<% if page_count > 0 then %>
|
||||||
|
<span class="currentpage"><%= current_page %></span>
|
||||||
|
<% end %>
|
||||||
|
<% for i = current_page + 1, right_end do %>
|
||||||
|
<a href="?page=<%= i %>" class="pagebutton"><%= i %></a>
|
||||||
|
<% end %>
|
||||||
|
<% if right_end < page_count then %>
|
||||||
|
<% if right_end < page_count - 1 then %>
|
||||||
|
<span class="currentpage">…</span>
|
||||||
|
<% end %>
|
||||||
|
<a href="?page=<%= page_count %>" class="pagebutton"><%= page_count %></a>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
1
views/common/timestamp.etlua
Normal file
@ -0,0 +1 @@
|
|||||||
|
<span class="timestamp" data-utc="<%= timestamp %>"><%= os.date("%c", timestamp) %></span>
|
22
views/common/topnav.etlua
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<nav id="topnav">
|
||||||
|
<span>
|
||||||
|
<% local topics_url = url_for("all_topics") %>
|
||||||
|
<a class="site-title" href="<%= topics_url %>">Porom</a>
|
||||||
|
<a href="<%= topics_url %>">All topics</a>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<% if me and me:is_logged_in() then -%>
|
||||||
|
Welcome, <a href="<%= url_for("user", {username = me.username}) %>"><%= me.username %></a>
|
||||||
|
•
|
||||||
|
<a href="<%= url_for("user_settings", {username = me.username}) %>">Settings</a>
|
||||||
|
•
|
||||||
|
<a href="<%= url_for("user_inbox", {username = me.username}) %>">Inbox</a>
|
||||||
|
<% if me:is_mod() then %>
|
||||||
|
•
|
||||||
|
<a href="<%= url_for("user_list") %>">User list</a>
|
||||||
|
<% end %>
|
||||||
|
<% else -%>
|
||||||
|
Welcome, guest. Please <a href="<%= url_for("user_signup") %>">sign up</a> or <a href="<%= url_for("user_login") %>">log in</a>
|
||||||
|
<% end -%>
|
||||||
|
</span>
|
||||||
|
</nav>
|
19
views/mod/sort-topics.etlua
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<h1>Change topics order</h1>
|
||||||
|
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
|
||||||
|
<form method="post" id=topics-container>
|
||||||
|
<% for _, topic in ipairs(topics) do %>
|
||||||
|
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
|
||||||
|
<div class="thread-title"><%= topic.name %></div>
|
||||||
|
<div><%= topic.description %></div>
|
||||||
|
<input type="hidden" name="<%= topic.id %>" value="<%= topic.sort_order %>" class="topic-input">
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<input type=submit value="Save order">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/sort-topics.js"></script>
|
8
views/mod/user-list.etlua
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>All users</h1>
|
||||||
|
<ul>
|
||||||
|
<% for _, user in ipairs(users) do %>
|
||||||
|
<li><a href="<%= url_for("user", {username = user.username}) %>"><%= user.username %></a></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
17
views/post/edit-post.etlua
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<% for i = #prev_context, 1, -1 do %>
|
||||||
|
<% local post = prev_context[i] %>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<% end %>
|
||||||
|
<span class="context-explain">
|
||||||
|
<span>↑↑↑</span><i>Context</i><span>↑↑↑</span>
|
||||||
|
</span>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<% render("views.threads.post", {post = editing_post, edit = true, is_latest = false, no_reply = true}) %>
|
||||||
|
<span class="context-explain">
|
||||||
|
<span>↓↓↓</span><i>Context</i><span>↓↓↓</span>
|
||||||
|
</span>
|
||||||
|
<% for _, post in ipairs(next_context) do %>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<% end %>
|
9
views/post/single-post.etlua
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<% if not post then %>
|
||||||
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.ERROR, msg = "Post not found"}) %>
|
||||||
|
<% else %>
|
||||||
|
<div class=darkbg>
|
||||||
|
<h1 class=thread-title><%= post.username .. "'s post in " .. thread.title %></h1>
|
||||||
|
</div>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<a class=linkbutton href="<%= url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id %>">View in context</a>
|
||||||
|
<% end %>
|
16
views/threads/create.etlua
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>New thread</h1>
|
||||||
|
<form method="post">
|
||||||
|
<label for="topic_id">Topic</label>
|
||||||
|
<select name="topic_id", id="topic_id" autocomplete="off">
|
||||||
|
<% for _, topic in ipairs(all_topics) do %>
|
||||||
|
<option value="<%= topic.id %>" <%- params.topic_id == tostring(topic.id) and "selected" or "" %>><%= topic.name %></value>
|
||||||
|
<% end %>
|
||||||
|
</select><br>
|
||||||
|
<label for="title">Thread title</label>
|
||||||
|
<input type="text" id="title" name="title" placeholder="Required" required>
|
||||||
|
<label for="initial_post">Post body</label><br>
|
||||||
|
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %>
|
||||||
|
<input type="submit" value="Create thread">
|
||||||
|
</form>
|
||||||
|
</div>
|
10
views/threads/new-post-notification.etlua
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<div id="new-post-notification" class="new-concept-notification hidden">
|
||||||
|
<div class="new-notification-content">
|
||||||
|
<p>New post in thread!</p>
|
||||||
|
<span class="notification-buttons">
|
||||||
|
<button id="dismiss-new-post-button">Dismiss</button>
|
||||||
|
<a class="linkbutton" id="go-to-new-post-button">View post</a>
|
||||||
|
<button id="unsub-new-post-button">Stop updates</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
85
views/threads/post.etlua
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<%
|
||||||
|
local pc = "post"
|
||||||
|
if edit then
|
||||||
|
pc = pc .. " editing"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<div class="<%= pc %>" id="post-<%= post.id %>">
|
||||||
|
<div class="usercard">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
|
||||||
|
<img src="<%= post.avatar_path %>" class="avatar">
|
||||||
|
</a>
|
||||||
|
<a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a>
|
||||||
|
<% if post.status ~= "" then %>
|
||||||
|
<em class="user-status"><%= post.status %></em>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
|
||||||
|
<div class="post-info">
|
||||||
|
<%
|
||||||
|
--local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||||
|
local post_url = get_post_url(post.id)
|
||||||
|
%>
|
||||||
|
<a href="<%= post_url %>" title="Permalink"><i>
|
||||||
|
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||||
|
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
|
||||||
|
<% else -%>
|
||||||
|
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
|
||||||
|
<% end -%>
|
||||||
|
</i></a>
|
||||||
|
<span>
|
||||||
|
<%
|
||||||
|
local show_edit = me.id == post.user_id and not me:is_guest() and (not ntob(thread.is_locked) or me:is_mod()) and not no_reply
|
||||||
|
if show_edit then
|
||||||
|
%>
|
||||||
|
<a class="linkbutton" href="<%= url_for("edit_post", {post_id = post.id}) .. "#babycode-content" %>">Edit</a>
|
||||||
|
<% end %>
|
||||||
|
<%
|
||||||
|
local show_reply = true
|
||||||
|
if ntob(thread.is_locked) and not me:is_mod() then
|
||||||
|
show_reply = false
|
||||||
|
elseif me:is_guest() then
|
||||||
|
show_reply = false
|
||||||
|
elseif edit then
|
||||||
|
show_reply = false
|
||||||
|
elseif no_reply then
|
||||||
|
show_reply = false
|
||||||
|
end
|
||||||
|
if show_reply then
|
||||||
|
local d = post.created_at < post.edited_at and post.edited_at or post.created_at
|
||||||
|
local quote_src_text = ("[url=%s]%s said:[/url]"):format(
|
||||||
|
post_url, post.username
|
||||||
|
)
|
||||||
|
local reply_text = ("%s\n[quote]%s[/quote]\n"):format(quote_src_text, post.original_markup)
|
||||||
|
%>
|
||||||
|
<button value="<%= reply_text %>" class="reply-button">Quote</button>
|
||||||
|
<% end %>
|
||||||
|
<%
|
||||||
|
local show_delete = ((post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()) and not no_reply
|
||||||
|
if show_delete then
|
||||||
|
%>
|
||||||
|
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="post-content">
|
||||||
|
<% if not edit then %>
|
||||||
|
<div class="post-inner"><%- post.content %></div>
|
||||||
|
<% if render_sig and #post.signature_rendered > 0 then %>
|
||||||
|
<div class="signature-container">
|
||||||
|
<hr>
|
||||||
|
<%- post.signature_rendered %></div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<% render("views.common.babycode-editor", {
|
||||||
|
cancel_url = url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id,
|
||||||
|
prefill = post.original_markup,
|
||||||
|
ta_name = "new_content"
|
||||||
|
}) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
76
views/threads/thread.etlua
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<%
|
||||||
|
local is_locked = ntob(thread.is_locked)
|
||||||
|
local is_stickied = ntob(thread.is_stickied)
|
||||||
|
local can_post = (not is_locked and not me:is_guest()) or me:is_mod()
|
||||||
|
local can_lock = me.id == thread.user_id or me:is_mod()
|
||||||
|
%>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<main>
|
||||||
|
<nav class="darkbg">
|
||||||
|
<h1 class="thread-title"><%= thread.title %></h1>
|
||||||
|
<span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a>
|
||||||
|
<% if is_stickied then %> • <i>stickied, so it's probably important</i>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<% if me:is_logged_in() then %>
|
||||||
|
<form class="modform" action="<%= url_for("thread_subscribe", {slug = thread.slug}) %>" method="post">
|
||||||
|
<input type="hidden" name="last_visible_post" value=<%= posts[#posts].id %>>
|
||||||
|
<input type="hidden" name="subscribe" value=<%= is_subscribed and "unsubscribe" or "subscribe" %>>
|
||||||
|
<input type="submit" value="<%= is_subscribed and "Unsubscribe" or "Subscribe" %>">
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
<% if can_lock then %>
|
||||||
|
<form class="modform" action="<%= url_for("thread_lock", {slug = thread.slug}) %>" method="post">
|
||||||
|
<input type=hidden value="<%= not is_locked %>" name="target_op">
|
||||||
|
<input class="warn" type="submit" value="<%= is_locked and "Unlock thread" or "Lock thread" %>">
|
||||||
|
</form>
|
||||||
|
<% if me:is_mod() then %>
|
||||||
|
<form class="modform" action="<%= url_for("thread_sticky", {slug = thread.slug}) %>" method="post">
|
||||||
|
<input type=hidden value="<%= not is_stickied %>" name="target_op">
|
||||||
|
<input class="warn" type="submit" value="<%= is_stickied and "Unsticky thread" or "Sticky thread" %>">
|
||||||
|
</form>
|
||||||
|
<form class="modform" action="<%= url_for("thread_move", {slug = thread.slug}) %>" method="post">
|
||||||
|
<label for="new_topic_id">Move to topic:</label>
|
||||||
|
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
|
||||||
|
<% for _, topic in ipairs(other_topics) do %>
|
||||||
|
<option value="<%= topic.id %>" <%- thread.topic_id == topic.id and "selected disabled" or "" %>><%= topic.name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<input class="warn" type="submit" value="Move thread">
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<% for i, post in ipairs(posts) do %>
|
||||||
|
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
|
||||||
|
<% end %>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav id="bottomnav">
|
||||||
|
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if is_locked then -%>
|
||||||
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
|
||||||
|
<% end -%>
|
||||||
|
<% if can_post then %>
|
||||||
|
<h1>Respond to "<%= thread.title %>"</h1>
|
||||||
|
<% render("views.common.babycode-editor", {ta_name="post_content"}) %>
|
||||||
|
<% end %>
|
||||||
|
<dialog id="delete-dialog">
|
||||||
|
<div class=delete-dialog-inner>
|
||||||
|
Are you sure you want to delete the highlighted post?
|
||||||
|
<span>
|
||||||
|
<button id=post-delete-dialog-close>Cancel</button>
|
||||||
|
<button class="critical" form=post-delete-form>Delete</button>
|
||||||
|
<form id="post-delete-form" method="post"></form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<input type="hidden" id="thread-subscribe-endpoint" value="<%= url_for("api_get_thread_updates", {thread_id = thread.id}, {since = os.time()}) %>">
|
||||||
|
<% render("views.threads.new-post-notification") %>
|
||||||
|
<script src="/static/js/thread.js?v=1"></script>
|
10
views/topics/create.etlua
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>Create topic</h1>
|
||||||
|
<form method="post">
|
||||||
|
<label for=name>Name</label>
|
||||||
|
<input type="text" name="name" id="name" required><br>
|
||||||
|
<label for=description>Description</label>
|
||||||
|
<textarea id="description" name="description" required rows=5></textarea><br>
|
||||||
|
<input type="submit" value="Create topic">
|
||||||
|
</form>
|
||||||
|
</div>
|
12
views/topics/edit.etlua
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>Editing topic <%= topic.name %></h1>
|
||||||
|
<form method="post">
|
||||||
|
<label for=name>Name</label>
|
||||||
|
<input type="text" name="name" id="name" value="<%= topic.name %>" placeholder="Topic name" required>
|
||||||
|
<label for=description>Description</label>
|
||||||
|
<textarea id="description" name="description" placeholder="Topic description" rows=4><%= topic.description %></textarea>
|
||||||
|
<input type="submit" value="Save changes">
|
||||||
|
<a class="linkbutton" href="<%= url_for("topic", {slug = topic.slug}) %>">Cancel</a><br>
|
||||||
|
<i>Note: to preserve history, you cannot change the topic URL.</i>
|
||||||
|
</form>
|
||||||
|
</div>
|
85
views/topics/topic.etlua
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% local is_locked = ntob(topic.is_locked) %>
|
||||||
|
|
||||||
|
<nav class="darkbg">
|
||||||
|
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
|
||||||
|
<span><%= topic.description %></span>
|
||||||
|
<div>
|
||||||
|
<% if thread_create_error == ThreadCreateError.OK then %>
|
||||||
|
<a class="linkbutton" href=<%= url_for("thread_create", nil, {topic_id = topic.id}) %>>New thread</a>
|
||||||
|
<% elseif thread_create_error == ThreadCreateError.GUEST then %>
|
||||||
|
<p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
|
||||||
|
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %>
|
||||||
|
<p>Only logged in users can create threads. <a href="<%= url_for("user_signup") %>">Sign up</a> or <a href="<%= url_for("user_login")%>">log in</a> to create a thread.</p>
|
||||||
|
<% end %>
|
||||||
|
<% if me:is_mod() then %>
|
||||||
|
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
|
||||||
|
<input type="hidden" name="is_locked" value="<%= not is_locked %>">
|
||||||
|
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>">
|
||||||
|
</form>
|
||||||
|
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if is_locked then -%>
|
||||||
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This topic is locked. Only moderators can create new threads."}) %>
|
||||||
|
<% end -%>
|
||||||
|
|
||||||
|
<% if #threads_list == 0 then %>
|
||||||
|
<p>There are no threads in this topic.</p>
|
||||||
|
<% else %>
|
||||||
|
<% for _, thread in ipairs(threads_list) do %>
|
||||||
|
<% local is_stickied = ntob(thread.is_stickied) %>
|
||||||
|
<% local thread_is_locked = ntob(thread.is_locked) %>
|
||||||
|
<div class="thread">
|
||||||
|
<div class="thread-sticky-container contain-svg">
|
||||||
|
<% if is_stickied then -%>
|
||||||
|
<% render("svg-icons.sticky") %>
|
||||||
|
<i>Stickied</i>
|
||||||
|
<% end -%>
|
||||||
|
</div>
|
||||||
|
<div class="thread-info-container">
|
||||||
|
<span>
|
||||||
|
<span class="thread-title"><a href="<%= url_for("thread", {slug = thread.slug}) %>"><%= thread.title %></a></span>
|
||||||
|
•
|
||||||
|
Started by <a href=<%= url_for("user", {username = thread.started_by}) %>><%= thread.started_by %></a>
|
||||||
|
on <% render("views.common.timestamp", {timestamp = thread.created_at}) -%>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Latest post by <a href="<%= url_for("user", {username = thread.latest_post_username}) %>"><%= thread.latest_post_username %></a>
|
||||||
|
<a href="<%= url_for("thread", {slug = thread.slug}, {after = thread.latest_post_id}) .. "#post-" .. thread.latest_post_id %>">on <% render("views.common.timestamp", {timestamp = thread.latest_post_created_at}) -%></a>:
|
||||||
|
</span>
|
||||||
|
<span class="thread-info-post-preview">
|
||||||
|
<%- thread.latest_post_content %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="thread-locked-container contain-svg">
|
||||||
|
<% if thread_is_locked then -%>
|
||||||
|
<% render("svg-icons.lock") %>
|
||||||
|
<i>Locked</i>
|
||||||
|
<% end -%>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<nav id="bottomnav">
|
||||||
|
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
|
||||||
|
</nav>
|
||||||
|
<dialog id="delete-dialog">
|
||||||
|
<div class=delete-dialog-inner>
|
||||||
|
Are you sure you want to delete this topic?
|
||||||
|
<span>
|
||||||
|
<button id=topic-delete-dialog-close>Cancel</button>
|
||||||
|
<button class="critical" form=topic-delete-form>Delete</button>
|
||||||
|
<form id="topic-delete-form" method="post" action="<%= url_for("topic_delete", {slug = topic.slug}) %>"></form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/js/topic.js"></script>
|
44
views/topics/topics.etlua
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<nav class="darkbg">
|
||||||
|
<h1 class="thread-title">All topics</h1>
|
||||||
|
<% if me:is_mod() then %>
|
||||||
|
<a class="linkbutton" href="<%= url_for("topic_create") %>">Create new topic</a>
|
||||||
|
<a class="linkbutton" href="<%= url_for("sort_topics") %>">Sort topics</a>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if #topic_list == 0 then %>
|
||||||
|
<p>There are no topics.</p>
|
||||||
|
<% else %>
|
||||||
|
<% for _, topic in ipairs(topic_list) do %>
|
||||||
|
<% local is_locked = ntob(topic.is_locked) %>
|
||||||
|
<div class="topic">
|
||||||
|
<div class="topic-info-container">
|
||||||
|
<a class="thread-title" href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
|
||||||
|
<%= topic.description %>
|
||||||
|
<% if topic.latest_thread_username then %>
|
||||||
|
<span>
|
||||||
|
Latest thread: <a href="<%= url_for("thread", {slug = topic.latest_thread_slug}) %>"><%= topic.latest_thread_title %></a> by <a href="<%= url_for("user", {username = topic.latest_thread_username}) %>"><%= topic.latest_thread_username %></a> on <% render("views.common.timestamp", {timestamp = topic.latest_thread_created_at}) -%>
|
||||||
|
</span>
|
||||||
|
<% if active_threads[topic.id] then %>
|
||||||
|
<% local thread = active_threads[topic.id] %>
|
||||||
|
<span>
|
||||||
|
Latest post in: <a href="<%= url_for("thread", {slug = thread.thread_slug}) %>"><%= thread.thread_title %></a> by <a href="<%= url_for("user", {username = thread.username}) %>"><%= thread.username %></a> at <a href="<%= get_post_url(thread.post_id) %>"><% render("views.common.timestamp", {timestamp = thread.post_created_at}) -%></a>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<i>No threads yet.</i>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="topic-locked-container contain-svg">
|
||||||
|
<% if is_locked then -%>
|
||||||
|
<% render("svg-icons.lock") %>
|
||||||
|
<i>Locked</i>
|
||||||
|
<% end -%>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
14
views/user/delete_confirm.etlua
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>Are you sure you want to delete your account, <%= me.username %>?</h1>
|
||||||
|
<p>This cannot be undone. This will not delete your posts, only anonymize them.</p>
|
||||||
|
<p>If you are sure, please type your password below.</p>
|
||||||
|
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<form method="post" action="<%= url_for("user_delete", {username = me.username}) %>">
|
||||||
|
<input type="password" name="password" id="password" autocomplete="current-password" placeholder="Password" required><br>
|
||||||
|
<input class="critical" type="submit" value="Delete my account (NO UNDO)">
|
||||||
|
</form>
|
||||||
|
</div>
|
50
views/user/inbox.etlua
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<div class="darkbg">
|
||||||
|
<h1 class="thread-title">Inbox</h1>
|
||||||
|
</div>
|
||||||
|
<div class="inbox-container">
|
||||||
|
<% if not all_subscriptions then %>
|
||||||
|
You have no subscriptions.<br>
|
||||||
|
<% else %>
|
||||||
|
Your subscriptions:
|
||||||
|
<ul>
|
||||||
|
<% for _, sub in ipairs(all_subscriptions) do %>
|
||||||
|
<li><a href="<%= url_for("thread", {slug = sub.thread_slug}) %>"><%= sub.thread_title %></a>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("thread_subscribe", {slug = sub.thread_slug}) %>">
|
||||||
|
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||||
|
<input class="warn" type="submit" value="Unsubscribe">
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
<% if #new_posts == 0 then %>
|
||||||
|
You have no unread posts.
|
||||||
|
<% else %>
|
||||||
|
You have <%= total_unreads_count %> unread post<%= total_unreads_count > 1 and "s" or "" %>:
|
||||||
|
<% for _, thread in ipairs(new_posts) do %>
|
||||||
|
<div class="accordion">
|
||||||
|
<div class="accordion-header">
|
||||||
|
<button type="button" class="accordion-toggle">▼</button>
|
||||||
|
<% local latest_post_id = thread.posts[#thread.posts].id %>
|
||||||
|
<%
|
||||||
|
local unread_posts_text = " (" .. thread.unread_count .. " unread post" .. (thread.unread_count > 1 and "s" or "")-- .. ")"
|
||||||
|
%>
|
||||||
|
<a class="accordion-title" href="<%= url_for("thread", {slug = thread.thread_slug}, {after = latest_post_id}) .. "#post-" .. latest_post_id %>" title="Jump to latest post"><%= thread.thread_title .. unread_posts_text %>, latest at <% render("views.common.timestamp", {timestamp = thread.newest_post_time}) -%>)</a>
|
||||||
|
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||||
|
<input type="hidden" name="subscribe" value="read">
|
||||||
|
<input type="submit" value="Mark Thread as Read">
|
||||||
|
</form>
|
||||||
|
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||||
|
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||||
|
<input class="warn" type="submit" value="Unsubscribe">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-content">
|
||||||
|
<% for _, post in ipairs(thread.posts) do %>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true, thread = get_thread_by_id(thread.thread_id)}) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
13
views/user/login.etlua
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<div class="darkbg login-container">
|
||||||
|
<h1>Log In</h1>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<form method="post" action="<%= url_for('user_login') %>" enctype="multipart/form-data">
|
||||||
|
<label for="username">Username</label><br>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username"><br>
|
||||||
|
<label for="password">Password</label><br>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
|
||||||
|
<input type="submit" value="Log in">
|
||||||
|
</form>
|
||||||
|
</div>
|
43
views/user/settings.etlua
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<% local disable_avatar = me:is_logged_in_guest() %>
|
||||||
|
<div class="darkbg settings-container">
|
||||||
|
<h1>User settings</h1>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<form class="avatar-form" method="post" action="<%= url_for("user_set_avatar", {username = me.username}) %>" enctype="multipart/form-data">
|
||||||
|
<img src="<%= avatar_url(me) %>">
|
||||||
|
<input id="file" type="file" name="avatar" accept="image/*" required>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Upload avatar" <%= disable_avatar and "disabled=disabled" %>>
|
||||||
|
<% if not me:is_default_avatar() then %>
|
||||||
|
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="">
|
||||||
|
<label for="topic_sort_by">Sort threads by:</label>
|
||||||
|
<select id="topic_sort_by" name="topic_sort_by">
|
||||||
|
<option value="activity" <%= session.sort_by == "activity" and "selected" %>>Latest activity</option>
|
||||||
|
<option value="thread" <%= session.sort_by == "thread" and "selected" %>>Thread creation date</option>
|
||||||
|
</select>
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="70" placeholder="Will be shown under your username. Max 70 characters">
|
||||||
|
<label for="babycode-content">Signature</label><br>
|
||||||
|
<% render("views.common.babycode-editor-component", {ta_name = "signature", prefill = me.signature_original_markup, ta_placeholder = "Will be shown under each of your posts", optional = true}) %>
|
||||||
|
<input autocomplete="off" type="checkbox" id="subscribe_by_default" name="subscribe_by_default" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||||
|
<label for="subscribe_by_default">Subscribe to thread by default when responding</label><br>
|
||||||
|
<input type="submit" value="Save settings">
|
||||||
|
</form>
|
||||||
|
<form method="post" action="<%= url_for("user_change_password", {username = me.username}) %>">
|
||||||
|
<label for="new_password">Change password</label><br>
|
||||||
|
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<label for="new_password2">Confirm new password</label><br>
|
||||||
|
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<input class="warn" type="submit" value="Change password">
|
||||||
|
</form>
|
||||||
|
<% if not me:is_admin() then %>
|
||||||
|
<div>
|
||||||
|
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
16
views/user/signup.etlua
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div class="darkbg login-container">
|
||||||
|
<h1>Sign up</h1>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<form method="post" action="<%= url_for('user_signup') %>" enctype="multipart/form-data">
|
||||||
|
<label for="username">Username</label><br>
|
||||||
|
<input type="text" id="username" name="username" pattern="[\w\-]{3,20}" title="3-20 characters. Only upper and lowercase letters, hyphens, and underscores" required autocomplete="username"><br>
|
||||||
|
<label for="password">Password</label><br>
|
||||||
|
<input type="password" id="password" name="password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<label for="password2">Confirm Password</label><br>
|
||||||
|
<input type="password" id="password2" name="password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<input type="submit" value="Sign up">
|
||||||
|
</form>
|
||||||
|
<p>After you sign up, a moderator will need to confirm your account before you will be allowed to post.</p>
|
||||||
|
</div>
|
90
views/user/user.etlua
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<div class="darkbg">
|
||||||
|
<h1 class="thread-title"><i><%= user.username %></i>'s profile</h1>
|
||||||
|
<% if user_is_me then -%>
|
||||||
|
<div class="user-actions">
|
||||||
|
<a class="linkbutton" href="<%= url_for("user_settings", {username = user.username}) %>">Settings</a>
|
||||||
|
<form method="post" action="<%= url_for("user_logout", {user_id = me.id}) %>">
|
||||||
|
<input class="warn" type="submit" value="Log out">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% if user:is_guest() then %>
|
||||||
|
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% if me:is_mod() and not user:is_system() then %>
|
||||||
|
<h1 class="thread-title">Moderator controls</h1>
|
||||||
|
<% if user:is_guest() then %>
|
||||||
|
<p>This user is a guest. They signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%>.</p>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
|
||||||
|
<input type="submit" value="Confirm user">
|
||||||
|
</form>
|
||||||
|
<% else %> <% --[[ user is not guest ]] %>
|
||||||
|
<p>This user signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%> and was confirmed on <% render("views.common.timestamp", {timestamp = user.confirmed_on}) %>.</p>
|
||||||
|
<% if user.permission < me.permission then %>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("guest_user", {user_id = user.id}) %>">
|
||||||
|
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
<% if me:is_admin() and not user:is_mod() then %>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("mod_user", {user_id = user.id}) %>">
|
||||||
|
<input class="warn" type="submit" value="Promote user to moderator">
|
||||||
|
</form>
|
||||||
|
<% elseif user:is_mod() and user.permission < me.permission then %>
|
||||||
|
<form class="modform" method="post" action="<%= url_for("demod_user", {user_id = user.id}) %>">
|
||||||
|
<input class="critical" type="submit" value="Demote user to regular user">
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-page-usercard">
|
||||||
|
<div class="usercard-inner">
|
||||||
|
<img class="avatar" src="<%= avatar_url(user) %>">
|
||||||
|
<strong class="big"><%= user.username %></strong>
|
||||||
|
<% if user.status ~= "" then %>
|
||||||
|
<em class="user-status"><%= user.status %></em>
|
||||||
|
<% end %>
|
||||||
|
<% if user.signature_rendered ~= "" then %>
|
||||||
|
Signature:
|
||||||
|
<div>
|
||||||
|
<%- user.signature_rendered %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-page-stats">
|
||||||
|
<ul class="user-stats-list">
|
||||||
|
<li>Permission: <%= PermissionLevelString[user.permission] %></li>
|
||||||
|
<li>Posts created: <%= stats.post_count %></li>
|
||||||
|
<li>Threads started: <%= stats.thread_count %></li>
|
||||||
|
<% if stats.latest_thread_title then %>
|
||||||
|
<li>Latest started thread: <a href="<%= url_for("thread", {slug = stats.latest_thread_slug}) %>"><%= stats.latest_thread_title %></a></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
Latest posts:
|
||||||
|
<div class="user-page-posts">
|
||||||
|
<% for _, post in ipairs(latest_posts) do %>
|
||||||
|
<div class="post-content-container">
|
||||||
|
<div class="post-info">
|
||||||
|
<% local post_url = get_post_url(post.id) %>
|
||||||
|
<a href="<%= post_url %>" title="Permalink"><i>
|
||||||
|
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||||
|
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%> in <%= post.thread_title %>
|
||||||
|
<% else -%>
|
||||||
|
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%> in <%= post.thread_title %>
|
||||||
|
<% end -%>
|
||||||
|
</i></a>
|
||||||
|
</div>
|
||||||
|
<div class="post-content wider user-page-post-preview">
|
||||||
|
<div class="post-inner"><%- post.content %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|